diff --git a/src/lib/components/HeroCard.svelte b/src/lib/components/HeroCard.svelte new file mode 100644 index 0000000..8ef51dc --- /dev/null +++ b/src/lib/components/HeroCard.svelte @@ -0,0 +1,69 @@ + + +
+

+ {#if icon}{icon}{/if} + {overline} +

+ + {#if line} +

+ {@render line()} +

+ {/if} + {@render children?.()} +
diff --git a/src/lib/components/Money.svelte b/src/lib/components/Money.svelte index a16a4b6..d843ff6 100644 --- a/src/lib/components/Money.svelte +++ b/src/lib/components/Money.svelte @@ -32,7 +32,8 @@ let text = $derived.by(() => { const body = formatMinor(minor < 0n ? -minor : minor, currency); - const prefix = minor < 0n ? '−' : sign ? '+' : ''; + // Zero carries no sign: "+$0.00" under Spending read as money coming in. + const prefix = minor < 0n ? '−' : sign && minor > 0n ? '+' : ''; // The sign goes with the digits when masked: a leading − would announce // "you're under", which is exactly what the mask is meant to withhold. return masked ? maskAmount(body) : prefix + body; diff --git a/src/lib/components/PeriodStepper.svelte b/src/lib/components/PeriodStepper.svelte new file mode 100644 index 0000000..9382b4e --- /dev/null +++ b/src/lib/components/PeriodStepper.svelte @@ -0,0 +1,66 @@ + + + + +{#snippet arrow(step: Step, aria: string, dir: 'prev' | 'next')} + {#if step === null} + + {:else if 'href' in step} + + {#if dir === 'prev'}{:else}{/if} + + {:else} + + {/if} +{/snippet} + +
+ {@render arrow(prev, prevLabel, 'prev')} +
+

{label}

+ {#if sublabel} + + {/if} +
+ {@render arrow(next, nextLabel, 'next')} +
diff --git a/src/lib/components/PlanHeader.svelte b/src/lib/components/PlanHeader.svelte new file mode 100644 index 0000000..d2ca2d5 --- /dev/null +++ b/src/lib/components/PlanHeader.svelte @@ -0,0 +1,64 @@ + + +
+
+

Plan

+
+ + + + {@render actions?.()} +
+
+ ({ + value: i.key, + label: i.label, + href: `/w/${slug}/${i.key}`, + title: i.hint + }))} + value={current} + ariaLabel="Plan section" + /> +
diff --git a/src/lib/components/PlanTabs.svelte b/src/lib/components/PlanTabs.svelte deleted file mode 100644 index 0082cf6..0000000 --- a/src/lib/components/PlanTabs.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - - -
- ({ - value: i.key, - label: i.label, - href: `/w/${slug}/${i.key}`, - title: i.hint - }))} - value={current} - fill={false} - ariaLabel="Plan section" - /> - - - -
diff --git a/src/lib/components/RecurringBreakdown.svelte b/src/lib/components/RecurringBreakdown.svelte index f3683fa..55d2483 100644 --- a/src/lib/components/RecurringBreakdown.svelte +++ b/src/lib/components/RecurringBreakdown.svelte @@ -44,7 +44,8 @@ */ import { ArrowRight } from '@lucide/svelte'; import { prefersReducedMotion } from 'svelte/motion'; - import Money from '$lib/components/Money.svelte'; + import HeroCard from '$lib/components/HeroCard.svelte'; + import Ribbon from '$lib/components/Ribbon.svelte'; import { formatMinor } from '$lib/money-format'; import { ledgerLink, NO_CATEGORY } from '$lib/ledger-filters'; @@ -87,14 +88,6 @@ return `${Math.round(f * 100)}%`; } - // The ribbon grows in from nothing once, on mount, so the proportions arrive - // as a gesture rather than a flash. Two frames so there is a from-state. - let drawn = $state(false); - $effect(() => { - const id = requestAnimationFrame(() => requestAnimationFrame(() => (drawn = true))); - return () => cancelAnimationFrame(id); - }); - // The chosen chip is brought into view — it may be the smallest category, // last on the rail, and arriving by link or by tapping the ribbon would // otherwise leave the one highlighted control off the edge of the screen. @@ -156,55 +149,31 @@ } -
- -

- {#if focus?.icon}{focus.icon}{/if} - {headline.overline} · per month -

- -

+ {#snippet line()} {formatMinor(headline.yearly, currency)} a year · {headline.line} -

+ {/snippet} {#if costs.length > 0} - - + ({ + key: c.key, + label: c.name, + color: c.color, + minor: c.monthlyMinor + }))} + focus={focus?.key ?? null} + onpick={pick} + /> -
- {#if data.hasPrev} - - - - {:else} -
- {/if} - {data.label} - {#if data.hasNext} - - - - {:else} -
- {/if} + +
+
{#if data.points.length === 0} diff --git a/src/routes/w/[workspace]/buckets/+page.svelte b/src/routes/w/[workspace]/buckets/+page.svelte index 4420f09..7ac431e 100644 --- a/src/routes/w/[workspace]/buckets/+page.svelte +++ b/src/routes/w/[workspace]/buckets/+page.svelte @@ -2,13 +2,15 @@ import { submit } from '$lib/actions/submit'; import { swipe } from '$lib/actions/swipe'; import { page } from '$app/state'; - import PlanTabs from '$lib/components/PlanTabs.svelte'; + import PlanHeader from '$lib/components/PlanHeader.svelte'; import { money } from '$lib/actions/money'; import { formatMinor, minorToDecimalInput, tryParseMinor } from '$lib/money-format'; import { overdraftBy } from '$lib/domain/bucket/flows'; import { formatPct } from '$lib/format'; import { Archive, CircleHelp, Pause, Pencil, Play, Plus, Wallet } from '@lucide/svelte'; import Money from '$lib/components/Money.svelte'; + import HeroCard from '$lib/components/HeroCard.svelte'; + import Ribbon from '$lib/components/Ribbon.svelte'; import RecurrencePicker from '$lib/components/RecurrencePicker.svelte'; import CheckField from '$lib/components/CheckField.svelte'; import Segmented from '$lib/components/Segmented.svelte'; @@ -148,6 +150,10 @@ allowanceFor = null; } + // What is still being topped up, on one per-month scale, for the headline. + const accruing = $derived(data.buckets.filter((b) => b.status === 'active')); + const accruingMonthly = $derived(accruing.reduce((sum, b) => sum + b.monthlyMinor, 0n)); + const overdrawn = $derived(data.buckets.filter((b) => b.balanceMinor < 0n)); const overdrawnTotal = $derived(overdrawn.reduce((sum, b) => sum - b.balanceMinor, 0n)); @@ -316,13 +322,16 @@ >
-

+

{b.name} {#if b.status === 'paused'}

-
+
{formatPct(progressPct(b))} of {formatMinor(b.goalCapMinor, b.currency)} {b.memberName}
@@ -447,7 +456,7 @@ method="POST" action="?/edit" use:submit={{ success: 'Changes saved', onSuccess: () => (editing = null) }} - class="mt-3 space-y-3 rounded-[14px] p-4" + class="mx-3 mb-3 space-y-3 rounded-[14px] p-4" style="background: var(--surface-2)" > @@ -518,12 +527,7 @@ {/if}
{/if} -

- Color -

+
{#each ACCENTS as c (c)} -
+ + {#snippet actions()} + + {/snippet} + {#if data.buckets.length > 0} - -
-
- - -
-
-
- - -
-
- - {#if overdrawn.length > 0} -

- {overdrawn.length === 1 - ? `${overdrawn[0].name} is ${formatMinor(-overdrawn[0].balanceMinor, overdrawn[0].currency)} overdrawn` - : `${overdrawn.length} buckets are overdrawn, ${formatMinor(overdrawnTotal, data.currency)} in total`} -

- {/if} + + + {#snippet line()} + {#if accruingMonthly > 0n} + +{formatMinor(accruingMonthly, data.currency)} a month into + {accruing.length} + {accruing.length === 1 ? 'bucket' : 'buckets'} + {:else} + Nothing set aside on a schedule right now + {/if} + · {formatMinor(data.lifetimeSavedMinor, data.currency)} saved over + time + {/snippet} + ({ + key: b.id, + label: b.name, + color: colorFor(b), + minor: b.balanceMinor + }))} + /> + + {#if overdrawn.length > 0} +

+ {overdrawn.length === 1 + ? `${overdrawn[0].name} is ${formatMinor(-overdrawn[0].balanceMinor, overdrawn[0].currency)} overdrawn` + : `${overdrawn.length} buckets are overdrawn, ${formatMinor(overdrawnTotal, data.currency)} in total`} +

+ {/if} +
{/if} {#if form?.error} @@ -757,9 +778,7 @@ {/if}

-

- Color -

+
{#each ACCENTS as c (c)}
+ {#if monthlyMinor > 0n || data.oneOffYearMinor > 0n} + + 0n ? 'Income · per month' : 'Income · last 12 months'} + minor={monthlyMinor > 0n ? monthlyMinor : data.oneOffYearMinor} + currency={data.currency} + tint="var(--approve)" + > + {#snippet line()} + {#if monthlyMinor > 0n} + {formatMinor(monthlyMinor * 12n, data.currency)} a year + · {monthly.length} + {monthly.length === 1 ? 'source' : 'sources'}{byPerson.length > 1 + ? ` from ${byPerson.length} people` + : ''} + {:else} + From one-off payments. Add a monthly one to see what comes in every month. + {/if} + {/snippet} + {#if byPerson.length > 1} + +
    + {#each byPerson as p (p.key)} +
  • + + {p.label} + {formatMinor(p.minor, data.currency)} +
  • + {/each} +
+ {/if} + {#if monthlyMinor > 0n && data.oneOffYearMinor > 0n} +

+ +{formatMinor(data.oneOffYearMinor, data.currency)} more in one-offs over the last 12 months +

+ {/if} +
+ {/if} + {#if form?.error}
+ r.entry.rrule === null && r.entry.receivedAt >= yearAgo && r.entry.receivedAt < startOfToday + ) + .reduce((sum, r) => sum + r.entry.amountMinor, 0n); return { + currency: ws.currency, + oneOffYearMinor, entries: rows.map((r) => { let freq: string | null = null; let monthDay: number | null = null; @@ -44,6 +56,7 @@ export async function load(ctx: WorkspaceContext, { params }: LoadEvent) { receivedDate: r.entry.receivedAt.toISOString().slice(0, 10), cadence: r.entry.rrule ? describe(r.entry.rrule) : null, note: r.entry.note, + memberId: r.entry.memberId, memberName: r.memberName, mine: r.entry.memberId === ctx.member.id, /* A monthly entry is a template, so it is never "past": it keeps diff --git a/src/routes/w/[workspace]/purchases/+page.svelte b/src/routes/w/[workspace]/purchases/+page.svelte index 7843397..68cc55b 100644 --- a/src/routes/w/[workspace]/purchases/+page.svelte +++ b/src/routes/w/[workspace]/purchases/+page.svelte @@ -314,45 +314,35 @@ }); } - // The "Bucket activity" toggle is remembered per user: it's a lasting - // preference about how you read the ledger, not a per-visit filter. - // Persisted to the DB so it follows you across devices; localStorage is a - // fast cache for the initial mount before the server round-trip completes. - const MOVEMENTS_KEY = 'ledger-movements'; - - /** Reloads from the server: it changes what gets paged over, not just shown. */ + /* + * The "Bucket activity" toggle is remembered per member, on the server: it's + * a lasting preference about how you read the ledger, not a per-visit + * filter, and the load reads it whenever the URL doesn't say. That is what + * lets the first paint carry the right rows. The page used to restore the + * choice from localStorage after mount, with a second navigation, so bucket + * rows popped in a beat after every arrival on the tab. + * + * The toggle writes the explicit value into the URL too, so the reload it + * triggers can't race the save. + */ function toggleMovements() { - const next = data.includeMovements ? '' : '1'; - const on = next === '1'; - try { - localStorage.setItem(MOVEMENTS_KEY, on ? '1' : '0'); - } catch { - /* storage unavailable — still applies for this session */ - } - // Persist to the server in the background — fire-and-forget so the toggle - // feels instant; the server-side load already reads from the DB so the - // next page load naturally reflects the saved value. + const on = !data.includeMovements; fetch(`/w/${page.params.workspace}/settings/member-flag`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ flag: 'includeLedgerMovements', value: on }) }).catch(() => { - /* silent — preference didn't save, but the toggle already moved */ + /* silent: the preference didn't save, but this view already applies it */ }); - void navigateWith({ movements: next }); + void navigateWith({ movements: on ? '1' : '0' }); } - // Apply the stored preference on first load, unless the URL already says - // (e.g. a shared or drill-through link, which wins and updates the pref). + // Retire the old client-side copy of the preference; the server holds it now. onMount(() => { try { - if (page.url.searchParams.has('movements')) { - localStorage.setItem(MOVEMENTS_KEY, data.includeMovements ? '1' : '0'); - } else if (localStorage.getItem(MOVEMENTS_KEY) === '1') { - void navigateWith({ movements: '1' }, { replace: true }); - } + localStorage.removeItem('ledger-movements'); } catch { - /* storage unavailable — no persistence this session */ + /* storage unavailable: nothing to clean up */ } }); @@ -556,8 +546,13 @@ // scope is the reactivity footgun svelte/prefer-svelte-reactivity warns // about. const qs = [...page.url.searchParams.entries()] - .filter(([k]) => k !== 'offset') - .concat([['offset', String(items.length)]]) + .filter(([k]) => k !== 'offset' && k !== 'movements') + // Said explicitly: page one may have taken it from the saved + // preference, and page two must match it even if that changed since. + .concat([ + ['offset', String(items.length)], + ['movements', data.includeMovements ? '1' : '0'] + ]) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join('&'); const res = await fetch(`/w/${slug}/purchases/data?${qs}`); @@ -1016,8 +1011,10 @@
{/if} +
-

Ledger

+

Ledger

{#if feedPending && items.length === 0} + {#if r.awaitingCount > 0} + To confirm + {/if}

- {r.cadence} · next {fmtNext(r.nextAt)}{r.autoComplete - ? '' - : ' · needs confirming'}{r.bucketName ? ` · ${r.bucketName}` : ''} + {r.cadence} · next {fmtNext(r.nextAt)}{r.bucketName + ? ` · ${r.bucketName}` + : ''}

@@ -724,6 +742,13 @@
{#if r.mine}
+ {#if r.status === 'active'}
@@ -745,13 +770,6 @@
{/if} -
(editing = null) }} - class="mt-3 space-y-3 rounded-[14px] p-4" + class="mx-3 mb-3 space-y-3 rounded-[14px] p-4" style="background: var(--surface-2)" > @@ -1002,7 +1020,7 @@ method="POST" action="?/restart" use:submit={{ success: 'Started again', onSuccess: () => (restarting = null) }} - class="mt-3 space-y-3 rounded-[14px] p-4" + class="mx-3 mb-3 space-y-3 rounded-[14px] p-4" style="background: var(--surface-2)" > diff --git a/src/routes/w/[workspace]/recurring/handlers.ts b/src/routes/w/[workspace]/recurring/handlers.ts index 6b50ebe..941a367 100644 --- a/src/routes/w/[workspace]/recurring/handlers.ts +++ b/src/routes/w/[workspace]/recurring/handlers.ts @@ -57,8 +57,10 @@ export async function load(ctx: WorkspaceContext, { params }: { params: { worksp listBuckets(db, ctx.workspace.id, { viewerId: ctx.member.id, now: ctx.deps.clock.now() }), // My recurring charges that landed but still need the real amount recorded — // the ledger's "Confirm what you paid" section is where you clear them. + // Counted per rule, so a row can say it has a charge waiting rather than + // every confirm-yourself rule saying so whether or not one has landed. db - .select({ count: count() }) + .select({ ruleId: purchase.recurringRuleId, count: count() }) .from(purchase) .where( and( @@ -67,7 +69,8 @@ export async function load(ctx: WorkspaceContext, { params }: { params: { worksp eq(purchase.state, 'approved'), isNotNull(purchase.recurringRuleId) ) - ), + ) + .groupBy(purchase.recurringRuleId), // What each rule has actually cost, for the lifetime figure on an ended // row. Same convention as repo/analytics.ts: the final amount summed over // completed and refunded rows, so a partial refund subtracts and a fully @@ -140,6 +143,9 @@ export async function load(ctx: WorkspaceContext, { params }: { params: { worksp // One shape for every rule the page renders. Ended rows reuse it wholesale: // they need the same name, price and cadence, and the same schedule fields, // because starting one again opens the very form an edit does. + const awaiting = new Map( + confirmRow.flatMap((c) => (c.ruleId ? [[c.ruleId, c.count] as const] : [])) + ); const toView = (r: (typeof rules)[number]) => { let parsed: Recurrence | null = null; try { @@ -168,6 +174,8 @@ export async function load(ctx: WorkspaceContext, { params }: { params: { worksp nextAt: r.nextOccurrenceAt?.toISOString() ?? null, status: r.status, autoComplete: r.autoComplete, + // Your charges from this rule still waiting on the real amount. + awaitingCount: awaiting.get(r.id) ?? 0, categoryId: r.categoryId, bucketId: r.bucketId, bucketName: r.bucketId ? (bucketNames.get(r.bucketId) ?? null) : null, @@ -247,7 +255,7 @@ export async function load(ctx: WorkspaceContext, { params }: { params: { worksp chargedYearMinor: [...charged.values()].reduce((s, v) => s + v, 0n), chargedFrom: formatCalDate(chargedWindow.from), chargedTo: formatCalDate(chargedWindow.to), - needsConfirmingCount: confirmRow[0].count, + needsConfirmingCount: confirmRow.reduce((n, r) => n + r.count, 0), rules: view, past, categories: categories.map((c) => ({ diff --git a/src/routes/w/[workspace]/settings/categories/+page.svelte b/src/routes/w/[workspace]/settings/categories/+page.svelte index aee5ef2..21522d8 100644 --- a/src/routes/w/[workspace]/settings/categories/+page.svelte +++ b/src/routes/w/[workspace]/settings/categories/+page.svelte @@ -257,7 +257,7 @@ {#if data.custom.length > 0} - + {#if owner} @@ -322,7 +322,7 @@ class="flex items-center gap-3 px-4 py-2.5" style={i > 0 ? 'box-shadow: inset 0 0.5px 0 var(--hairline)' : ''} > - {c.icon ?? '📦'} + {c.icon ?? '📦'} {c.name} {c.purchases}{data.builtIn.length} - +
{#each data.builtIn as c, i (c.id)}
0 ? 'box-shadow: inset 0 0.5px 0 var(--hairline)' : ''} > - {c.icon ?? '📦'} + {c.icon ?? '📦'} {c.name} {#each groups as group (group.label)} - -
- {#each group.sections as s (s.id)} - {@const SIcon = HELP_ICONS[s.icon] ?? HELP_ICONS.card} -
- - + +
+ {#each group.sections as s, i (s.id)} + {@const SIcon = HELP_ICONS[s.icon] ?? HELP_ICONS.card} +
0 ? 'box-shadow: inset 0 0.5px 0 var(--hairline)' : ''}> + - - - {s.title} - - -
- {#each s.body as para (para)} -

- - {@html render(para)} -

- {/each} -
-
- {/each} -
+ + + + {s.title} + +
+
+ {#each s.body as para (para)} +

+ + {@html render(para)} +

+ {/each} +
+
+ {/each} +
+
{/each}

diff --git a/src/routes/w/[workspace]/statement/+page.svelte b/src/routes/w/[workspace]/statement/+page.svelte index 63e2ce2..e8b2667 100644 --- a/src/routes/w/[workspace]/statement/+page.svelte +++ b/src/routes/w/[workspace]/statement/+page.svelte @@ -1,17 +1,11 @@