From 100fe116b45004e911214e50d8309bb2655438a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:36:52 +0000 Subject: [PATCH 1/2] Plan reads like every other tab, and its pages open on one statement - Plan's masthead sits where every tab's does, with the calendar and the page's actions beside it and a full-width Recurring | Buckets switch under it (PlanTabs becomes PlanHeader). - Buckets and Income open on the same hero Recurring does: a shared HeroCard and Ribbon, with on-hand by bucket and monthly income by person. - The recurring chip rail no longer snaps "All" flush to the card edge. - Bucket activity no longer pops into the ledger after arrival: an absent `movements` param now defers to the saved preference on the server, so the first paint has the rows and the post-mount second navigation is gone. - Inline edit forms are inset inside their cards; list tiles, action order and small meta text match across Recurring, Buckets and Income. - New purchase: the currency symbol hugs the amount. Statement: a zero figure carries no "+". Categories: the "Bought" caption no longer clips. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019iNojndGyPD9Kt2Db6gCCf --- src/lib/components/HeroCard.svelte | 69 ++++++++++ src/lib/components/Money.svelte | 3 +- src/lib/components/PlanHeader.svelte | 64 +++++++++ src/lib/components/PlanTabs.svelte | 49 ------- src/lib/components/RecurringBreakdown.svelte | 106 ++++---------- src/lib/components/Ribbon.svelte | 106 ++++++++++++++ src/lib/ledger-query.test.ts | 21 +++ src/lib/ledger-query.ts | 17 ++- src/routes/w/[workspace]/buckets/+page.svelte | 130 ++++++++++-------- src/routes/w/[workspace]/buckets/handlers.ts | 5 + src/routes/w/[workspace]/income/+page.svelte | 84 ++++++++++- src/routes/w/[workspace]/income/handlers.ts | 13 ++ .../w/[workspace]/purchases/+page.svelte | 53 ++++--- .../w/[workspace]/purchases/data/handlers.ts | 6 +- .../w/[workspace]/purchases/handlers.ts | 10 +- .../w/[workspace]/purchases/new/+page.svelte | 12 +- .../w/[workspace]/recurring/+page.svelte | 33 +++-- .../settings/categories/+page.svelte | 8 +- 18 files changed, 546 insertions(+), 243 deletions(-) create mode 100644 src/lib/components/HeroCard.svelte create mode 100644 src/lib/components/PlanHeader.svelte delete mode 100644 src/lib/components/PlanTabs.svelte create mode 100644 src/lib/components/Ribbon.svelte create mode 100644 src/lib/ledger-query.test.ts 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 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/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 @@ } -
- - - -

+ {#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 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 +775,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..489c51f 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}`); diff --git a/src/routes/w/[workspace]/purchases/data/handlers.ts b/src/routes/w/[workspace]/purchases/data/handlers.ts index 3f44d81..81146be 100644 --- a/src/routes/w/[workspace]/purchases/data/handlers.ts +++ b/src/routes/w/[workspace]/purchases/data/handlers.ts @@ -14,8 +14,12 @@ export async function GET(ctx: WorkspaceContext, { url }: { url: URL }) { const now = ctx.deps.clock.now(); const db = ctx.db; const scope = { workspaceId: ctx.workspace.id, viewerId: ctx.member.id }; + const opts = ledgerOptsFromUrl(url.searchParams, ctx.workspace.timezone); const feed = await listLedger(db, scope, now, { - ...ledgerOptsFromUrl(url.searchParams, ctx.workspace.timezone), + ...opts, + // Page two must be filtered exactly like page one, which fell back to the + // saved preference when the URL didn't say. + includeMovements: opts.includeMovements ?? ctx.member.includeLedgerMovements, limit: 20, // Clamped: a negative OFFSET is a Postgres error, not an empty page. offset: Math.max(0, parseInt(url.searchParams.get('offset') ?? '0') || 0) diff --git a/src/routes/w/[workspace]/purchases/handlers.ts b/src/routes/w/[workspace]/purchases/handlers.ts index 6d02a08..b804987 100644 --- a/src/routes/w/[workspace]/purchases/handlers.ts +++ b/src/routes/w/[workspace]/purchases/handlers.ts @@ -26,7 +26,13 @@ export async function load(ctx: WorkspaceContext, { url, params }: LoadEvent) { const ws = ctx.workspace; // Filters are a URL concern, not client state: they change what the server // pages over, and they make the view shareable and restorable. - const opts = ledgerOptsFromUrl(url.searchParams, ws.timezone); + const parsed = ledgerOptsFromUrl(url.searchParams, ws.timezone); + // The URL wins when it says; otherwise this member's saved preference. Read + // here, on the server, so the first paint already has the right rows. + const opts = { + ...parsed, + includeMovements: parsed.includeMovements ?? ctx.member.includeLedgerMovements + }; const scope = { workspaceId: ws.id, viewerId: ctx.member.id }; /* @@ -176,7 +182,7 @@ export async function load(ctx: WorkspaceContext, { url, params }: LoadEvent) { members: membersAll .filter((m) => m.member.status === 'active') .map((m) => ({ id: m.member.id, name: m.user.displayName })), - includeMovements: opts.includeMovements ?? ctx.member.includeLedgerMovements, + includeMovements: opts.includeMovements, // How much of the headline this member wants legible on arrival. Server-side // so a masked number never renders before the client can hide it. safeToSpendDisplay: toDiscretionMode(ctx.member.safeToSpendDisplay), diff --git a/src/routes/w/[workspace]/purchases/new/+page.svelte b/src/routes/w/[workspace]/purchases/new/+page.svelte index b463723..cb93dfa 100644 --- a/src/routes/w/[workspace]/purchases/new/+page.svelte +++ b/src/routes/w/[workspace]/purchases/new/+page.svelte @@ -362,6 +362,14 @@ } if (v.image) attachImage(v.image); } + /* + * The amount field hugs what's typed. At a fixed 6ch the empty "0" sat in + * the middle of a wide box and the currency symbol two digits' width away + * from it, so "$ 0" read as two things. Sized to the text (plus a sliver for + * the caret), the symbol stays beside the digits as they grow. Capped where + * the card runs out of room. + */ + const amountWidth = $derived(`${Math.min(9, Math.max(1, (amount || '0').length)) + 0.3}ch`); const symbol = $derived( (0) .toLocaleString(undefined, { style: 'currency', currency: data.workspace.currency }) @@ -583,8 +591,8 @@ pattern="[0-9]*\.?[0-9]*" placeholder="0" autocomplete="off" - class="w-[6ch] border-none bg-transparent p-0 text-center font-[family-name:var(--font-display)] text-[56px] leading-none font-bold tracking-tight tabular-nums outline-none placeholder:text-(--ink-4)" - style="color: var(--ink)" + class="border-none bg-transparent p-0 text-center font-[family-name:var(--font-display)] text-[56px] leading-none font-bold tracking-tight tabular-nums outline-none placeholder:text-(--ink-4)" + style="color: var(--ink); width: {amountWidth}" /> {#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} -
- {#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 6b1ac50..7ac431e 100644 --- a/src/routes/w/[workspace]/buckets/+page.svelte +++ b/src/routes/w/[workspace]/buckets/+page.svelte @@ -328,7 +328,10 @@
-

+

{b.name} {#if b.status === 'paused'} {/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}` + : ''}

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/help/+page.svelte b/src/routes/w/[workspace]/settings/help/+page.svelte index 57686ac..d5408e3 100644 --- a/src/routes/w/[workspace]/settings/help/+page.svelte +++ b/src/routes/w/[workspace]/settings/help/+page.svelte @@ -323,35 +323,44 @@

+ {#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 @@