A self-hosted household ledger.
0

Configure Feed

Select the types of activity you want to include in your feed.

add spending pie chart with poline-generated per-category palette

author graham.systems date (Jul 16, 2026, 6:28 PM -0700) commit e46d790c parent 34dc9e57 change-id pwzomzon
+432 -10
+16 -5
DESIGN.md
··· 54 54 Red (`--neg`) appears in chrome only when money or data is at risk; an expense 55 55 figure in a table is plain ink, not red. 56 56 57 - ### Chart categoricals (colorblind-safe ordering, muted chroma) 57 + ### Chart colors 58 58 59 - `--chart-1` `oklch(0.55 0.13 180)` (validated ≥0.1 chroma vs light surface; dark: `oklch(0.65 0.12 190)`) · `--chart-2` `oklch(0.65 0.11 75)` · 60 - `--chart-3` `oklch(0.55 0.10 300)` · `--chart-4` `oklch(0.68 0.10 130)` · 61 - `--chart-5` `oklch(0.58 0.11 25)` · `--chart-6` `oklch(0.62 0.08 250)` · 62 - `--chart-7` `oklch(0.70 0.09 55)`. Dark theme: same hues, lightness +0.10. 59 + - `--chart-1` `oklch(0.55 0.13 180)` light / `oklch(0.65 0.12 190)` dark — the 60 + single-series mark color (net worth line). Validated ≥0.1 chroma and 3:1 61 + contrast per surface. (`--chart-2…7` remain as spare series tokens.) 62 + - **Per-category palette** `--cat-0` … `--cat-11`: each category owns a stable 63 + palette index (`categories.color_index`, assigned at creation, never 64 + recomputed from position). Generated with 65 + [poline](https://github.com/meodai/poline) via 66 + `scripts/generate-category-palette.ts` — a closed-loop hue journey anchored 67 + at spruce/amber/plum, normalized into OKLCH L 0.54–0.62, C ≥ 0.10 so one set 68 + passes the lightness band, chroma floor, and 3:1 contrast on BOTH surfaces 69 + (no dark override). Neighboring slots are interleaved to maximize hue 70 + distance; charts additionally separate fills with 2px surface gaps and 71 + always carry a legend, so identity never rests on hue alone. Neutral 72 + (`--text-faint`) is reserved for "Other" and "Uncategorized" — never a 73 + category. 63 74 64 75 ## Typography 65 76
+5
deno.lock
··· 11 11 "npm:@sveltejs/kit@^2.63.0": "2.69.2_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.4__vite@8.1.4___@types+node@26.1.1__@types+node@26.1.1_svelte@5.56.4_typescript@6.0.3_vite@8.1.4__@types+node@26.1.1_@types+node@26.1.1", 12 12 "npm:@sveltejs/vite-plugin-svelte@^7.1.2": "7.2.0_svelte@5.56.4_vite@8.1.4__@types+node@26.1.1_@types+node@26.1.1", 13 13 "npm:@types/node@^26.1.1": "26.1.1", 14 + "npm:poline@~0.13.1": "0.13.1", 14 15 "npm:svelte-check@*": "4.7.2_svelte@5.56.4_typescript@6.0.3", 15 16 "npm:svelte-check@^4.6.0": "4.7.2_svelte@5.56.4_typescript@6.0.3", 16 17 "npm:svelte@^5.56.1": "5.56.4", ··· 832 833 "picomatch@4.0.5": { 833 834 "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==" 834 835 }, 836 + "poline@0.13.1": { 837 + "integrity": "sha512-CCK9R8mSBoaxlbVDfeTAmWbw2or6WEpHL1uNHyJH9CYppVWlcLUUPAOf40mHcKGLLBmGb6NdWse+ZEeArpbG8Q==" 838 + }, 835 839 "postcss@8.5.17": { 836 840 "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", 837 841 "dependencies": [ ··· 1048 1052 "npm:@sveltejs/kit@^2.63.0", 1049 1053 "npm:@sveltejs/vite-plugin-svelte@^7.1.2", 1050 1054 "npm:@types/node@^26.1.1", 1055 + "npm:poline@~0.13.1", 1051 1056 "npm:svelte-check@^4.6.0", 1052 1057 "npm:svelte@^5.56.1", 1053 1058 "npm:typescript@^6.0.3",
+6
migrations/002_category_colors.sql
··· 1 + -- Stable per-category chart color: an index into the --cat-N palette tokens. 2 + -- Color follows the entity — assigned once, never recomputed from position. 3 + 4 + ALTER TABLE categories ADD COLUMN color_index INTEGER; 5 + 6 + UPDATE categories SET color_index = (id - 1) % 12;
+1
package.json
··· 23 23 "@sveltejs/kit": "^2.63.0", 24 24 "@sveltejs/vite-plugin-svelte": "^7.1.2", 25 25 "@types/node": "^26.1.1", 26 + "poline": "^0.13.1", 26 27 "svelte": "^5.56.1", 27 28 "svelte-check": "^4.6.0", 28 29 "typescript": "^6.0.3",
+157
scripts/generate-category-palette.ts
··· 1 + /// <reference lib="deno.ns" /> 2 + // Design-time generator for the per-category chart palette (--cat-0 … --cat-11). 3 + // 4 + // Poline (meodai/poline) draws the hue journey between anchors; each stop is 5 + // then normalized into a fixed OKLCH band (L 0.54–0.62, C 0.10–0.14, reduced 6 + // only if out of sRGB gamut) so a single palette passes the dataviz checks — 7 + // chroma floor, lightness band, and 3:1 contrast — on BOTH the light and dark 8 + // surfaces. Category colors are assigned by persisted index (color follows the 9 + // entity), so regenerate only with intent: existing assignments keep their 10 + // index but change appearance. 11 + // 12 + // Usage: deno run -A scripts/generate-category-palette.ts 13 + 14 + import { Poline } from 'poline'; 15 + 16 + const COUNT = 12; 17 + const L_MIN = 0.54; 18 + const L_MAX = 0.62; 19 + const C_MIN = 0.1; 20 + const C_MAX = 0.14; 21 + 22 + // Anchors tour the wheel starting in Quantum's spruce family, through warm 23 + // amber, into plum — wide hue coverage for CVD-distinguishable neighbors. 24 + const poline = new Poline({ 25 + numPoints: 10, 26 + closedLoop: true, // tour the whole wheel: spruce → amber → plum → back 27 + anchorColors: [ 28 + [178, 0.62, 0.42], // spruce 29 + [42, 0.72, 0.5], // amber 30 + [292, 0.45, 0.55] // plum 31 + ] 32 + }); 33 + 34 + // --- OKLab <-> sRGB (Björn Ottosson's reference math) --- 35 + 36 + function srgbToLinear(c: number): number { 37 + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); 38 + } 39 + 40 + function linearToSrgb(c: number): number { 41 + return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; 42 + } 43 + 44 + function rgbToOklch(r: number, g: number, b: number): [number, number, number] { 45 + const [lr, lg, lb] = [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)]; 46 + const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); 47 + const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); 48 + const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); 49 + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; 50 + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; 51 + const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; 52 + return [L, Math.hypot(a, bb), (Math.atan2(bb, a) * 180) / Math.PI]; 53 + } 54 + 55 + function oklchToRgb(L: number, C: number, hDeg: number): [number, number, number] | null { 56 + const h = (hDeg * Math.PI) / 180; 57 + const a = C * Math.cos(h); 58 + const b = C * Math.sin(h); 59 + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; 60 + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; 61 + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; 62 + const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; 63 + const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; 64 + const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; 65 + const rgb = [lr, lg, lb].map(linearToSrgb); 66 + if (rgb.some((c) => c < -0.001 || c > 1.001)) return null; 67 + return rgb.map((c) => Math.min(1, Math.max(0, c))) as [number, number, number]; 68 + } 69 + 70 + function hslToRgb(h: number, s: number, l: number): [number, number, number] { 71 + const k = (n: number) => (n + h / 30) % 12; 72 + const a = s * Math.min(l, 1 - l); 73 + const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); 74 + return [f(0), f(8), f(4)]; 75 + } 76 + 77 + function toHex(rgb: [number, number, number]): string { 78 + return '#' + rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join(''); 79 + } 80 + 81 + // --- normalize each Poline stop into the validated band --- 82 + 83 + // All candidate OKLCH hues from the Poline journey. 84 + const candidateHues = (poline.colors as [number, number, number][]).map(([h, s, l]) => { 85 + const [, , hue] = rgbToOklch(...hslToRgb(h, s, l)); 86 + return (hue + 360) % 360; 87 + }); 88 + 89 + // Greedy selection of COUNT hues maximizing minimum pairwise hue distance, 90 + // seeded from the first stop (the spruce family). 91 + function hueDist(a: number, b: number): number { 92 + const d = Math.abs(a - b) % 360; 93 + return Math.min(d, 360 - d); 94 + } 95 + const selected: number[] = [candidateHues[0]]; 96 + while (selected.length < COUNT) { 97 + let best = -1; 98 + let bestScore = -1; 99 + for (const hue of candidateHues) { 100 + const score = Math.min(...selected.map((s) => hueDist(hue, s))); 101 + if (score > bestScore) { 102 + bestScore = score; 103 + best = hue; 104 + } 105 + } 106 + selected.push(best); 107 + } 108 + 109 + // Interleave sorted hues (0,6,1,7,…) so *adjacent palette slots* are far 110 + // apart in hue — that adjacency is what the CVD check exercises. 111 + selected.sort((a, b) => a - b); 112 + const half = Math.ceil(COUNT / 2); 113 + const ordered = Array.from({ length: COUNT }, (_, i) => 114 + i % 2 === 0 ? selected[i / 2] : selected[half + (i - 1) / 2] 115 + ); 116 + 117 + const results: { oklch: string; hex: string }[] = []; 118 + 119 + for (let i = 0; i < COUNT; i++) { 120 + const hue = ordered[i]; 121 + const preferredL = L_MIN + ((L_MAX - L_MIN) * (i % 3)) / 2; // slight in-band variation 122 + 123 + // Find the highest chroma (≥ floor) that stays in sRGB gamut, searching the 124 + // preferred lightness first, then the rest of the band. 125 + const lCandidates = [preferredL, 0.54, 0.56, 0.58, 0.6, 0.62, 0.52, 0.5]; 126 + let found: { L: number; C: number; rgb: [number, number, number] } | null = null; 127 + for (const L of lCandidates) { 128 + for (let C = C_MAX; C >= C_MIN - 0.001; C -= 0.005) { 129 + const rgb = oklchToRgb(L, C, hue); 130 + if (rgb) { 131 + found = { L, C: Math.max(C, C_MIN), rgb }; 132 + break; 133 + } 134 + } 135 + if (found) break; 136 + } 137 + if (!found) { 138 + // pathological hue: take the floor chroma at mid lightness, sub-floor if forced 139 + for (let C = C_MIN; C >= 0.02; C -= 0.005) { 140 + const rgb = oklchToRgb(0.56, C, hue); 141 + if (rgb) { 142 + found = { L: 0.56, C, rgb }; 143 + break; 144 + } 145 + } 146 + } 147 + const final = found!; 148 + results.push({ 149 + oklch: `oklch(${final.L.toFixed(2)} ${final.C.toFixed(3)} ${((hue + 360) % 360).toFixed(0)})`, 150 + hex: toHex(final.rgb) 151 + }); 152 + } 153 + 154 + console.log('/* CSS tokens (same palette both themes — validated for both surfaces) */'); 155 + results.forEach((r, i) => console.log(`\t--cat-${i}: ${r.oklch};`)); 156 + console.log('\n# hex list for validate_palette.js'); 157 + console.log(results.map((r) => r.hex).join(','));
+15
src/app.css
··· 34 34 35 35 --shadow-overlay: 0 4px 16px oklch(0 0 0 / 0.1); 36 36 37 + /* per-category palette (poline-generated, validated for BOTH surfaces — 38 + single set, no dark override; regenerate: deno run -A scripts/generate-category-palette.ts) */ 39 + --cat-0: oklch(0.54 0.14 16); 40 + --cat-1: oklch(0.58 0.1 218); 41 + --cat-2: oklch(0.62 0.14 61); 42 + --cat-3: oklch(0.54 0.12 240); 43 + --cat-4: oklch(0.58 0.115 83); 44 + --cat-5: oklch(0.62 0.14 275); 45 + --cat-6: oklch(0.54 0.11 104); 46 + --cat-7: oklch(0.58 0.14 308); 47 + --cat-8: oklch(0.62 0.14 143); 48 + --cat-9: oklch(0.54 0.14 328); 49 + --cat-10: oklch(0.6 0.1 191); 50 + --cat-11: oklch(0.62 0.14 354); 51 + 37 52 /* typography */ 38 53 --font-ui: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif; 39 54 --font-mono: ui-monospace, 'Cascadia Mono', Consolas, monospace;
+154
src/lib/components/SpendingPie.svelte
··· 1 + <script lang="ts"> 2 + import { formatCents } from '$lib/format'; 3 + 4 + interface SpendSlice { 5 + label: string; 6 + /** Positive cents of spend. */ 7 + cents: number; 8 + /** Palette token index, or null for neutral (Other / Uncategorized). */ 9 + colorIndex: number | null; 10 + href?: string; 11 + } 12 + 13 + let { slices }: { slices: SpendSlice[] } = $props(); 14 + 15 + const SIZE = 220; 16 + const R = 100; 17 + const CX = SIZE / 2; 18 + const CY = SIZE / 2; 19 + 20 + let hovered = $state<number | null>(null); 21 + 22 + let total = $derived(slices.reduce((sum, s) => sum + s.cents, 0)); 23 + 24 + let arcs = $derived.by(() => { 25 + let angle = -Math.PI / 2; // start at 12 o'clock 26 + return slices.map((slice) => { 27 + const sweep = total > 0 ? (slice.cents / total) * Math.PI * 2 : 0; 28 + const start = angle; 29 + angle += sweep; 30 + const large = sweep > Math.PI ? 1 : 0; 31 + const x1 = CX + R * Math.cos(start); 32 + const y1 = CY + R * Math.sin(start); 33 + const x2 = CX + R * Math.cos(angle); 34 + const y2 = CY + R * Math.sin(angle); 35 + const path = 36 + slices.length === 1 37 + ? `M${CX},${CY - R} A${R},${R} 0 1 1 ${CX - 0.01},${CY - R} Z` 38 + : `M${CX},${CY} L${x1.toFixed(2)},${y1.toFixed(2)} A${R},${R} 0 ${large} 1 ${x2.toFixed(2)},${y2.toFixed(2)} Z`; 39 + return { path, slice }; 40 + }); 41 + }); 42 + 43 + function color(slice: SpendSlice): string { 44 + return slice.colorIndex === null ? 'var(--text-faint)' : `var(--cat-${slice.colorIndex})`; 45 + } 46 + 47 + function pct(cents: number): string { 48 + return total > 0 ? `${Math.round((cents / total) * 100)}%` : ''; 49 + } 50 + </script> 51 + 52 + {#if slices.length > 0 && total > 0} 53 + <div class="pie"> 54 + <svg 55 + viewBox="0 0 {SIZE} {SIZE}" 56 + role="img" 57 + aria-label="Spending by category, {formatCents(total)} total" 58 + > 59 + {#each arcs as arc, i (arc.slice.label)} 60 + <path 61 + role="presentation" 62 + d={arc.path} 63 + fill={color(arc.slice)} 64 + class="slice" 65 + class:dim={hovered !== null && hovered !== i} 66 + onpointerenter={() => (hovered = i)} 67 + onpointerleave={() => (hovered = null)} 68 + /> 69 + {/each} 70 + </svg> 71 + 72 + <div class="legend" role="list"> 73 + {#each slices as slice, i (slice.label)} 74 + <div 75 + class="entry" 76 + role="listitem" 77 + class:dim={hovered !== null && hovered !== i} 78 + onpointerenter={() => (hovered = i)} 79 + onpointerleave={() => (hovered = null)} 80 + > 81 + <span class="swatch" style:background={color(slice)}></span> 82 + {#if slice.href} 83 + <a href={slice.href}>{slice.label}</a> 84 + {:else} 85 + <span>{slice.label}</span> 86 + {/if} 87 + <span class="value tnum">{formatCents(slice.cents)}</span> 88 + <span class="pct tnum">{pct(slice.cents)}</span> 89 + </div> 90 + {/each} 91 + </div> 92 + </div> 93 + {/if} 94 + 95 + <style> 96 + .pie { 97 + display: flex; 98 + gap: var(--space-6); 99 + align-items: center; 100 + flex-wrap: wrap; 101 + margin-top: var(--space-4); 102 + } 103 + 104 + svg { 105 + width: 200px; 106 + height: 200px; 107 + flex-shrink: 0; 108 + } 109 + 110 + .slice { 111 + /* 2px surface gap between fills: identity never rests on hue alone */ 112 + stroke: var(--bg); 113 + stroke-width: 2; 114 + transition: opacity var(--dur-micro) var(--ease-out); 115 + } 116 + 117 + .dim { 118 + opacity: 0.35; 119 + } 120 + 121 + .legend { 122 + display: flex; 123 + flex-direction: column; 124 + gap: var(--space-1); 125 + min-width: 240px; 126 + flex: 1; 127 + } 128 + 129 + .entry { 130 + display: grid; 131 + grid-template-columns: 12px 1fr auto 3.5ch; 132 + gap: var(--space-2); 133 + align-items: center; 134 + font-size: var(--text-xs); 135 + padding: 2px var(--space-1); 136 + border-radius: var(--radius-sm); 137 + transition: opacity var(--dur-micro) var(--ease-out); 138 + } 139 + 140 + .swatch { 141 + width: 10px; 142 + height: 10px; 143 + border-radius: 3px; 144 + } 145 + 146 + .value { 147 + font-weight: 500; 148 + } 149 + 150 + .pct { 151 + color: var(--text-faint); 152 + text-align: right; 153 + } 154 + </style>
+30 -5
src/lib/server/services/categories.ts
··· 2 2 3 3 export type CategoryKind = 'income' | 'expense' | 'transfer'; 4 4 5 + export const CATEGORY_PALETTE_SIZE = 12; 6 + 5 7 export interface Category { 6 8 id: number; 7 9 name: string; 8 10 kind: CategoryKind; 9 11 builtin: boolean; 10 12 active: boolean; 13 + /** Index into the --cat-N palette tokens; assigned once at creation. */ 14 + colorIndex: number; 11 15 } 12 16 13 17 export function listCategories( ··· 16 20 ): Category[] { 17 21 const rows = db 18 22 .prepare( 19 - `SELECT id, name, kind, builtin, active FROM categories 23 + `SELECT id, name, kind, builtin, active, color_index FROM categories 20 24 ${options.activeOnly ? 'WHERE active = 1' : ''} 21 25 ORDER BY kind, name` 22 26 ) ··· 26 30 name: r.name as string, 27 31 kind: r.kind as CategoryKind, 28 32 builtin: r.builtin === 1, 29 - active: r.active === 1 33 + active: r.active === 1, 34 + colorIndex: (r.color_index as number | null) ?? ((r.id as number) - 1) % CATEGORY_PALETTE_SIZE 30 35 })); 31 36 } 32 37 38 + /** Least-used palette slot (lowest index wins ties) so colors stay distinct as long as possible. */ 39 + function nextColorIndex(db: DatabaseSync): number { 40 + const rows = db 41 + .prepare('SELECT color_index, COUNT(*) AS n FROM categories GROUP BY color_index') 42 + .all() as { color_index: number | null; n: number }[]; 43 + const usage = new Array(CATEGORY_PALETTE_SIZE).fill(0); 44 + for (const row of rows) { 45 + if (row.color_index != null) usage[row.color_index % CATEGORY_PALETTE_SIZE] += row.n; 46 + } 47 + return usage.indexOf(Math.min(...usage)); 48 + } 49 + 33 50 export function createCategory(db: DatabaseSync, name: string, kind: CategoryKind): Category { 34 51 const trimmed = name.trim(); 35 52 if (!trimmed) throw new Error('Category name is required.'); 53 + const colorIndex = nextColorIndex(db); 36 54 const result = db 37 - .prepare('INSERT INTO categories (name, kind, created_at) VALUES (?, ?, ?)') 38 - .run(trimmed, kind, new Date().toISOString()); 39 - return { id: Number(result.lastInsertRowid), name: trimmed, kind, builtin: false, active: true }; 55 + .prepare('INSERT INTO categories (name, kind, color_index, created_at) VALUES (?, ?, ?, ?)') 56 + .run(trimmed, kind, colorIndex, new Date().toISOString()); 57 + return { 58 + id: Number(result.lastInsertRowid), 59 + name: trimmed, 60 + kind, 61 + builtin: false, 62 + active: true, 63 + colorIndex 64 + }; 40 65 } 41 66 42 67 export function renameCategory(db: DatabaseSync, id: number, name: string): void {
src/lib/server/services/reports.ts

This is a binary file and will not be displayed.

+48
src/routes/(app)/reports/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import NetWorthChart from '$lib/components/NetWorthChart.svelte'; 3 + import SpendingPie from '$lib/components/SpendingPie.svelte'; 3 4 import { formatCents, formatMonth } from '$lib/format'; 4 5 5 6 let { data } = $props(); 7 + 8 + const MAX_SLICES = 6; 9 + 10 + interface SpendSlice { 11 + label: string; 12 + cents: number; 13 + colorIndex: number | null; 14 + href?: string; 15 + } 16 + 17 + let spendSlices = $derived.by((): SpendSlice[] => { 18 + if (!data.report) return []; 19 + const spend: SpendSlice[] = data.report.expense 20 + .filter((c) => c.totalCents < 0) 21 + .map((c) => ({ 22 + label: c.name, 23 + cents: -c.totalCents, 24 + colorIndex: c.colorIndex, 25 + href: `/ledger?month=${data.month}&category=${c.categoryId}` 26 + })) 27 + .sort((a, b) => b.cents - a.cents); 28 + 29 + const slices = spend.slice(0, MAX_SLICES); 30 + const rest = spend.slice(MAX_SLICES); 31 + if (rest.length > 0) { 32 + slices.push({ 33 + label: `Other (${rest.length})`, 34 + cents: rest.reduce((sum, s) => sum + s.cents, 0), 35 + colorIndex: null, 36 + href: `/ledger?month=${data.month}` 37 + }); 38 + } 39 + if (data.report.uncategorized.totalCents < 0) { 40 + slices.push({ 41 + label: 'Uncategorized', 42 + cents: -data.report.uncategorized.totalCents, 43 + colorIndex: null, 44 + href: `/ledger?month=${data.month}&category=uncategorized` 45 + }); 46 + } 47 + return slices; 48 + }); 6 49 </script> 7 50 8 51 <h1>Reports</h1> ··· 37 80 </span> 38 81 </div> 39 82 </div> 83 + 84 + {#if spendSlices.length > 0} 85 + <h3>Spending</h3> 86 + <SpendingPie slices={spendSlices} /> 87 + {/if} 40 88 41 89 {#each [{ title: 'Income', rows: data.report.income }, { title: 'Expenses', rows: data.report.expense }] as section (section.title)} 42 90 {#if section.rows.length > 0}