= {};
+ for (const e of monthly) {
+ const t = (totals[e.memberId] ??= { name: e.memberName, minor: 0n });
+ t.minor += e.amountMinor;
+ }
+ const sorted = Object.entries(totals).sort((a, b) =>
+ a[1].minor > b[1].minor ? -1 : a[1].minor < b[1].minor ? 1 : 0
+ );
+ return sorted.map(([key, t], i) => ({
+ key,
+ label: t.name,
+ minor: t.minor,
+ color: `color-mix(in oklab, var(--approve) ${Math.max(35, 100 - i * 22)}%, var(--surface-2))`
+ }));
+ });
+
function startEdit(e: (typeof data.entries)[number]) {
editing = editing === e.id ? null : e.id;
if (editing === null) return;
@@ -117,7 +145,7 @@
>
@@ -169,7 +197,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)"
>
@@ -222,6 +250,56 @@
+ {#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}
+
startEdit(r)}
+ class="press inline-flex items-center gap-1"
+ style="color: var(--ink-2)"
+ >
+ Edit
+
{#if r.status === 'active'}
{/if}
-
startEdit(r)}
- class="press inline-flex items-center gap-1"
- style="color: var(--ink-2)"
- >
- Edit
-
{#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)}
-
{group.label}
-
- {#each group.sections as s (s.id)}
- {@const SIcon = HELP_ICONS[s.icon] ?? HELP_ICONS.card}
-
-
-
+ {group.label}
+
+ {#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 @@