Compare commits

...

5 commits

Author SHA1 Message Date
58cf5cb762 refactor(libra): drop status icons, add Income/Expense badges
Per the iteration: the title-row status icons (green check / yellow
clock / red X) were doing the same job as the new status badges and
amount color, so each row had three signals fighting for the same
meaning. Drop the icons and lean on badges instead.

Badge row order (left-to-right): Voided > Income/Expense > Pending >
user tags. Type badge sits between the high-attention Voided marker
and the secondary Pending marker, so the type chip is easy to spot
on every row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 23:05:55 +02:00
b483674ebe refactor(libra): collapse type filter + voided switch into category chips
Each chip toggles inclusion of one bucket of rows. Every row belongs to
exactly one bucket — voided rows go to the Voided bucket regardless of
their underlying type — so the model is straightforward:

  [Income] [Expenses] [Voided]
     |         |          |
  income     expense    voided
  (non-voided only)    (any type)

Defaults: Income + Expenses on, Voided off. Independent multi-select.
Empty selection shows the empty state with a 'select a category' hint
instead of an open-ended 'try a different time period'.

Replaces the previous 'type radio + voided switch' pair: same axes, one
control type, no left/right visual split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 23:00:28 +02:00
dd71d10564 feat(libra): hide voided by default + add Pending badge
Voided transactions are noise in the day-to-day view (the user already
saw and rejected them), so default to hiding them. A Switch in the filter
row toggles 'Show voided'. When voided are present but hidden, the
results-count line shows '(N voided hidden)' so the toggle has a
discoverable hook.

Pending entries gain a yellow Pending badge symmetric with the red
Voided badge — both signal 'needs attention' states in the badge row,
while cleared entries stay unmarked (the default, quiet state).

Status / type encoding (icon = status, signed/colored amount = type)
is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 22:51:43 +02:00
fd269f97ea fix(libra): drop colored status border to remove color collision
The status border (green/yellow/red) and the type-tinted amount (green
income, red expense) both used the same palette, so cleared expenses
showed a green border with a red amount and pending income showed a
yellow border with a green amount — same colors carrying two different
meanings on the same row.

Concentrate the encoding so each meaning has one home:
- Status lives in the icon (small, single glyph at the title)
- Type lives in the amount (sign + red/green tint)
- Voided still wins via strike-through + muted amount + Voided badge

Per Wise / Mint / YNAB convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 22:43:48 +02:00
5db2dbe8a8 refactor(libra): encode status on border, type on signed amount
Original styling used the left border for entry-type (green=income,
red=expense), which clashed visually with the status icons: a red
border on a pending expense suggested "rejected". Split the visual
channels so each conveys one thing:

  - Left border + status icon = workflow state (green accepted,
    yellow pending, red rejected/voided)
  - Signed/tinted amount = type (+green income, -red expense)
  - Strike-through + muted amount = voided
  - Badges = user-added tags only; income-entry / expense-entry
    suppressed (redundant with the amount), Voided kept as a
    high-signal status badge

Follows the conventional bank-statement / personal-finance encoding
(Wise, Mint, YNAB), where amount sign carries direction and chrome
carries state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 22:33:49 +02:00

View file

@ -12,10 +12,6 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
CheckCircle2,
Clock,
Flag,
XCircle,
RefreshCw, RefreshCw,
Calendar, Calendar,
Filter Filter
@ -30,14 +26,26 @@ const isLoading = ref(false)
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom' const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
const customStartDate = ref<string>('') const customStartDate = ref<string>('')
const customEndDate = ref<string>('') const customEndDate = ref<string>('')
const typeFilter = ref<'all' | 'income' | 'expense'>('all') // Each chip is an inclusion toggle for one bucket of rows. Every row
// belongs to exactly one bucket (voided rows go to 'voided' regardless
// of their income/expense type). Default hides voided.
type Category = 'income' | 'expense' | 'voided'
const typeFilterOptions = [ const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
{ label: 'All', value: 'all' as const },
{ label: 'Income', value: 'income' as const }, const categoryChips: { label: string; value: Category }[] = [
{ label: 'Expenses', value: 'expense' as const } { label: 'Income', value: 'income' },
{ label: 'Expenses', value: 'expense' },
{ label: 'Voided', value: 'voided' }
] ]
function toggleCategory(cat: Category) {
const next = new Set(activeCategories.value)
if (next.has(cat)) next.delete(cat)
else next.add(cat)
activeCategories.value = next
}
function isIncome(t: Transaction): boolean { function isIncome(t: Transaction): boolean {
return t.tags?.includes('income-entry') ?? false return t.tags?.includes('income-entry') ?? false
} }
@ -50,6 +58,18 @@ function isVoided(t: Transaction): boolean {
return t.tags?.includes('voided') ?? false return t.tags?.includes('voided') ?? false
} }
function isPending(t: Transaction): boolean {
return t.flag === '!' && !isVoided(t)
}
// Which chip bucket a row falls into. Voided always wins over type.
function getBucket(t: Transaction): Category | null {
if (isVoided(t)) return 'voided'
if (isIncome(t)) return 'income'
if (isExpense(t)) return 'expense'
return null
}
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey) const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
// Fuzzy search state and configuration // Fuzzy search state and configuration
@ -75,12 +95,13 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
matchAllWhenSearchEmpty: true matchAllWhenSearchEmpty: true
} }
// Transactions to display (search results or all transactions), filtered by type // Transactions to display: row passes if its bucket's chip is active.
const transactionsToDisplay = computed(() => { const transactionsToDisplay = computed(() => {
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
if (typeFilter.value === 'income') return base.filter(isIncome) return base.filter(t => {
if (typeFilter.value === 'expense') return base.filter(isExpense) const bucket = getBucket(t)
return base return bucket !== null && activeCategories.value.has(bucket)
})
}) })
// Handle search results // Handle search results
@ -112,23 +133,28 @@ function formatAmount(amount: number): string {
return new Intl.NumberFormat('en-US').format(amount) return new Intl.NumberFormat('en-US').format(amount)
} }
// Get status icon and color. Voided entries (per libra convention) keep // Income gets a leading '+', expense a leading '-'.
// flag='!' and carry a 'voided' tag surface that as the icon regardless function getAmountSign(t: Transaction): string {
// of the underlying flag. if (isIncome(t)) return '+'
function getStatusInfo(transaction: Transaction) { if (isExpense(t)) return '-'
if (isVoided(transaction)) { return ''
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
} }
switch (transaction.flag) {
case '*': // Color tint for the amount text. Voided entries drop to muted regardless
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' } // of type since the strike-through carries the "ignore this" signal.
case '!': function getAmountColorClass(t: Transaction): string {
return { icon: Clock, color: 'text-orange-600', label: 'Pending' } if (isVoided(t)) return 'line-through text-muted-foreground'
case '#': if (isIncome(t)) return 'text-green-600 dark:text-green-400'
return { icon: Flag, color: 'text-red-600', label: 'Flagged' } if (isExpense(t)) return 'text-red-600 dark:text-red-400'
default: return ''
return null
} }
// Tags that drive other visual channels (border / sign / strike-through)
// suppressed from the badge row so it only carries user-added tags.
const TYPE_TAGS = new Set(['income-entry', 'expense-entry', 'voided'])
function getDisplayTags(t: Transaction): string[] {
return (t.tags ?? []).filter(tag => !TYPE_TAGS.has(tag))
} }
// Load transactions // Load transactions
@ -231,19 +257,20 @@ onMounted(() => {
</Button> </Button>
</div> </div>
<!-- Type Filter (All / Income / Expenses) --> <!-- Category chips: each chip toggles inclusion of one bucket
of rows. Defaults: Income + Expenses on, Voided off. -->
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<Filter class="h-4 w-4 text-muted-foreground" /> <Filter class="h-4 w-4 text-muted-foreground" />
<Button <Button
v-for="option in typeFilterOptions" v-for="chip in categoryChips"
:key="option.value" :key="chip.value"
:variant="typeFilter === option.value ? 'default' : 'outline'" :variant="activeCategories.has(chip.value) ? 'default' : 'outline'"
size="sm" size="sm"
class="h-7 md:h-8 px-3 text-xs" class="h-7 md:h-8 px-3 text-xs"
@click="typeFilter = option.value" @click="toggleCategory(chip.value)"
:disabled="isLoading" :disabled="isLoading"
> >
{{ option.label }} {{ chip.label }}
</Button> </Button>
</div> </div>
@ -298,7 +325,7 @@ onMounted(() => {
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }} Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span> </span>
<span v-else> <span v-else>
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }} {{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span> </span>
</div> </div>
@ -314,7 +341,9 @@ onMounted(() => {
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4"> <div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
<p class="text-muted-foreground">No transactions found</p> <p class="text-muted-foreground">No transactions found</p>
<p class="text-sm text-muted-foreground mt-2"> <p class="text-sm text-muted-foreground mt-2">
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }} <template v-if="searchResults.length > 0">Try a different search term</template>
<template v-else-if="activeCategories.size === 0">Select a category above to see transactions</template>
<template v-else>Try selecting a different time period or toggling more categories</template>
</p> </p>
</div> </div>
@ -323,34 +352,19 @@ onMounted(() => {
<div <div
v-for="transaction in transactionsToDisplay" v-for="transaction in transactionsToDisplay"
:key="transaction.id" :key="transaction.id"
:class="[ class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
isIncome(transaction) && 'border-l-4 border-l-green-600',
isExpense(transaction) && 'border-l-4 border-l-red-600'
]"
> >
<!-- Transaction Header --> <!-- Transaction Header -->
<div class="flex items-start justify-between gap-3 mb-2"> <div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<!-- Status Icon -->
<component
v-if="getStatusInfo(transaction)"
:is="getStatusInfo(transaction)!.icon"
:class="[
'h-4 w-4 flex-shrink-0',
getStatusInfo(transaction)!.color
]"
/>
<h3 <h3
:class="[ :class="[
'font-medium text-sm sm:text-base truncate', 'font-medium text-sm sm:text-base truncate mb-1',
isVoided(transaction) && 'line-through text-muted-foreground' isVoided(transaction) && 'line-through text-muted-foreground'
]" ]"
> >
{{ transaction.description }} {{ transaction.description }}
</h3> </h3>
</div>
<p class="text-xs sm:text-sm text-muted-foreground"> <p class="text-xs sm:text-sm text-muted-foreground">
{{ formatDate(transaction.date) }} {{ formatDate(transaction.date) }}
</p> </p>
@ -358,22 +372,17 @@ onMounted(() => {
<!-- Amount --> <!-- Amount -->
<div class="text-right flex-shrink-0"> <div class="text-right flex-shrink-0">
<p <p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]">
:class="[ {{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
'font-semibold text-sm sm:text-base',
isVoided(transaction) && 'line-through text-muted-foreground'
]"
>
{{ formatAmount(transaction.amount) }} sats
</p> </p>
<p <p
v-if="transaction.fiat_amount" v-if="transaction.fiat_amount"
:class="[ :class="[
'text-xs text-muted-foreground', 'text-xs',
isVoided(transaction) && 'line-through' getAmountColorClass(transaction) || 'text-muted-foreground'
]" ]"
> >
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }} {{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
</p> </p>
</div> </div>
</div> </div>
@ -390,20 +399,44 @@ onMounted(() => {
<span class="font-medium">Ref:</span> {{ transaction.reference }} <span class="font-medium">Ref:</span> {{ transaction.reference }}
</div> </div>
<!-- Tags --> <!-- Badges: Voided (leftmost, high-signal) + type (Income /
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2"> Expense) + Pending (after type) + any user-added tags. -->
<div class="flex flex-wrap gap-1 mt-2">
<Badge <Badge
v-for="tag in transaction.tags" v-if="isVoided(transaction)"
variant="secondary"
class="text-xs bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300"
>
Voided
</Badge>
<Badge
v-if="isIncome(transaction)"
variant="secondary"
class="text-xs bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300"
>
Income
</Badge>
<Badge
v-else-if="isExpense(transaction)"
variant="secondary"
class="text-xs bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300"
>
Expense
</Badge>
<Badge
v-if="isPending(transaction)"
variant="secondary"
class="text-xs bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300"
>
Pending
</Badge>
<Badge
v-for="tag in getDisplayTags(transaction)"
:key="tag" :key="tag"
variant="secondary" variant="secondary"
:class="[ class="text-xs"
'text-xs',
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300',
tag === 'voided' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
]"
> >
{{ tag === 'voided' ? 'Voided' : tag }} {{ tag }}
</Badge> </Badge>
</div> </div>
</div> </div>