diff --git a/.gitignore b/.gitignore index 3c2b7e6..2631a35 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ yarn-error.log* # Local only /data /docs/superpowers +packages/convex/scripts/*.xlsx .playwright-mcp +.claude diff --git a/apps/budget/.prettierignore b/apps/budget/.prettierignore index b7c7d6d..e34b9de 100644 --- a/apps/budget/.prettierignore +++ b/apps/budget/.prettierignore @@ -3,3 +3,4 @@ pnpm-lock.yaml yarn.lock convex/_generated +**/routeTree.gen.ts diff --git a/apps/budget/src/components/budget/BudgetBreakdownTable.tsx b/apps/budget/src/components/budget/BudgetBreakdownTable.tsx new file mode 100644 index 0000000..6edc7fe --- /dev/null +++ b/apps/budget/src/components/budget/BudgetBreakdownTable.tsx @@ -0,0 +1,93 @@ +import { formatCurrency } from '@/lib/budget'; + +export interface BreakdownRowData { + date: number; + income: number; + spend: number; + mortgage: number | null; + net: number; +} + +interface Props { + rows: Array | undefined; + onRowClick: (date: number) => void; +} + +function monthLabel(date: number): string { + return new Date(date).toLocaleString('en-AU', { + month: 'short', + year: 'numeric' + }); +} + +function SkeletonRows() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ); +} + +export default function BudgetBreakdownTable({ rows, onRowClick }: Props) { + return ( +
+

+ Monthly breakdown +

+ {!rows ? ( + + ) : rows.length === 0 ? ( +

No budget rows.

+ ) : ( +
+ + + + + + + + + + + + {rows.map((r) => ( + onRowClick(r.date)} + className="cursor-pointer border-t border-warm-border hover:bg-warm-bg-card transition-colors" + > + + + + + + + ))} + +
MonthIncomeSpendMortgageNet
+ {monthLabel(r.date)} + + {formatCurrency(r.income)} + + {formatCurrency(r.spend)} + + {r.mortgage === null ? '—' : formatCurrency(r.mortgage)} + = 0 ? 'text-warm-positive' : 'text-warm-negative' + }`} + > + {r.net >= 0 ? '+' : ''} + {formatCurrency(r.net)} +
+
+ )} +
+ ); +} diff --git a/apps/budget/src/components/budget/BudgetChart.tsx b/apps/budget/src/components/budget/BudgetChart.tsx index ffda52d..0d343bf 100644 --- a/apps/budget/src/components/budget/BudgetChart.tsx +++ b/apps/budget/src/components/budget/BudgetChart.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { Group } from '@visx/group'; import { scaleBand, scaleLinear } from '@visx/scale'; import { AxisBottom, AxisLeft } from '@visx/axis'; @@ -8,7 +7,6 @@ import { useTooltip } from '@visx/tooltip'; import BudgetChartBars from './BudgetChartBars'; import BudgetChartLines from './BudgetChartLines'; import BudgetChartTooltip from './BudgetChartTooltip'; -import BudgetChartFilters from './BudgetChartFilters'; import type { BudgetDataPoint, TimePeriod } from '@/lib/budget'; import { computeMovingAverage, @@ -24,15 +22,15 @@ const CHART_HEIGHT = 520; interface BudgetChartProps { data: Array; + period: TimePeriod; } function BudgetChartInner({ data, + period, width, height }: BudgetChartProps & { width: number; height: number }) { - const [period, setPeriod] = useState('ALL'); - const { tooltipData, tooltipLeft, @@ -46,9 +44,9 @@ function BudgetChartInner({ if (filtered.length === 0) { return ( -
-

No budget data

-

+

+

No budget data

+

Seed the budget table to render the chart.

@@ -111,106 +109,117 @@ function BudgetChartInner({ if (innerWidth <= 0 || innerHeight <= 0) return null; return ( -
+
-
+

+ Income vs Spending +

+
Spend
Sink or Swim
-
- - - +
+ + + - + - + - formatDateLabel(date)} - tickValues={filtered - .map((d) => d.date) - .filter((_, i) => i % labelInterval === 0)} - tickLabelProps={() => ({ - fill: '#6b7280', - fontSize: 11, - textAnchor: 'end', - dy: '0.25em', - dx: '-0.5em', - angle: -45 - })} - stroke="#d1d5db" - tickStroke="#d1d5db" - hideTicks={false} - /> + formatDateLabel(date)} + tickValues={filtered + .map((d) => d.date) + .filter((_, i) => i % labelInterval === 0)} + tickLabelProps={() => ({ + fill: '#7C6755', + fontSize: 11, + textAnchor: 'end', + dy: '0.25em', + dx: '-0.5em', + angle: -45 + })} + stroke="#EFE3D2" + tickStroke="#EFE3D2" + hideTicks={false} + /> - formatCurrency(v as number)} - tickLabelProps={() => ({ - fill: '#6b7280', - fontSize: 11, - textAnchor: 'end', - dx: '-0.5em', - dy: '0.33em' - })} - stroke="#d1d5db" - tickStroke="#d1d5db" - numTicks={6} + formatCurrency(v as number)} + tickLabelProps={() => ({ + fill: '#7C6755', + fontSize: 11, + textAnchor: 'end', + dx: '-0.5em', + dy: '0.33em' + })} + stroke="#EFE3D2" + tickStroke="#EFE3D2" + numTicks={6} + /> + + + + {tooltipOpen && tooltipData && ( + - - - - {tooltipOpen && tooltipData && ( - - )} + )} +
); } -export default function BudgetChart({ data }: BudgetChartProps) { - return ; +export default function BudgetChart({ data, period }: BudgetChartProps) { + return ( + + ); } diff --git a/apps/budget/src/components/budget/BudgetChartBars.tsx b/apps/budget/src/components/budget/BudgetChartBars.tsx index 0095c17..e198e62 100644 --- a/apps/budget/src/components/budget/BudgetChartBars.tsx +++ b/apps/budget/src/components/budget/BudgetChartBars.tsx @@ -39,7 +39,7 @@ export default function BudgetChartBars({ y={y} width={bandwidth} height={Math.max(0, barHeight)} - fill="rgba(100, 149, 237, 0.5)" + fill="#D8E9D2" onMouseMove={(e) => onMouseMove(e as React.MouseEvent, d) } @@ -61,7 +61,7 @@ export default function BudgetChartBars({ y={y} width={bandwidth} height={Math.max(0, barHeight)} - fill="rgba(250, 128, 114, 0.5)" + fill="#FFDFC7" onMouseMove={(e) => onMouseMove(e as React.MouseEvent, d) } diff --git a/apps/budget/src/components/budget/BudgetChartFilters.tsx b/apps/budget/src/components/budget/BudgetChartFilters.tsx index a7775d7..2517707 100644 --- a/apps/budget/src/components/budget/BudgetChartFilters.tsx +++ b/apps/budget/src/components/budget/BudgetChartFilters.tsx @@ -1,7 +1,12 @@ import type { TimePeriod } from '@/lib/budget'; import { cn } from '@/lib/utils'; -const periods: Array = ['1Y', '3Y', '5Y', 'ALL']; +const PERIODS: Array<{ id: TimePeriod; label: string }> = [ + { id: '3M', label: '3 mo' }, + { id: '6M', label: '6 mo' }, + { id: '12M', label: '12 mo' }, + { id: 'ALL', label: 'All' } +]; interface BudgetChartFiltersProps { selected: TimePeriod; @@ -13,21 +18,29 @@ export default function BudgetChartFilters({ onChange }: BudgetChartFiltersProps) { return ( -
- {periods.map((p) => ( - - ))} +
+ {PERIODS.map((p) => { + const active = selected === p.id; + return ( + + ); + })}
); } diff --git a/apps/budget/src/components/budget/BudgetChartLines.tsx b/apps/budget/src/components/budget/BudgetChartLines.tsx index 8e63b87..c785f35 100644 --- a/apps/budget/src/components/budget/BudgetChartLines.tsx +++ b/apps/budget/src/components/budget/BudgetChartLines.tsx @@ -28,7 +28,7 @@ export default function BudgetChartLines({ data={sinkOrSwimTrend} x={(d) => (xScale(d.date) ?? 0) + halfBand} y={(d) => yScale(d.value)} - stroke="rgb(65, 105, 225)" + stroke="#5F9466" strokeWidth={2} curve={curveMonotoneX} /> @@ -36,7 +36,7 @@ export default function BudgetChartLines({ data={spendTrend} x={(d) => (xScale(d.date) ?? 0) + halfBand} y={(d) => yScale(d.value)} - stroke="rgb(220, 50, 50)" + stroke="#D85A36" strokeWidth={2} curve={curveMonotoneX} /> diff --git a/apps/budget/src/components/budget/BudgetChartTooltip.tsx b/apps/budget/src/components/budget/BudgetChartTooltip.tsx index 6ba4531..a2bc848 100644 --- a/apps/budget/src/components/budget/BudgetChartTooltip.tsx +++ b/apps/budget/src/components/budget/BudgetChartTooltip.tsx @@ -22,28 +22,30 @@ export default function BudgetChartTooltip({ left={left} style={{ position: 'absolute', - backgroundColor: 'white', - border: '1px solid #e2e8f0', - borderRadius: '6px', - padding: '8px 12px', - fontSize: '13px', - lineHeight: '1.5', - boxShadow: '0 2px 8px rgba(0,0,0,0.12)', - pointerEvents: 'none' + backgroundColor: '#FFFCF6', + border: '1px solid #EFE3D2', + borderRadius: '12px', + padding: '10px 14px', + fontSize: '12px', + lineHeight: '1.55', + boxShadow: '0 12px 32px rgba(61,46,34,0.16)', + pointerEvents: 'none', + fontFamily: 'DM Sans, system-ui, sans-serif', + color: '#3D2E22' }} > -
{formatDateLabel(date)}
-
+
{formatDateLabel(date)}
+
Spend: {formatCurrency(spend)}
-
+
Sink or Swim: {formatCurrency(sinkOrSwim)}
diff --git a/apps/budget/src/components/budget/BudgetKpiCards.tsx b/apps/budget/src/components/budget/BudgetKpiCards.tsx new file mode 100644 index 0000000..9b64062 --- /dev/null +++ b/apps/budget/src/components/budget/BudgetKpiCards.tsx @@ -0,0 +1,60 @@ +import { KpiCard } from './KpiCard'; +import type { BudgetPageSummary } from '@repo/convex/budgetSummary'; + +interface Props { + summary: BudgetPageSummary | undefined; +} + +function SkeletonRow() { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ); +} + +export default function BudgetKpiCards({ summary }: Props) { + if (!summary) return ; + + return ( +
+ + + + +
+ ); +} diff --git a/apps/budget/src/components/budget/InsightsPanel.tsx b/apps/budget/src/components/budget/InsightsPanel.tsx new file mode 100644 index 0000000..6f8ddbf --- /dev/null +++ b/apps/budget/src/components/budget/InsightsPanel.tsx @@ -0,0 +1,22 @@ +import { Sparkles } from 'lucide-react'; + +export default function InsightsPanel() { + return ( + + ); +} diff --git a/apps/budget/src/components/budget/KpiCard.tsx b/apps/budget/src/components/budget/KpiCard.tsx new file mode 100644 index 0000000..75bebd9 --- /dev/null +++ b/apps/budget/src/components/budget/KpiCard.tsx @@ -0,0 +1,73 @@ +import { ArrowDown, ArrowUp } from 'lucide-react'; +import { formatCurrency } from '@/lib/budget'; + +export interface KpiCardProps { + label: string; + /** Cents, or basis points if `kind === 'rate'`. */ + value: number; + delta: number | null; + deltaPct: number | null; + kind: 'money' | 'rate'; + periodLabel: string; +} + +function formatValue(value: number, kind: 'money' | 'rate'): string { + if (kind === 'rate') { + // basis points -> percentage with 1 decimal + return `${(value / 100).toFixed(1)}%`; + } + return formatCurrency(value); +} + +function formatDelta( + delta: number | null, + deltaPct: number | null, + kind: 'money' | 'rate' +): string | null { + if (delta === null) return null; + if (kind === 'rate') { + return `${delta >= 0 ? '+' : ''}${(delta / 100).toFixed(1)}pp`; + } + if (deltaPct === null) return formatCurrency(delta); + return `${delta >= 0 ? '+' : ''}${deltaPct.toFixed(1)}%`; +} + +export function KpiCard({ + label, + value, + delta, + deltaPct, + kind, + periodLabel +}: KpiCardProps) { + const deltaLabel = formatDelta(delta, deltaPct, kind); + const positive = (delta ?? 0) >= 0; + + return ( +
+
+ {label} +
+
+ {formatValue(value, kind)} +
+ {deltaLabel ? ( +
+ {positive ? : } + {deltaLabel} + + vs prior {periodLabel} + +
+ ) : ( +
+ vs prior {periodLabel} +
+ )} +
+ ); +} diff --git a/apps/budget/src/components/budget/MonthIncomeSection.tsx b/apps/budget/src/components/budget/MonthIncomeSection.tsx new file mode 100644 index 0000000..8d7f3af --- /dev/null +++ b/apps/budget/src/components/budget/MonthIncomeSection.tsx @@ -0,0 +1,51 @@ +import { formatCurrency } from '@/lib/budget'; + +interface Props { + primary: number; + secondary: number; + billContrib: number; +} + +export default function MonthIncomeSection({ + primary, + secondary, + billContrib +}: Props) { + return ( +
+
+

+ Income +

+ + {formatCurrency(primary + secondary + billContrib)} + +
+
+

+ Income contributors +

+
    +
  • + Primary + + {formatCurrency(primary)} + +
  • +
  • + Secondary + + {formatCurrency(secondary)} + +
  • +
  • + Bill contributions + + {formatCurrency(billContrib)} + +
  • +
+
+
+ ); +} diff --git a/apps/budget/src/components/budget/MonthMortgageSection.tsx b/apps/budget/src/components/budget/MonthMortgageSection.tsx new file mode 100644 index 0000000..24e753b --- /dev/null +++ b/apps/budget/src/components/budget/MonthMortgageSection.tsx @@ -0,0 +1,78 @@ +import { formatCurrency } from '@/lib/budget'; + +interface Props { + contribTotal: number; + interestCharged: number; + principalPaid: number; + debt1: number; + debt2: number; + equity: number; +} + +export default function MonthMortgageSection({ + contribTotal, + interestCharged, + principalPaid, + debt1, + debt2, + equity +}: Props) { + return ( +
+
+

+ Mortgage +

+ + {formatCurrency(contribTotal)} + +
+ +
+

+ Payment split +

+
    +
  • + Interest charged + + {formatCurrency(interestCharged)} + +
  • +
  • + Principal paid + + {formatCurrency(principalPaid)} + +
  • +
+
+ +
+

+ Debt and equity context +

+
    +
  • + Debt 1 + + {formatCurrency(debt1)} + +
  • +
  • + Debt 2 + + {formatCurrency(debt2)} + +
  • +
  • + House equity + + {formatCurrency(equity)} + +
  • +
+
+
+ ); +} diff --git a/apps/budget/src/components/budget/MonthSpendSection.tsx b/apps/budget/src/components/budget/MonthSpendSection.tsx new file mode 100644 index 0000000..8ee36ef --- /dev/null +++ b/apps/budget/src/components/budget/MonthSpendSection.tsx @@ -0,0 +1,64 @@ +import { formatCurrency } from '@/lib/budget'; + +interface Props { + credit1: number; + credit2: number; + credit3: number; + oneOffs: number; +} + +const CATEGORY_STUBS = [ + 'Groceries', + 'Dining out', + 'Transport', + 'Retail, bills & health' +]; + +export default function MonthSpendSection({ + credit1, + credit2, + credit3, + oneOffs +}: Props) { + const creditSubtotal = credit1 + credit2 + credit3; + const total = creditSubtotal + oneOffs; + + return ( +
+
+

+ Spend +

+ + {formatCurrency(total)} + +
+ +
+
+ + Credit card categories + + + {formatCurrency(creditSubtotal)} + +
+
    + {CATEGORY_STUBS.map((cat) => ( +
  • + {cat} + +
  • + ))} +
+
+ +
+ One-offs + + {formatCurrency(oneOffs)} + +
+
+ ); +} diff --git a/apps/budget/src/components/budget/MonthlyDetailOverlay.tsx b/apps/budget/src/components/budget/MonthlyDetailOverlay.tsx new file mode 100644 index 0000000..550107a --- /dev/null +++ b/apps/budget/src/components/budget/MonthlyDetailOverlay.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { X } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface Props { + open: boolean; + monthLabel: string; + subtitle?: string; + onClose: () => void; + children: ReactNode; +} + +export default function MonthlyDetailOverlay({ + open, + monthLabel, + subtitle, + onClose, + children +}: Props) { + const dialogRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + dialogRef.current?.focus(); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open || typeof document === 'undefined') return null; + + return createPortal( +
+
+
e.stopPropagation()} + className="relative w-full max-w-5xl max-h-[90vh] overflow-auto rounded-[28px] bg-warm-bg-card p-7 shadow-[0_24px_60px_rgba(61,46,34,0.25)] border border-warm-border outline-none" + > +
+
+
+ + $ + +
+
+

+ {monthLabel} +

+ {subtitle ? ( +

+ {subtitle} +

+ ) : null} +
+
+ +
+ {children} +
+
, + document.body + ); +} diff --git a/apps/budget/src/components/budget/SummaryMini.test.tsx b/apps/budget/src/components/budget/SummaryMini.test.tsx new file mode 100644 index 0000000..32bf1df --- /dev/null +++ b/apps/budget/src/components/budget/SummaryMini.test.tsx @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import SummaryMini from './SummaryMini'; + +describe('SummaryMini', () => { + it('renders label and value', () => { + render(); + expect(screen.getByText('Income')).toBeDefined(); + // formatCurrency divides cents by 100 and formats in en-AU + expect(screen.getByText(/\$/)).toBeDefined(); + }); + + it('omits trend row when trend is null', () => { + const { container } = render( + + ); + expect(container.querySelector('svg')).toBeNull(); + expect(screen.queryByText(/vs prior month/)).toBeNull(); + }); + + it('omits trend row when trend is undefined', () => { + const { container } = render( + + ); + expect(container.querySelector('svg')).toBeNull(); + }); + + it('renders up trend with positive color and "+" sign', () => { + render( + + ); + const row = screen.getByText(/\+2\.4% vs prior month/).parentElement!; + expect(row.className).toContain('text-warm-positive'); + expect(row.querySelector('svg')).toBeTruthy(); + }); + + it('renders down trend with accent color and no "+" sign', () => { + render( + + ); + const row = screen.getByText(/-5\.7% vs prior month/).parentElement!; + expect(row.className).toContain('text-warm-accent'); + expect(row.querySelector('svg')).toBeTruthy(); + }); + + it('renders flat trend with secondary text color', () => { + render( + + ); + const row = screen.getByText(/0\.0% vs prior month/).parentElement!; + expect(row.className).toContain('text-warm-text-secondary'); + }); +}); diff --git a/apps/budget/src/components/budget/SummaryMini.tsx b/apps/budget/src/components/budget/SummaryMini.tsx new file mode 100644 index 0000000..9f36e20 --- /dev/null +++ b/apps/budget/src/components/budget/SummaryMini.tsx @@ -0,0 +1,53 @@ +import { Minus, TrendingDown, TrendingUp } from 'lucide-react'; +import { formatCurrency } from '@/lib/budget'; + +export type TrendDirection = 'up' | 'down' | 'flat'; +export type Trend = { pct: number; direction: TrendDirection }; + +interface Props { + label: string; + value: number; + fill: string; + trend?: Trend | null; +} + +export default function SummaryMini({ label, value, fill, trend }: Props) { + return ( +
+
+ {label} +
+
+ {formatCurrency(value)} +
+ {trend ? : null} +
+ ); +} + +function TrendRow({ trend }: { trend: Trend }) { + const Icon = + trend.direction === 'up' + ? TrendingUp + : trend.direction === 'down' + ? TrendingDown + : Minus; + const color = + trend.direction === 'up' + ? 'text-warm-positive' + : trend.direction === 'down' + ? 'text-warm-accent' + : 'text-warm-text-secondary'; + const sign = trend.pct > 0 ? '+' : ''; + return ( +
+ + + {sign} + {trend.pct.toFixed(1)}% vs prior month + +
+ ); +} diff --git a/apps/budget/src/integrations/convex/provider.tsx b/apps/budget/src/integrations/convex/provider.tsx index 2128816..c7ec5aa 100644 --- a/apps/budget/src/integrations/convex/provider.tsx +++ b/apps/budget/src/integrations/convex/provider.tsx @@ -8,7 +8,6 @@ const CONVEX_URL = env.VITE_CONVEX_URL; const CLERK_KEY = env.VITE_CLERK_PUBLISHABLE_KEY; if (!CONVEX_URL) { - console.error('missing envar VITE_CONVEX_URL'); } diff --git a/apps/budget/src/lib/budget.test.ts b/apps/budget/src/lib/budget.test.ts index 11b6618..99f7475 100644 --- a/apps/budget/src/lib/budget.test.ts +++ b/apps/budget/src/lib/budget.test.ts @@ -32,33 +32,33 @@ describe('computeMovingAverage', () => { describe('filterByTimePeriod', () => { const now = Date.now(); - const msPerYear = 365.25 * 24 * 60 * 60 * 1000; + const msPerMonth = 30 * 24 * 60 * 60 * 1000; const data = [ - { date: now - 6 * msPerYear, value: 1 }, - { date: now - 4 * msPerYear, value: 2 }, - { date: now - 2 * msPerYear, value: 3 }, - { date: now - 0.5 * msPerYear, value: 4 } + { date: now - 24 * msPerMonth, value: 1 }, + { date: now - 9 * msPerMonth, value: 2 }, + { date: now - 4 * msPerMonth, value: 3 }, + { date: now - 1 * msPerMonth, value: 4 } ]; it('returns all data for ALL', () => { expect(filterByTimePeriod(data, 'ALL')).toHaveLength(4); }); - it('filters to last 1 year', () => { - const result = filterByTimePeriod(data, '1Y'); - expect(result).toHaveLength(1); - expect(result[0].value).toBe(4); + it('filters to last 3 months', () => { + const r = filterByTimePeriod(data, '3M'); + expect(r).toHaveLength(1); + expect(r[0].value).toBe(4); }); - it('filters to last 3 years', () => { - const result = filterByTimePeriod(data, '3Y'); - expect(result).toHaveLength(2); + it('filters to last 6 months', () => { + const r = filterByTimePeriod(data, '6M'); + expect(r).toHaveLength(2); }); - it('filters to last 5 years', () => { - const result = filterByTimePeriod(data, '5Y'); - expect(result).toHaveLength(3); + it('filters to last 12 months', () => { + const r = filterByTimePeriod(data, '12M'); + expect(r).toHaveLength(3); }); }); @@ -78,15 +78,19 @@ describe('formatDateLabel', () => { }); describe('formatCurrency', () => { - it('formats positive values with dollar sign', () => { - expect(formatCurrency(1234)).toBe('$1,234'); + it('formats cents as whole AUD with thousands separators', () => { + expect(formatCurrency(123400)).toBe('$1,234'); }); - it('rounds to nearest integer', () => { - expect(formatCurrency(1234.56)).toBe('$1,235'); + it('rounds half-cents to nearest dollar', () => { + expect(formatCurrency(123456)).toBe('$1,235'); }); - it('formats zero', () => { + it('handles zero', () => { expect(formatCurrency(0)).toBe('$0'); }); + + it('handles negatives', () => { + expect(formatCurrency(-50000)).toBe('-$500'); + }); }); diff --git a/apps/budget/src/lib/budget.ts b/apps/budget/src/lib/budget.ts index 1f947ad..a8f0031 100644 --- a/apps/budget/src/lib/budget.ts +++ b/apps/budget/src/lib/budget.ts @@ -1,4 +1,4 @@ -export type TimePeriod = '1Y' | '3Y' | '5Y' | 'ALL'; +export type TimePeriod = '3M' | '6M' | '12M' | 'ALL'; export interface BudgetDataPoint { date: number; @@ -31,11 +31,8 @@ export function filterByTimePeriod( period: TimePeriod ): Array { if (period === 'ALL') return data; - - const now = Date.now(); - const years = period === '1Y' ? 1 : period === '3Y' ? 3 : 5; - const cutoff = now - years * 365.25 * 24 * 60 * 60 * 1000; - + const months = period === '3M' ? 3 : period === '6M' ? 6 : 12; + const cutoff = Date.now() - months * 30 * 24 * 60 * 60 * 1000; return data.filter((d) => d.date >= cutoff); } @@ -47,6 +44,10 @@ export function formatDateLabel(timestamp: number): string { return `${day}/${month}/${year}`; } -export function formatCurrency(value: number): string { - return `$${Math.round(value).toLocaleString('en-AU')}`; +export function formatCurrency(cents: number): string { + const dollars = Math.round(cents / 100); + if (dollars < 0) { + return `-$${Math.abs(dollars).toLocaleString('en-AU')}`; + } + return `$${dollars.toLocaleString('en-AU')}`; } diff --git a/apps/budget/src/routes/index.tsx b/apps/budget/src/routes/index.tsx index 6afbe1d..6a4416b 100644 --- a/apps/budget/src/routes/index.tsx +++ b/apps/budget/src/routes/index.tsx @@ -1,30 +1,128 @@ +import { useState } from 'react'; import { createFileRoute } from '@tanstack/react-router'; import { useQuery } from 'convex/react'; import { api } from '@repo/convex'; +import type { TimePeriod } from '@/lib/budget'; import BudgetChart from '@/components/budget/BudgetChart'; +import BudgetChartFilters from '@/components/budget/BudgetChartFilters'; +import BudgetKpiCards from '@/components/budget/BudgetKpiCards'; +import BudgetBreakdownTable from '@/components/budget/BudgetBreakdownTable'; +import InsightsPanel from '@/components/budget/InsightsPanel'; +import MonthlyDetailOverlay from '@/components/budget/MonthlyDetailOverlay'; +import MonthIncomeSection from '@/components/budget/MonthIncomeSection'; +import MonthSpendSection from '@/components/budget/MonthSpendSection'; +import MonthMortgageSection from '@/components/budget/MonthMortgageSection'; +import SummaryMini from '@/components/budget/SummaryMini'; export const Route = createFileRoute('/')({ component: BudgetPage }); +function monthLabel(date: number) { + return new Date(date).toLocaleString('en-AU', { + month: 'long', + year: 'numeric' + }); +} + function BudgetPage() { - const data = useQuery(api.queries.listBudgetChart); + const [period, setPeriod] = useState('12M'); + const [openMonth, setOpenMonth] = useState(null); - if (data === undefined) { - return ( -
- Loading… -
- ); - } + const summary = useQuery(api.queries.getBudgetPageSummary, { period }); + const chartData = useQuery(api.queries.listBudgetChart); + const periodLimit = + period === '3M' + ? 3 + : period === '6M' + ? 6 + : period === '12M' + ? 12 + : undefined; + const rows = useQuery(api.queries.getMonthlyBreakdown, { + limit: periodLimit + }); + const detail = useQuery( + api.queries.getMonthlyDetail, + openMonth !== null ? { date: openMonth } : 'skip' + ); return ( -
-
-
- + <> +
+
+
+

Budget overview

+ +
+ + + + + + setOpenMonth(date)} + />
+ +
-
+ + setOpenMonth(null)} + > + {detail ? ( + <> +
+ + + +
+
+ + + {detail.mortgage ? ( + + ) : null} +
+ + ) : null} +
+ ); } diff --git a/apps/home/.prettierignore b/apps/home/.prettierignore index b7c7d6d..e34b9de 100644 --- a/apps/home/.prettierignore +++ b/apps/home/.prettierignore @@ -3,3 +3,4 @@ pnpm-lock.yaml yarn.lock convex/_generated +**/routeTree.gen.ts diff --git a/apps/home/src/routeTree.gen.ts b/apps/home/src/routeTree.gen.ts index dceedff..411c075 100644 --- a/apps/home/src/routeTree.gen.ts +++ b/apps/home/src/routeTree.gen.ts @@ -8,61 +8,61 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' +import { Route as rootRouteImport } from './routes/__root'; +import { Route as IndexRouteImport } from './routes/index'; const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', - getParentRoute: () => rootRouteImport, -} as any) + getParentRoute: () => rootRouteImport +} as any); export interface FileRoutesByFullPath { - '/': typeof IndexRoute + '/': typeof IndexRoute; } export interface FileRoutesByTo { - '/': typeof IndexRoute + '/': typeof IndexRoute; } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/': typeof IndexRoute + __root__: typeof rootRouteImport; + '/': typeof IndexRoute; } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' - fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' - fileRoutesById: FileRoutesById + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: '/'; + fileRoutesByTo: FileRoutesByTo; + to: '/'; + id: '__root__' | '/'; + fileRoutesById: FileRoutesById; } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute + IndexRoute: typeof IndexRoute; } declare module '@tanstack/react-router' { interface FileRoutesByPath { '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } + id: '/'; + path: '/'; + fullPath: '/'; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; } } const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, -} + IndexRoute: IndexRoute +}; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes() + ._addFileTypes(); -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' +import type { getRouter } from './router.tsx'; +import type { createStart } from '@tanstack/react-start'; declare module '@tanstack/react-start' { interface Register { - ssr: true - router: Awaited> + ssr: true; + router: Awaited>; } } diff --git a/docs/architecture.md b/docs/architecture.md index a728f52..2c87970 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,17 +4,17 @@ Doma is a Vercel Multi-Zones monorepo. `apps/home` owns the apex domain and rewr ## Monorepo layout -| Path | Framework | Notes | -| --- | --- | --- | -| `apps/home` | TanStack Start | Apex zone, port 3000; owns `vercel.json` rewrites | -| `apps/budget` | TanStack Start | Mounts at `/budget`, port 3001 | -| `apps/api-*` | (per-experiment) | Convention for non-Convex backends — none scaffolded yet | -| `packages/convex` | — | Shared Convex schema/functions (`@repo/convex`) | -| `packages/tokens` | — | Tailwind v4 design tokens (`@repo/tokens`) | -| `packages/shell` | React | Shared sidebar + AppFrame + AuthGate (`@repo/shell`) | -| `packages/ui` | React | Shadcn primitives (`@repo/ui`) | -| `packages/eslint-config` | — | Shared ESLint configs | -| `packages/typescript-config` | — | Shared TypeScript configs | +| Path | Framework | Notes | +| ---------------------------- | ---------------- | -------------------------------------------------------- | +| `apps/home` | TanStack Start | Apex zone, port 3000; owns `vercel.json` rewrites | +| `apps/budget` | TanStack Start | Mounts at `/budget`, port 3001 | +| `apps/api-*` | (per-experiment) | Convention for non-Convex backends — none scaffolded yet | +| `packages/convex` | — | Shared Convex schema/functions (`@repo/convex`) | +| `packages/tokens` | — | Tailwind v4 design tokens (`@repo/tokens`) | +| `packages/shell` | React | Shared sidebar + AppFrame + AuthGate (`@repo/shell`) | +| `packages/ui` | React | Shadcn primitives (`@repo/ui`) | +| `packages/eslint-config` | — | Shared ESLint configs | +| `packages/typescript-config` | — | Shared TypeScript configs | ## Multi-Zones @@ -26,12 +26,15 @@ Doma is a Vercel Multi-Zones monorepo. `apps/home` owns the apex domain and rewr Each dev port is a separate browser origin, so Clerk's session cookie doesn't carry across them. The Sidebar uses Clerk's `buildUrlWithAuth` to append a short-lived `__clerk_db_jwt` to cross-origin links — clicking the Budget icon while signed in on Home lands you on Budget already authed. Implementation lives in `packages/shell/src/auth.tsx` (`UrlAuthContext`) and `Sidebar.tsx` (`useUrlAuth()` consumer). In production all zones share the apex cookie and the URL builder is a no-op for same-origin paths. -*A Caddy reverse-proxy was explored as an alternative (single origin → one cookie), but TanStack Start + Nitro + Vite's `base` option don't play well together: `//@react-refresh`, `//@vite/client`, `//@id/...` all 404 in dev even with `base` set, breaking the proxy approach. The `clerk.buildUrlWithAuth` route is simpler and doesn't fight the framework.* +_A Caddy reverse-proxy was explored as an alternative (single origin → one cookie), but TanStack Start + Nitro + Vite's `base` option don't play well together: `//@react-refresh`, `//@vite/client`, `//@id/...` all 404 in dev even with `base` set, breaking the proxy approach. The `clerk.buildUrlWithAuth` route is simpler and doesn't fight the framework._ When new sub-apps land, add rewrite entries to `apps/home/vercel.json`: ```json -{ "source": "//:path*", "destination": "https://doma-.vercel.app//:path*" } +{ + "source": "//:path*", + "destination": "https://doma-.vercel.app//:path*" +} ``` ## Backend services convention @@ -39,7 +42,10 @@ When new sub-apps land, add rewrite entries to `apps/home/vercel.json`: Non-Convex backend experiments live at `apps/api-` (e.g. `apps/api-recipes-import`). Each is its own Vercel project. To expose one to the frontend, add a rewrite under `apps/home/vercel.json`: ```json -{ "source": "/api//:path*", "destination": "https://doma-api-.vercel.app/:path*" } +{ + "source": "/api//:path*", + "destination": "https://doma-api-.vercel.app/:path*" +} ``` Convex remains the primary backend — most data and business logic belong there. `apps/api-*` is for experiments that don't fit Convex's model (long-running jobs, webhook receivers, framework playgrounds). diff --git a/docs/deployment.md b/docs/deployment.md index 623a49b..5146628 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -14,13 +14,13 @@ Why this shape: What you'll create: -| Piece | Where | One per | -| --- | --- | --- | -| Convex deployment (production) | Convex cloud | All apps share one | -| Clerk application (production env) | Clerk dashboard | All apps share one | -| Vercel project for Home | Vercel | Owns the apex domain + rewrites | -| Vercel project for Budget | Vercel | Lives behind a Vercel-assigned URL | -| Vercel project for each future app | Vercel | One per app | +| Piece | Where | One per | +| ---------------------------------- | --------------- | ---------------------------------- | +| Convex deployment (production) | Convex cloud | All apps share one | +| Clerk application (production env) | Clerk dashboard | All apps share one | +| Vercel project for Home | Vercel | Owns the apex domain + rewrites | +| Vercel project for Budget | Vercel | Lives behind a Vercel-assigned URL | +| Vercel project for each future app | Vercel | One per app | ## Prerequisites @@ -41,6 +41,7 @@ pnpm --filter @repo/convex exec convex deploy --cmd 'echo "deployed"' This creates a production deployment in your Convex project and prints the production deployment URL (something like `https://.convex.cloud`). Save it — you'll set this as `VITE_CONVEX_URL` on Vercel. In the Convex dashboard for the **production** deployment specifically: + - **Settings → Environment Variables**: set `CLERK_JWT_ISSUER_DOMAIN` to your **production** Clerk Frontend API URL (different from dev — see Step 2). ## Step 2 — Clerk production environment @@ -79,12 +80,12 @@ Or via the dashboard: - **Install Command**: leave default; Vercel detects pnpm workspaces and runs install from the repo root. 3. **Environment Variables** (Production, Preview, Development as appropriate — at minimum Production): - | Name | Value | - | --- | --- | - | `VITE_CONVEX_URL` | Convex production URL from Step 1 | - | `VITE_CLERK_PUBLISHABLE_KEY` | Clerk production publishable key (Step 2) | - | `CLERK_SECRET_KEY` | Clerk production secret key (Step 2) | - | `VITE_CLERK_FRONTEND_API_URL` | Clerk Frontend API URL (Step 2) | + | Name | Value | + | ----------------------------- | ----------------------------------------- | + | `VITE_CONVEX_URL` | Convex production URL from Step 1 | + | `VITE_CLERK_PUBLISHABLE_KEY` | Clerk production publishable key (Step 2) | + | `CLERK_SECRET_KEY` | Clerk production secret key (Step 2) | + | `VITE_CLERK_FRONTEND_API_URL` | Clerk Frontend API URL (Step 2) | 4. **Deploy**. When it succeeds, copy the deployment URL — it'll look like `https://doma-budget-.vercel.app`. You'll use this in Step 4. @@ -126,11 +127,11 @@ Home owns the apex. Before deploying, point its rewrites at Budget's real URL. 4. **Environment Variables** on the Home project (Home doesn't talk to Convex yet, but wire the Clerk vars so the auth gate works): - | Name | Value | - | --- | --- | - | `VITE_CLERK_PUBLISHABLE_KEY` | Clerk production publishable key | - | `CLERK_SECRET_KEY` | Clerk production secret key | - | `VITE_CLERK_FRONTEND_API_URL` | Clerk Frontend API URL | + | Name | Value | + | ----------------------------- | -------------------------------- | + | `VITE_CLERK_PUBLISHABLE_KEY` | Clerk production publishable key | + | `CLERK_SECRET_KEY` | Clerk production secret key | + | `VITE_CLERK_FRONTEND_API_URL` | Clerk Frontend API URL | ## Step 5 — Attach your apex domain to Home @@ -156,7 +157,10 @@ The pattern repeats: 3. Add a rewrite to `apps/home/vercel.json`: ```json - { "source": "//:path*", "destination": "https://doma-.vercel.app//:path*" } + { + "source": "//:path*", + "destination": "https://doma-.vercel.app//:path*" + } ``` 4. Redeploy Home. @@ -165,13 +169,13 @@ The pattern repeats: ## Environment-variable reference -| Variable | Where it lives | Used by | Notes | -| --- | --- | --- | --- | -| `VITE_CONVEX_URL` | Vercel (per consumer app), `.env.local` | Browser-side Convex client | Different value in dev vs production | -| `VITE_CLERK_PUBLISHABLE_KEY` | Vercel (every app), `.env.local` | Browser-side Clerk SDK | `pk_test_…` in dev, `pk_live_…` in prod | -| `CLERK_SECRET_KEY` | Vercel (every app), `.env.local` | Server-side Clerk operations | Never expose to the browser | -| `VITE_CLERK_FRONTEND_API_URL` | Vercel (every app), `.env.local` | Clerk JWT issuer URL | Same in every app; one per Clerk env | -| `CLERK_JWT_ISSUER_DOMAIN` | Convex dashboard (per deployment) | Convex auth.config.ts | Same value as `VITE_CLERK_FRONTEND_API_URL` | +| Variable | Where it lives | Used by | Notes | +| ----------------------------- | --------------------------------------- | ---------------------------- | ------------------------------------------- | +| `VITE_CONVEX_URL` | Vercel (per consumer app), `.env.local` | Browser-side Convex client | Different value in dev vs production | +| `VITE_CLERK_PUBLISHABLE_KEY` | Vercel (every app), `.env.local` | Browser-side Clerk SDK | `pk_test_…` in dev, `pk_live_…` in prod | +| `CLERK_SECRET_KEY` | Vercel (every app), `.env.local` | Server-side Clerk operations | Never expose to the browser | +| `VITE_CLERK_FRONTEND_API_URL` | Vercel (every app), `.env.local` | Clerk JWT issuer URL | Same in every app; one per Clerk env | +| `CLERK_JWT_ISSUER_DOMAIN` | Convex dashboard (per deployment) | Convex auth.config.ts | Same value as `VITE_CLERK_FRONTEND_API_URL` | ## Common pitfalls diff --git a/docs/offline.md b/docs/offline.md index 7c25932..ebb7183 100644 --- a/docs/offline.md +++ b/docs/offline.md @@ -14,18 +14,18 @@ Picking a layer is a per-app decision: not every sub-app needs it. ## Per-app likelihood -| App | Likely needs offline data? | -| --- | --- | -| Budget | No — analytics over server data, online is fine | -| Mortgage | No | -| Schedule | Maybe — viewing yes, mutating no | -| Todo list | Yes — quick captures from anywhere | -| Shopping list | Yes — supermarket, no signal | -| Recipes | Yes — kitchen, sometimes no signal | +| App | Likely needs offline data? | +| ------------- | ----------------------------------------------- | +| Budget | No — analytics over server data, online is fine | +| Mortgage | No | +| Schedule | Maybe — viewing yes, mutating no | +| Todo list | Yes — quick captures from anywhere | +| Shopping list | Yes — supermarket, no signal | +| Recipes | Yes — kitchen, sometimes no signal | When the first offline-needing app is built, choose the layer in that PR — not earlier. -## What the PWA shell *does* give you +## What the PWA shell _does_ give you - The app launches from the home screen (Add to Home Screen on iOS, Install on Chrome). - The static shell loads offline; users see the loading state instead of a connection error. diff --git a/package.json b/package.json index e4a8cb4..f34bed7 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "format": "prettier --write \"**/*.{ts,tsx,md}\"", "check-types": "turbo run check-types", "test": "turbo run test", - "convex": "pnpm --filter @repo/convex dev" + "convex": "pnpm --filter @repo/convex dev", + "seed": "pnpm --filter @repo/convex seed", + "seed:clear": "pnpm --filter @repo/convex seed:clear" }, "devDependencies": { "prettier": "^3.7.4", diff --git a/packages/convex/convex/_generated/api.d.ts b/packages/convex/convex/_generated/api.d.ts index bce4ea3..080546a 100644 --- a/packages/convex/convex/_generated/api.d.ts +++ b/packages/convex/convex/_generated/api.d.ts @@ -8,7 +8,10 @@ * @module */ +import type * as budgetSummary from "../budgetSummary.js"; import type * as helpers from "../helpers.js"; +import type * as monthDetail from "../monthDetail.js"; +import type * as monthlyBreakdown from "../monthlyBreakdown.js"; import type * as mutations from "../mutations.js"; import type * as queries from "../queries.js"; import type * as seed from "../seed.js"; @@ -20,7 +23,10 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + budgetSummary: typeof budgetSummary; helpers: typeof helpers; + monthDetail: typeof monthDetail; + monthlyBreakdown: typeof monthlyBreakdown; mutations: typeof mutations; queries: typeof queries; seed: typeof seed; diff --git a/packages/convex/convex/budgetSummary.test.ts b/packages/convex/convex/budgetSummary.test.ts new file mode 100644 index 0000000..ed31505 --- /dev/null +++ b/packages/convex/convex/budgetSummary.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { + summarizeBudgetForPeriod, + type SummaryPeriod, + type BudgetRow +} from './budgetSummary'; + +function row( + date: number, + incomePrimary: number, + incomeSecondary: number, + billContrib: number, + credit1: number, + credit2: number, + credit3: number, + oneOffs: number, + shared: number +): BudgetRow { + return { + _id: 'x' as any, + _creationTime: 0, + date, + incomePrimary, + incomeSecondary, + billContrib, + credit1, + credit2, + credit3, + oneOffs, + shared, + variable: 0, + fixed: 0, + rent: 0 + }; +} + +const MS = 86_400_000; +const month = (i: number) => i * 30 * MS; // synthetic month spacing + +describe('summarizeBudgetForPeriod', () => { + it('returns zeros + null deltas for empty data', () => { + const r = summarizeBudgetForPeriod([], '12M', 999_999_999_999); + expect(r.avgSpend.value).toBe(0); + expect(r.avgSpend.delta).toBeNull(); + expect(r.avgIncome.value).toBe(0); + expect(r.savingsRate.value).toBe(0); + expect(r.netGain.value).toBe(0); + }); + + it('ALL period uses every row, prior window empty', () => { + const rows = [ + row(month(1), 100_000, 0, 0, 30_000, 0, 0, 0, 0), + row(month(2), 200_000, 0, 0, 40_000, 0, 0, 0, 0) + ]; + const r = summarizeBudgetForPeriod(rows, 'ALL', month(2)); + expect(r.avgIncome.value).toBe(150_000); // (100000 + 200000) / 2 + expect(r.avgSpend.value).toBe(35_000); // (30000 + 40000) / 2 + expect(r.netGain.value).toBe(115_000); // 150000 - 35000 + expect(r.avgIncome.delta).toBeNull(); + }); + + it('savings rate is in basis points', () => { + const rows = [row(month(1), 100_000, 0, 0, 88_000, 0, 0, 0, 0)]; + const r = summarizeBudgetForPeriod(rows, 'ALL', month(1)); + // net = 12000, in = 100000 -> 12% -> 1200 bp + expect(r.savingsRate.value).toBe(1200); + }); + + it('12M window picks last 12 months and prior 12 for delta', () => { + const rows: BudgetRow[] = []; + // 24 months of synthetic data, income doubling in second 12 + for (let i = 0; i < 24; i++) { + rows.push( + row(month(i + 1), i < 12 ? 100_000 : 200_000, 0, 0, 0, 0, 0, 0, 0) + ); + } + const r = summarizeBudgetForPeriod(rows, '12M', month(24)); + expect(r.avgIncome.value).toBe(200_000); + expect(r.avgIncome.delta).toBe(100_000); // 200000 - 100000 + expect(r.avgIncome.deltaPct).toBeCloseTo(100, 1); // +100% + }); + + it('periodLabel reflects window choice', () => { + expect(summarizeBudgetForPeriod([], '12M', 0).periodLabel).toBe( + '12 months' + ); + expect(summarizeBudgetForPeriod([], '6M', 0).periodLabel).toBe('6 months'); + expect(summarizeBudgetForPeriod([], '3M', 0).periodLabel).toBe('3 months'); + expect(summarizeBudgetForPeriod([], 'ALL', 0).periodLabel).toBe('All time'); + }); +}); diff --git a/packages/convex/convex/budgetSummary.ts b/packages/convex/convex/budgetSummary.ts new file mode 100644 index 0000000..c42db1c --- /dev/null +++ b/packages/convex/convex/budgetSummary.ts @@ -0,0 +1,110 @@ +import type { Doc } from './_generated/dataModel'; +import { budgetTotalIn, budgetTotalOut, budgetNetGainLoss } from './helpers'; + +export type SummaryPeriod = '3M' | '6M' | '12M' | 'ALL'; +export type BudgetRow = Doc<'budget'>; + +export interface SummaryMetric { + value: number; // cents (or basis points for savingsRate) + delta: number | null; + deltaPct: number | null; +} + +export interface BudgetPageSummary { + avgSpend: SummaryMetric; + avgIncome: SummaryMetric; + savingsRate: SummaryMetric; + netGain: SummaryMetric; + periodLabel: string; +} + +const MS_PER_MONTH = 30 * 86_400_000; + +function windowMs(period: SummaryPeriod): number | null { + if (period === 'ALL') return null; + const months = period === '3M' ? 3 : period === '6M' ? 6 : 12; + return months * MS_PER_MONTH; +} + +function labelFor(period: SummaryPeriod): string { + return period === 'ALL' ? 'All time' : `${period.replace('M', '')} months`; +} + +function avg(values: number[]): number { + if (values.length === 0) return 0; + let sum = 0; + for (const v of values) sum += v; + return Math.round(sum / values.length); +} + +function metric(current: number, prior: number | null): SummaryMetric { + if (prior === null) return { value: current, delta: null, deltaPct: null }; + const delta = current - prior; + const deltaPct = prior === 0 ? null : (delta / Math.abs(prior)) * 100; + return { value: current, delta, deltaPct }; +} + +function computeWindow(rows: BudgetRow[]) { + const ins = rows.map(budgetTotalIn); + const outs = rows.map(budgetTotalOut); + const nets = rows.map(budgetNetGainLoss); + const avgIn = avg(ins); + const avgOut = avg(outs); + const totalIn = ins.reduce((s, x) => s + x, 0); + const totalNet = nets.reduce((s, x) => s + x, 0); + // basis points: 12.34% -> 1234 + const savingsBp = + totalIn === 0 ? 0 : Math.round((totalNet / totalIn) * 10_000); + return { + avgSpend: avgOut, + avgIncome: avgIn, + savingsRate: savingsBp, + netGain: avg(nets) + }; +} + +export function summarizeBudgetForPeriod( + rows: BudgetRow[], + period: SummaryPeriod, + now: number +): BudgetPageSummary { + if (rows.length === 0) { + const empty = { value: 0, delta: null, deltaPct: null }; + return { + avgSpend: empty, + avgIncome: empty, + savingsRate: empty, + netGain: empty, + periodLabel: labelFor(period) + }; + } + + const sorted = [...rows].sort((a, b) => a.date - b.date); + const window = windowMs(period); + + let currentRows: BudgetRow[]; + let priorRows: BudgetRow[]; + + if (window === null) { + currentRows = sorted; + priorRows = []; + } else { + const currentStart = now - window; + const priorStart = currentStart - window; + currentRows = sorted.filter((r) => r.date > currentStart && r.date <= now); + priorRows = sorted.filter( + (r) => r.date > priorStart && r.date <= currentStart + ); + } + + const cur = computeWindow(currentRows); + const prior = priorRows.length > 0 ? computeWindow(priorRows) : null; + + return { + avgSpend: metric(cur.avgSpend, prior?.avgSpend ?? null), + avgIncome: metric(cur.avgIncome, prior?.avgIncome ?? null), + savingsRate: metric(cur.savingsRate, prior?.savingsRate ?? null), + netGain: metric(cur.netGain, prior?.netGain ?? null), + periodLabel: labelFor(period) + }; +} diff --git a/packages/convex/convex/helpers.test.ts b/packages/convex/convex/helpers.test.ts new file mode 100644 index 0000000..4d34df1 --- /dev/null +++ b/packages/convex/convex/helpers.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + toCents, + fromCents, + superPensionAud, + ukTotalAud, + investmentTotal +} from './helpers'; + +describe('toCents', () => { + it('converts whole dollars to cents', () => { + expect(toCents(10)).toBe(1000); + }); + + it('converts fractional dollars to cents with rounding', () => { + expect(toCents(10.005)).toBe(1001); + expect(toCents(10.004)).toBe(1000); + }); + + it('handles zero and negatives', () => { + expect(toCents(0)).toBe(0); + expect(toCents(-2.5)).toBe(-250); + }); +}); + +describe('fromCents', () => { + it('converts cents back to dollars', () => { + expect(fromCents(1000)).toBe(10); + expect(fromCents(1234)).toBe(12.34); + }); + + it('handles zero and negatives', () => { + expect(fromCents(0)).toBe(0); + expect(fromCents(-250)).toBe(-2.5); + }); +}); + +describe('rate-boundary rounding (returns integer cents)', () => { + it('superPensionAud rounds to integer cents', () => { + // 100,000 pence (£1000) * 1.95 = 195,000 cents + expect( + superPensionAud({ + _id: 'x' as any, + _creationTime: 0, + date: 0, + pension: 100_000, + super1: 0, + super2: 0, + super3: 0, + gbpAud: 1.95 + }) + ).toBe(195_000); + }); + + it('superPensionAud rounds fractional cents', () => { + // 100 pence * 1.234 = 123.4 cents -> 123 + expect( + superPensionAud({ + _id: 'x' as any, + _creationTime: 0, + date: 0, + pension: 100, + super1: 0, + super2: 0, + super3: 0, + gbpAud: 1.234 + }) + ).toBe(123); + expect( + Number.isInteger( + superPensionAud({ + _id: 'x' as any, + _creationTime: 0, + date: 0, + pension: 99, + super1: 0, + super2: 0, + super3: 0, + gbpAud: 1.987 + }) + ) + ).toBe(true); + }); + + it('ukTotalAud returns integer cents', () => { + const row = { + _id: 'x' as any, + _creationTime: 0, + date: 0, + currentGbp: 10_000, + saverGbp: 20_000, + cashIsaGbp: 30_000, + sharesIsaGbp: 40_000, + gbpAud: 1.95 + }; + // 100,000 pence * 1.95 = 195,000 cents + expect(ukTotalAud(row)).toBe(195_000); + }); + + it('investmentTotal returns integer cents', () => { + const row = { + _id: 'x' as any, + _creationTime: 0, + date: 0, + managedFund1: 1_000_000, + investmentLoan: -500_000, + tradingAus1: 100_000, + tradingInt1: 50_000, + tradingInt2: 80_000, // USD cents + usdAud: 1.55, + managedFund2: 0, + tradingAus2: 0, + managedFund3: 0, + crypto1: 0, + crypto2: 0 + }; + // tradingInt2 * usdAud = 80000 * 1.55 = 124000 cents + // total = 500000 + 100000 + 50000 + 124000 = 774000 + expect(investmentTotal(row)).toBe(774_000); + expect(Number.isInteger(investmentTotal(row))).toBe(true); + }); +}); diff --git a/packages/convex/convex/helpers.ts b/packages/convex/convex/helpers.ts index 757835d..f400277 100644 --- a/packages/convex/convex/helpers.ts +++ b/packages/convex/convex/helpers.ts @@ -22,7 +22,7 @@ export function ukTotalGbp(row: Doc<'ukAccounts'>) { } export function ukTotalAud(row: Doc<'ukAccounts'>) { - return ukTotalGbp(row) * row.gbpAud; + return Math.round(ukTotalGbp(row) * row.gbpAud); } export function ukAudGbp(row: Doc<'ukAccounts'>) { @@ -33,7 +33,7 @@ export function ukAudGbp(row: Doc<'ukAccounts'>) { // SUPER ACCOUNTS — derived fields // ============================================================ export function superPensionAud(row: Doc<'superAccounts'>) { - return row.pension * row.gbpAud; + return Math.round(row.pension * row.gbpAud); } export function superTotal(row: Doc<'superAccounts'>) { @@ -52,7 +52,7 @@ export function investmentTotal(row: Doc<'investmentAccounts'>) { investmentManagedFundNet(row) + row.tradingAus1 + row.tradingInt1 + - row.tradingInt2 * row.usdAud + + Math.round(row.tradingInt2 * row.usdAud) + row.managedFund2 + row.tradingAus2 + row.managedFund3 + @@ -161,3 +161,16 @@ export function timestampToExcelDate(timestamp: number): number { const EXCEL_EPOCH = new Date(Date.UTC(1899, 11, 30)).getTime(); return (timestamp - EXCEL_EPOCH) / MS_PER_DAY; } + +// ============================================================ +// Money: integer minor units (cents/pence) conversion +// All money fields in this schema are stored as integer minor +// units. Rates (gbpAud, usdAud, rateVar, rateFix) stay as floats. +// ============================================================ +export function toCents(value: number): number { + return Math.round(value * 100); +} + +export function fromCents(cents: number): number { + return cents / 100; +} diff --git a/packages/convex/convex/monthDetail.test.ts b/packages/convex/convex/monthDetail.test.ts new file mode 100644 index 0000000..2c8f292 --- /dev/null +++ b/packages/convex/convex/monthDetail.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { + computeTrend, + shapeMonthDetail, + type BudgetRow, + type MortgageRow +} from './monthDetail'; + +const MS = 86_400_000; + +const budgetRow: BudgetRow = { + _id: 'b' as any, + _creationTime: 0, + date: MS * 100, + incomePrimary: 800_000, + incomeSecondary: 50_000, + billContrib: 20_000, + credit1: 90_000, + credit2: 60_000, + credit3: 80_000, + oneOffs: 34_000, + shared: 0, + variable: 0, + fixed: 0, + rent: 0 +}; + +const priorBudgetRow: BudgetRow = { + ...budgetRow, + _id: 'b-prior' as any, + date: MS * 70, + incomePrimary: 700_000, + incomeSecondary: 50_000, + billContrib: 20_000, // prior income total = 770_000 → +12.987% ≈ 13.0 + credit1: 100_000, + credit2: 60_000, + credit3: 80_000, + oneOffs: 40_000 // prior spend total = 280_000 → −5.71% ≈ −5.7 +}; + +const mortgageRow: MortgageRow = { + _id: 'm' as any, + _creationTime: 0, + date: MS * 100, + deposit: 0, + familyContrib: 0, + debt1: 30_000_000, + debt2: 10_000_000, + interestCharged: 120_000, + principalPaid: 80_000, + contrib1: 100_000, + contrib2: 120_000, + contrib3: 72_000, + price: 80_000_000, + landValue: 0, + capitalGrowth: 0 +}; + +const priorMortgageRow: MortgageRow = { + ...mortgageRow, + _id: 'm-prior' as any, + date: MS * 70, + contrib1: 100_000, + contrib2: 120_000, + contrib3: 72_000 // identical contrib → flat +}; + +describe('shapeMonthDetail', () => { + it('returns null when budget row missing', () => { + expect(shapeMonthDetail(null, mortgageRow)).toBeNull(); + }); + + it('returns income subtotal', () => { + const r = shapeMonthDetail(budgetRow, null)!; + expect(r.income.total).toBe(870_000); // 800k+50k+20k + }); + + it('returns spend subtotal (credit + oneOffs)', () => { + const r = shapeMonthDetail(budgetRow, null)!; + expect(r.spend.total).toBe(264_000); // 90+60+80+34 (thousands of cents) + }); + + it('returns null mortgage when no mortgage row', () => { + expect(shapeMonthDetail(budgetRow, null)!.mortgage).toBeNull(); + }); + + it('returns mortgage block with derived totals', () => { + const r = shapeMonthDetail(budgetRow, mortgageRow)!; + expect(r.mortgage).not.toBeNull(); + expect(r.mortgage!.contribTotal).toBe(292_000); // 100+120+72 + expect(r.mortgage!.totalDebt).toBe(40_000_000); + expect(r.mortgage!.equity).toBe(40_000_000); // 80M - 40M + }); + + it('returns null trends when no prior data', () => { + const r = shapeMonthDetail(budgetRow, mortgageRow)!; + expect(r.trends.income).toBeNull(); + expect(r.trends.spend).toBeNull(); + expect(r.trends.mortgage).toBeNull(); + }); + + it('returns income trend up when income increases', () => { + const r = shapeMonthDetail(budgetRow, mortgageRow, priorBudgetRow, null)!; + // current 870k vs prior 770k → +12.987% → rounds to 13.0 + expect(r.trends.income).toEqual({ pct: 13.0, direction: 'up' }); + }); + + it('returns spend trend down when spend decreases', () => { + const r = shapeMonthDetail(budgetRow, mortgageRow, priorBudgetRow, null)!; + // current 264k vs prior 280k → −5.71% → rounds to −5.7 + expect(r.trends.spend).toEqual({ pct: -5.7, direction: 'down' }); + }); + + it('returns flat mortgage trend when contrib unchanged', () => { + const r = shapeMonthDetail( + budgetRow, + mortgageRow, + priorBudgetRow, + priorMortgageRow + )!; + expect(r.trends.mortgage).toEqual({ pct: 0, direction: 'flat' }); + }); + + it('returns null mortgage trend when prior mortgage missing', () => { + const r = shapeMonthDetail(budgetRow, mortgageRow, priorBudgetRow, null)!; + expect(r.trends.mortgage).toBeNull(); + }); +}); + +describe('computeTrend', () => { + it('returns null when prior is null', () => { + expect(computeTrend(100, null)).toBeNull(); + }); + + it('returns null when prior is zero (avoid div by zero)', () => { + expect(computeTrend(100, 0)).toBeNull(); + }); + + it('rounds percentage to 1 decimal', () => { + expect(computeTrend(105, 100)).toEqual({ pct: 5, direction: 'up' }); + expect(computeTrend(102.345, 100)).toEqual({ pct: 2.3, direction: 'up' }); + }); + + it('treats rounded-zero change as flat', () => { + // 0.04% rounds to 0.0 → flat + expect(computeTrend(100.04, 100)).toEqual({ pct: 0, direction: 'flat' }); + }); +}); diff --git a/packages/convex/convex/monthDetail.ts b/packages/convex/convex/monthDetail.ts new file mode 100644 index 0000000..fb84ed5 --- /dev/null +++ b/packages/convex/convex/monthDetail.ts @@ -0,0 +1,130 @@ +import type { Doc } from './_generated/dataModel'; +import { mortgageTotalDebt, mortgageEquity } from './helpers'; + +export type BudgetRow = Doc<'budget'>; +export type MortgageRow = Doc<'mortgage'>; + +export type TrendDirection = 'up' | 'down' | 'flat'; + +export interface MonthDetailTrend { + pct: number; + direction: TrendDirection; +} + +export interface MonthDetail { + date: number; + income: { + primary: number; + secondary: number; + billContrib: number; + total: number; + }; + spend: { + credit1: number; + credit2: number; + credit3: number; + oneOffs: number; + total: number; + }; + mortgage: { + contrib1: number; + contrib2: number; + contrib3: number; + contribTotal: number; + interestCharged: number; + principalPaid: number; + debt1: number; + debt2: number; + totalDebt: number; + equity: number; + } | null; + trends: { + income: MonthDetailTrend | null; + spend: MonthDetailTrend | null; + mortgage: MonthDetailTrend | null; + }; +} + +function incomeTotal(b: BudgetRow): number { + return b.incomePrimary + b.incomeSecondary + b.billContrib; +} + +function spendTotal(b: BudgetRow): number { + return b.credit1 + b.credit2 + b.credit3 + b.oneOffs; +} + +function mortgageContribTotal(m: MortgageRow): number { + return m.contrib1 + m.contrib2 + m.contrib3; +} + +export function computeTrend( + current: number, + prior: number | null | undefined +): MonthDetailTrend | null { + if (prior == null || prior === 0) return null; + const pct = ((current - prior) / prior) * 100; + const rounded = Math.round(pct * 10) / 10; + let direction: TrendDirection; + if (rounded > 0) direction = 'up'; + else if (rounded < 0) direction = 'down'; + else direction = 'flat'; + return { pct: rounded, direction }; +} + +export function shapeMonthDetail( + budget: BudgetRow | null, + mortgage: MortgageRow | null, + priorBudget?: BudgetRow | null, + priorMortgage?: MortgageRow | null +): MonthDetail | null { + if (!budget) return null; + + const curIncome = incomeTotal(budget); + const curSpend = spendTotal(budget); + const curMortgageContrib = mortgage ? mortgageContribTotal(mortgage) : null; + + const priorIncome = priorBudget ? incomeTotal(priorBudget) : null; + const priorSpend = priorBudget ? spendTotal(priorBudget) : null; + const priorMortgageContrib = priorMortgage + ? mortgageContribTotal(priorMortgage) + : null; + + return { + date: budget.date, + income: { + primary: budget.incomePrimary, + secondary: budget.incomeSecondary, + billContrib: budget.billContrib, + total: curIncome + }, + spend: { + credit1: budget.credit1, + credit2: budget.credit2, + credit3: budget.credit3, + oneOffs: budget.oneOffs, + total: curSpend + }, + mortgage: mortgage + ? { + contrib1: mortgage.contrib1, + contrib2: mortgage.contrib2, + contrib3: mortgage.contrib3, + contribTotal: mortgageContribTotal(mortgage), + interestCharged: mortgage.interestCharged, + principalPaid: mortgage.principalPaid, + debt1: mortgage.debt1, + debt2: mortgage.debt2, + totalDebt: mortgageTotalDebt(mortgage), + equity: mortgageEquity(mortgage) + } + : null, + trends: { + income: computeTrend(curIncome, priorIncome), + spend: computeTrend(curSpend, priorSpend), + mortgage: + curMortgageContrib != null + ? computeTrend(curMortgageContrib, priorMortgageContrib) + : null + } + }; +} diff --git a/packages/convex/convex/monthlyBreakdown.test.ts b/packages/convex/convex/monthlyBreakdown.test.ts new file mode 100644 index 0000000..c6f6fd8 --- /dev/null +++ b/packages/convex/convex/monthlyBreakdown.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + joinBudgetWithMortgage, + type BudgetRow, + type MortgageRow +} from './monthlyBreakdown'; + +const MS = 86_400_000; + +function b(date: number, inP: number, credit: number): BudgetRow { + return { + _id: 'x' as any, + _creationTime: 0, + date, + incomePrimary: inP, + incomeSecondary: 0, + billContrib: 0, + credit1: credit, + credit2: 0, + credit3: 0, + oneOffs: 0, + shared: 0, + variable: 0, + fixed: 0, + rent: 0 + }; +} + +function m(date: number, c1: number, c2: number, c3: number): MortgageRow { + return { + _id: 'x' as any, + _creationTime: 0, + date, + deposit: 0, + familyContrib: 0, + debt1: 0, + debt2: 0, + interestCharged: 0, + principalPaid: 0, + contrib1: c1, + contrib2: c2, + contrib3: c3, + price: 0, + landValue: 0, + capitalGrowth: 0 + }; +} + +describe('joinBudgetWithMortgage', () => { + it('returns null mortgage when no mortgage rows exist', () => { + const out = joinBudgetWithMortgage([b(MS, 100, 30)], []); + expect(out).toEqual([ + { date: MS, income: 100, spend: 30, mortgage: null, net: 70 } + ]); + }); + + it('matches exact-date mortgage row', () => { + const out = joinBudgetWithMortgage( + [b(MS * 10, 100, 30)], + [m(MS * 10, 10, 20, 30)] + ); + expect(out[0]!.mortgage).toBe(60); + }); + + it('carries forward most recent prior mortgage', () => { + const out = joinBudgetWithMortgage( + [b(MS * 20, 100, 30), b(MS * 30, 200, 40)], + [m(MS * 15, 10, 20, 30)] + ); + expect(out[0]!.mortgage).toBe(60); // carried forward to budget at MS*20 + expect(out[1]!.mortgage).toBe(60); // still carried forward to MS*30 + }); + + it('returns null mortgage when budget row precedes earliest mortgage row', () => { + const out = joinBudgetWithMortgage( + [b(MS * 5, 100, 30)], + [m(MS * 10, 10, 20, 30)] + ); + expect(out[0]!.mortgage).toBeNull(); + }); + + it('returns rows in descending date order', () => { + const out = joinBudgetWithMortgage( + [b(MS * 10, 1, 0), b(MS * 30, 3, 0), b(MS * 20, 2, 0)], + [] + ); + expect(out.map((r) => r.date)).toEqual([MS * 30, MS * 20, MS * 10]); + }); + + it('respects limit (taking most recent)', () => { + const out = joinBudgetWithMortgage( + [b(MS * 10, 1, 0), b(MS * 20, 2, 0), b(MS * 30, 3, 0)], + [], + 2 + ); + expect(out.map((r) => r.date)).toEqual([MS * 30, MS * 20]); + }); +}); diff --git a/packages/convex/convex/monthlyBreakdown.ts b/packages/convex/convex/monthlyBreakdown.ts new file mode 100644 index 0000000..4a4cbb3 --- /dev/null +++ b/packages/convex/convex/monthlyBreakdown.ts @@ -0,0 +1,49 @@ +import type { Doc } from './_generated/dataModel'; +import { budgetTotalIn, budgetTotalOut, budgetNetGainLoss } from './helpers'; + +export type BudgetRow = Doc<'budget'>; +export type MortgageRow = Doc<'mortgage'>; + +export interface BreakdownRow { + date: number; + income: number; + spend: number; + mortgage: number | null; + net: number; +} + +function mortgageContrib(m: MortgageRow): number { + return m.contrib1 + m.contrib2 + m.contrib3; +} + +export function joinBudgetWithMortgage( + budgetRows: BudgetRow[], + mortgageRows: MortgageRow[], + limit?: number +): BreakdownRow[] { + const sortedBudget = [...budgetRows].sort((a, b) => a.date - b.date); + const sortedMortgage = [...mortgageRows].sort((a, b) => a.date - b.date); + + // Two-pointer carry-forward + let mIdx = 0; + let lastMortgage: MortgageRow | null = null; + const out: BreakdownRow[] = []; + for (const row of sortedBudget) { + while (mIdx < sortedMortgage.length) { + const candidate = sortedMortgage[mIdx]; + if (candidate === undefined || candidate.date > row.date) break; + lastMortgage = candidate; + mIdx += 1; + } + out.push({ + date: row.date, + income: budgetTotalIn(row), + spend: budgetTotalOut(row), + mortgage: lastMortgage ? mortgageContrib(lastMortgage) : null, + net: budgetNetGainLoss(row) + }); + } + + out.reverse(); // desc + return typeof limit === 'number' ? out.slice(0, limit) : out; +} diff --git a/packages/convex/convex/queries.ts b/packages/convex/convex/queries.ts index 0efdabe..954a71e 100644 --- a/packages/convex/convex/queries.ts +++ b/packages/convex/convex/queries.ts @@ -20,6 +20,9 @@ import { ukTotalAud, ukTotalGbp } from './helpers'; +import { summarizeBudgetForPeriod, type SummaryPeriod } from './budgetSummary'; +import { joinBudgetWithMortgage } from './monthlyBreakdown'; +import { shapeMonthDetail } from './monthDetail'; // ============================================================ // CURRENT ACCOUNTS @@ -385,3 +388,81 @@ export const getTotalsHistory = query({ return args.limit ? results.slice(0, args.limit) : results; } }); + +// ============================================================ +// MONTHLY BREAKDOWN — budget rows joined with mortgage contrib +// ============================================================ +export const getMonthlyBreakdown = query({ + args: { limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const [budgetRows, mortgageRows] = await Promise.all([ + ctx.db.query('budget').withIndex('by_date').order('asc').collect(), + ctx.db.query('mortgage').withIndex('by_date').order('asc').collect() + ]); + return joinBudgetWithMortgage(budgetRows, mortgageRows, args.limit); + } +}); + +// ============================================================ +// MONTHLY DETAIL — single month overlay data (budget + mortgage carry-forward) +// ============================================================ +export const getMonthlyDetail = query({ + args: { date: v.number() }, + handler: async (ctx, args) => { + const budget = await ctx.db + .query('budget') + .withIndex('by_date', (q) => q.eq('date', args.date)) + .first(); + + // Carry-forward mortgage lookup: most recent at-or-before args.date + const mortgage = await ctx.db + .query('mortgage') + .withIndex('by_date', (q) => q.lte('date', args.date)) + .order('desc') + .first(); + + // Prior-month budget row (most recent strictly before args.date) + const priorBudget = await ctx.db + .query('budget') + .withIndex('by_date', (q) => q.lt('date', args.date)) + .order('desc') + .first(); + + // Prior mortgage carry-forward: most recent at-or-before priorBudget.date + const priorMortgage = priorBudget + ? await ctx.db + .query('mortgage') + .withIndex('by_date', (q) => q.lte('date', priorBudget.date)) + .order('desc') + .first() + : null; + + return shapeMonthDetail(budget, mortgage, priorBudget, priorMortgage); + } +}); + +// ============================================================ +// BUDGET PAGE SUMMARY — KPI cards with period comparisons +// ============================================================ +export const getBudgetPageSummary = query({ + args: { + period: v.union( + v.literal('3M'), + v.literal('6M'), + v.literal('12M'), + v.literal('ALL') + ) + }, + handler: async (ctx, args) => { + const rows = await ctx.db + .query('budget') + .withIndex('by_date') + .order('asc') + .collect(); + return summarizeBudgetForPeriod( + rows, + args.period as SummaryPeriod, + Date.now() + ); + } +}); diff --git a/packages/convex/convex/schema.ts b/packages/convex/convex/schema.ts index 0c63bfb..6964415 100644 --- a/packages/convex/convex/schema.ts +++ b/packages/convex/convex/schema.ts @@ -1,3 +1,11 @@ +/** + * MONEY CONVENTION + * ---------------- + * All monetary fields in this schema are stored as integer minor units + * (cents for AUD/USD, pence for GBP). Use toCents() / fromCents() in + * helpers.ts to convert. Rates (gbpAud, usdAud, rateVar, rateFix) remain + * floats. + */ import { defineSchema, defineTable } from 'convex/server'; import { v } from 'convex/values'; diff --git a/packages/convex/package.json b/packages/convex/package.json index 664b570..75991e7 100644 --- a/packages/convex/package.json +++ b/packages/convex/package.json @@ -11,22 +11,32 @@ "default": "./convex/_generated/api.js" }, "./schema": "./convex/schema.ts", - "./auth": "./convex/auth.config.ts" + "./auth": "./convex/auth.config.ts", + "./helpers": "./convex/helpers.ts", + "./budgetSummary": "./convex/budgetSummary.ts", + "./monthDetail": "./convex/monthDetail.ts" }, "scripts": { "dev": "dotenv -e ../../.env.local -- convex dev", "deploy": "dotenv -e ../../.env.local -- convex deploy", "lint": "eslint --fix", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run", + "seed": "tsx scripts/seed.ts", + "seed:clear": "tsx scripts/clearAll.ts" }, "dependencies": { - "convex": "^1.27.3" + "convex": "^1.27.3", + "dotenv": "^16.4.5", + "xlsx": "^0.18.5" }, "devDependencies": { "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^22.10.2", "dotenv-cli": "^11.0.0", - "typescript": "^5.7.2" + "tsx": "^4.21.0", + "typescript": "^5.7.2", + "vitest": "^3.0.5" } } diff --git a/packages/convex/scripts/README.md b/packages/convex/scripts/README.md new file mode 100644 index 0000000..08df5f9 --- /dev/null +++ b/packages/convex/scripts/README.md @@ -0,0 +1,87 @@ +# CREAM — Excel to Convex Migration + +Personal finance tracker migrating from an Excel workbook (CREAM.xlsx) to a Convex backend. + +## Project Structure + +├── CREAM.xlsx # Source spreadsheet (12 sheets, ~1,200 data rows total) +├── seedScript.ts # Node script to read xlsx and seed Convex +├── convex/ +│ ├── schema.ts # Table definitions (9 tables) +│ ├── helpers.ts # Derived field functions + Excel date ↔ Unix timestamp +│ ├── seed.ts # Bulk insert mutations (used by seedScript.ts) +│ ├── queries.ts # Read queries — attach computed fields at read time +│ └── mutations.ts # CRUD mutations + addSnapshot + updateExchangeRates +└── README.md + +## Architecture + +### Core Principle: Store raw inputs, derive everything else The Excel workbook has many columns that are formulas (TOTAL, NET, Equity, etc.). + +These are NOT stored in Convex. Instead, `helpers.ts` exports pure functions that compute them, and `queries.ts` attaches them to query results at read time. +This keeps the database normalized and the logic testable. + +### Tables ↔ Excel Sheets | Convex Table | Excel Sheet | ~Rows | Key Fields | + +|------------------------|----------------|-------|------------| +| `currentAccounts` | Current | 157 | NAB current, NAB shared, ING, Other | +| `cashAccounts` | Cash | 157 | NAB saver, EasyStreet | +| `ukAccounts` | UK | 159 | 4× HSBC accounts (GBP), GBPAUD rate | +| `superAccounts` | Super | 157 | HL (GBP), FirstChoice, REST, AustralianSuper, GBPAUD | +| `investmentAccounts` | Investments | 202 | MonitorMoney, MarginLoan, NABTrade, Schwab, USDAUD, + 5 more | +| `mortgage` | Mortgage | 113 | Deposit, Mum, 2× Debt, Interest, Principal, Price, Land, Growth | +| `budget` | Sink or Swim | 139 | Income (Ray/Romi/Lola), Expenses (Qantas/NAB/ANZ/OneOffs/Shared) | +| `cryptoTransactions` | Crypto | ~21 | Platform, date, deposit/withdrawal, amount | +| `cryptoSummaries` | Crypto | 2 | Platform totals (deposited, withdrawn, current value) | + +### Sheets NOT migrated (fully derived) + +- **TOTALS** — Reproduced by `getLatestTotals` and `getTotalsHistory` queries +- **Perf** — Period-over-period growth, compute from totals history +- **Ratios** — Single-point ratios, compute from latest totals +- **Growth** — 1M/3M/12M growth windows, compute from totals history + +### Derived Fields (computed, not stored) All in `helpers.ts`. Key examples: + +- `currentAccountTotal()` → sum of 4 bank accounts +- `ukTotalGbp()` / `ukTotalAud()` → sum GBP accounts, multiply by exchange rate +- `superTotal()` → convert HL to AUD + sum AU super accounts +- `investmentTotal()` → net monitor money + all brokerages (Schwab converted via USDAUD) +- `mortgageEquity()` → price - (debtSimplifier + debtFixed) +- `budgetNetGainLoss()` → totalIn - totalOut +- `computeTotals()` → joins latest row from each table into the TOTALS view + +### Date Handling All dates stored two ways: + +- `excelDate: number` — Excel serial date (e.g., 45937 = a date in 2025). Preserves original reference. +- `date: number` — Unix timestamp in ms. Used for indexing and display. + +- Conversion functions in `helpers.ts`: `excelDateToTimestamp()` and `timestampToExcelDate()`. + +### Excel Column Mappings Derived columns are SKIPPED during import. The `seedScript.ts` maps by column index: + +- **Investments**: col 3 (Monitor Money NET) = derived, skip. Read cols 1-2, 4-12. +- **Mortgage**: cols 10-15 (Available ×3, My Available, Liquid, Equity) = derived, skip. Read cols 1-9, 16-18. +- **Sink or Swim**: cols 3, 12, 17-19 = derived or blank, skip. Column order is non-sequential — income is cols 5-6+16, expenses are cols 1-2+13-15. +- **UK**: cols 5-6 (TOTAL £, TOTAL $) and col 8 (AUDGBP) = derived. +- **Super**: col 2 (HL $) and col 7 (TOTAL) = derived. + +### Key Mutations - `addSnapshot` — The primary "add a new row" operation. Pass an excelDate and any combination of table data. Equivalent to adding a row across multiple Excel sheets at once. - `updateExchangeRates` — Bulk-update GBPAUD and/or USDAUD across UK, Super, and Investments for a given date. - Individual `add/update/delete` mutations exist for every table. + +## Setup & Seed ```bash npm install convex xlsx tsx npx convex dev + +# Start Convex dev server npx tsx seedScript.ts + +# Seed all data from CREAM.xlsx + +The seed script batches inserts in chunks of 100 rows per mutation call. + +``` +npx tsx seedScript.ts +``` + +## Dependencies + +convex — backend framework +xlsx — reads the Excel file in seedScript.ts +tsx — runs TypeScript directly for the seed script diff --git a/packages/convex/scripts/clearAll.ts b/packages/convex/scripts/clearAll.ts new file mode 100644 index 0000000..6f9bc85 --- /dev/null +++ b/packages/convex/scripts/clearAll.ts @@ -0,0 +1,50 @@ +/** + * Clear every seedable table in @repo/convex. + * Run with: pnpm seed:clear + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as dotenv from 'dotenv'; +import { ConvexHttpClient } from 'convex/browser'; +import { api } from '@repo/convex'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') }); + +const CONVEX_URL = + process.env.CONVEX_URL ?? + process.env.NEXT_PUBLIC_CONVEX_URL ?? + process.env.VITE_CONVEX_URL; + +if (!CONVEX_URL) { + console.error('No CONVEX_URL found in .env.local.'); + process.exit(1); +} + +const client = new ConvexHttpClient(CONVEX_URL); + +const tables = [ + 'currentAccounts', + 'cashAccounts', + 'ukAccounts', + 'superAccounts', + 'investmentAccounts', + 'mortgage', + 'budget', + 'cryptoTransactions', + 'cryptoSummaries' +] as const; + +async function main() { + for (const table of tables) { + const { deleted } = await client.mutation(api.seed.clearTable, { table }); + console.log(` ${table}: deleted ${deleted}`); + } + console.log('\nClear complete.'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/convex/scripts/seed.ts b/packages/convex/scripts/seed.ts new file mode 100644 index 0000000..3786a71 --- /dev/null +++ b/packages/convex/scripts/seed.ts @@ -0,0 +1,416 @@ +/** + * Seed @repo/convex from CREAM.xlsx. + * + * Run with: pnpm --filter @repo/convex seed + * or: pnpm seed (root convenience alias) + * + * Loads env from ../../.env.local (CONVEX_URL or NEXT_PUBLIC_CONVEX_URL or VITE_CONVEX_URL). + * Every money column is converted to integer cents via toCents(). + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as dotenv from 'dotenv'; +import { ConvexHttpClient } from 'convex/browser'; +import { api } from '@repo/convex'; +import { toCents } from '@repo/convex/helpers'; +import XLSX from 'xlsx'; + +// Load monorepo root .env.local (must run before reading process.env) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') }); + +const CONVEX_URL = + process.env.CONVEX_URL ?? + process.env.NEXT_PUBLIC_CONVEX_URL ?? + process.env.VITE_CONVEX_URL; + +if (!CONVEX_URL) { + console.error( + 'No CONVEX_URL found. Set CONVEX_URL or NEXT_PUBLIC_CONVEX_URL in .env.local.' + ); + process.exit(1); +} + +const client = new ConvexHttpClient(CONVEX_URL); +const XLSX_PATH = path.resolve(__dirname, 'CREAM.xlsx'); + +function excelDateToTimestamp(excelDate: number): number { + const MS_PER_DAY = 86400000; + const EXCEL_EPOCH = new Date(Date.UTC(1899, 11, 30)).getTime(); + return EXCEL_EPOCH + excelDate * MS_PER_DAY; +} + +function num(val: unknown): number { + if (val === '' || val === null || val === undefined) return 0; + const n = Number(val); + return isNaN(n) ? 0 : n; +} + +function optNum(val: unknown): number | undefined { + if (val === '' || val === null || val === undefined) return undefined; + const n = Number(val); + return isNaN(n) ? undefined : n; +} + +async function main() { + console.log('Clearing existing tables...'); + for (const table of [ + 'currentAccounts', + 'cashAccounts', + 'ukAccounts', + 'superAccounts', + 'investmentAccounts', + 'mortgage', + 'budget', + 'cryptoTransactions', + 'cryptoSummaries' + ] as const) { + const { deleted } = await client.mutation(api.seed.clearTable, { table }); + console.log(` cleared ${table}: ${deleted}`); + } + console.log('Reading CREAM.xlsx...'); + const wb = XLSX.readFile(XLSX_PATH); + + // ── Current Accounts ────────────────────────────────────── + { + const ws = wb.Sheets['Current']; + const data = XLSX.utils.sheet_to_json(ws, { header: 1 }); + const rows = data + .slice(1) + .filter((r: any[]) => r[0] && typeof r[0] === 'number') + .map((r: any[]) => ({ + date: excelDateToTimestamp(num(r[0])), + currentSecondary: toCents(num(r[1])), + shared: toCents(num(r[2])), + currentPrimary: toCents(num(r[3])), + other: toCents(num(r[4])) + // r[5] = TOTAL (derived, skip) + })); + + for (let i = 0; i < rows.length; i += 100) { + const batch = rows.slice(i, i + 100); + const result = await client.mutation(api.seed.seedCurrentAccounts, { + rows: batch + }); + console.log( + ` currentAccounts: inserted ${result.inserted} (batch ${Math.floor(i / 100) + 1})` + ); + } + console.log(` currentAccounts: ${rows.length} total rows`); + } + + // ── Cash Accounts ───────────────────────────────────────── + { + const ws = wb.Sheets['Cash']; + const data = XLSX.utils.sheet_to_json(ws, { header: 1 }); + const rows = data + .slice(1) + .filter((r: any[]) => r[0] && typeof r[0] === 'number') + .map((r: any[]) => ({ + date: excelDateToTimestamp(num(r[0])), + saver: toCents(num(r[1])), + highInterest: toCents(num(r[2])) + // r[3] = TOTAL (derived, skip) + })); + + for (let i = 0; i < rows.length; i += 100) { + const batch = rows.slice(i, i + 100); + const result = await client.mutation(api.seed.seedCashAccounts, { + rows: batch + }); + console.log( + ` cashAccounts: inserted ${result.inserted} (batch ${Math.floor(i / 100) + 1})` + ); + } + console.log(` cashAccounts: ${rows.length} total rows`); + } + + // ── UK Accounts ─────────────────────────────────────────── + { + const ws = wb.Sheets['UK']; + const data = XLSX.utils.sheet_to_json(ws, { header: 1 }); + const rows = data + .slice(1) + .filter((r: any[]) => r[0] && typeof r[0] === 'number') + .map((r: any[]) => ({ + date: excelDateToTimestamp(num(r[0])), + currentGbp: toCents(num(r[1])), + saverGbp: toCents(num(r[2])), + cashIsaGbp: toCents(num(r[3])), + sharesIsaGbp: toCents(num(r[4])), + // r[5] = TOTAL GBP (derived) + // r[6] = TOTAL AUD (derived) + gbpAud: num(r[7]) + // r[8] = AUDGBP (derived) + })); + + for (let i = 0; i < rows.length; i += 100) { + const batch = rows.slice(i, i + 100); + const result = await client.mutation(api.seed.seedUkAccounts, { + rows: batch + }); + console.log( + ` ukAccounts: inserted ${result.inserted} (batch ${Math.floor(i / 100) + 1})` + ); + } + console.log(` ukAccounts: ${rows.length} total rows`); + } + + // ── Super Accounts ──────────────────────────────────────── + { + const ws = wb.Sheets['Super']; + const data = XLSX.utils.sheet_to_json(ws, { header: 1 }); + const rows = data + .slice(1) + .filter((r: any[]) => r[0] && typeof r[0] === 'number') + .map((r: any[]) => ({ + date: excelDateToTimestamp(num(r[0])), + pension: toCents(num(r[1])), + // r[2] = Pension AUD (derived) + super1: toCents(num(r[3])), + super2: toCents(num(r[4])), + super3: toCents(num(r[5])), + gbpAud: num(r[6]) + // r[7] = TOTAL (derived) + })); + + for (let i = 0; i < rows.length; i += 100) { + const batch = rows.slice(i, i + 100); + const result = await client.mutation(api.seed.seedSuperAccounts, { + rows: batch + }); + console.log( + ` superAccounts: inserted ${result.inserted} (batch ${Math.floor(i / 100) + 1})` + ); + } + console.log(` superAccounts: ${rows.length} total rows`); + } + + // ── Investment Accounts ─────────────────────────────────── + { + const ws = wb.Sheets['Investments']; + const data = XLSX.utils.sheet_to_json(ws, { header: 1 }); + const rows = data + .slice(1) + .filter((r: any[]) => r[0] && typeof r[0] === 'number') + .map((r: any[]) => ({ + date: excelDateToTimestamp(num(r[0])), + managedFund1: toCents(num(r[1])), + investmentLoan: toCents(num(r[2])), + // r[3] = Managed Fund NET (derived) + tradingAus1: toCents(num(r[4])), + tradingInt1: toCents(num(r[5])), + tradingInt2: toCents(num(r[6])), + usdAud: num(r[7]), + managedFund2: toCents(num(r[8])), + tradingAus2: toCents(num(r[9])), + managedFund3: toCents(num(r[10])), + crypto1: toCents(num(r[11])), + crypto2: toCents(num(r[12])) + // r[13] = TOTAL (derived) + })); + + for (let i = 0; i < rows.length; i += 100) { + const batch = rows.slice(i, i + 100); + const result = await client.mutation(api.seed.seedInvestmentAccounts, { + rows: batch + }); + console.log( + ` investmentAccounts: inserted ${result.inserted} (batch ${Math.floor(i / 100) + 1})` + ); + } + console.log(` investmentAccounts: ${rows.length} total rows`); + } + + // ── Mortgage ────────────────────────────────────────────── + { + const ws = wb.Sheets['Mortgage']; + const data = XLSX.utils.sheet_to_json(ws, { header: 1 }); + const rows = data + .slice(1) + .filter((r: any[]) => r[0] && typeof r[0] === 'number') + .map((r: any[]) => ({ + date: excelDateToTimestamp(num(r[0])), + deposit: toCents(num(r[1])), + familyContrib: toCents(num(r[2])), + debt1: toCents(num(r[3])), + debt2: toCents(num(r[4])), + interestCharged: toCents(num(r[5])), + principalPaid: toCents(num(r[6])), + contrib1: toCents(num(r[7])), + contrib2: toCents(num(r[8])), + contrib3: toCents(num(r[9])), + // r[10..15] = Available/My available/Liquid/Equity (derived) + price: toCents(num(r[16])), + landValue: toCents(num(r[17])), + capitalGrowth: toCents(num(r[18])) + })); + + for (let i = 0; i < rows.length; i += 100) { + const batch = rows.slice(i, i + 100); + const result = await client.mutation(api.seed.seedMortgage, { + rows: batch + }); + console.log( + ` mortgage: inserted ${result.inserted} (batch ${Math.floor(i / 100) + 1})` + ); + } + console.log(` mortgage: ${rows.length} total rows`); + } + + // ── Budget (Sink or Swim) ───────────────────────────────── + { + const ws = wb.Sheets['Sink or Swim']; + const data = XLSX.utils.sheet_to_json(ws, { header: 1 }); + // Columns: Date(0) | Credit 2(1) | Credit 1(2) | Spend(3=derived) | Sink or swim(4) | + // Income Primary(5) | Income Secondary(6) | Variable(7) | Fixed(8) | Rent(9) | + // Rate Var(10) | Rate Fix(11) | blank(12) | Credit 3(13) | + // One-offs(14) | Shared(15) | Bill Contrib(16) | + // IN(17=derived) | OUT(18=derived) | NET(19=derived) + const rows = data + .slice(1) + .filter((r: any[]) => r[0] && typeof r[0] === 'number') + .map((r: any[]) => ({ + date: excelDateToTimestamp(num(r[0])), + incomePrimary: toCents(num(r[5])), + incomeSecondary: toCents(num(r[6])), + billContrib: toCents(num(r[16])), + credit2: toCents(num(r[1])), + credit1: toCents(num(r[2])), + credit3: toCents(num(r[13])), + oneOffs: toCents(num(r[14])), + shared: toCents(num(r[15])), + variable: toCents(num(r[7])), + fixed: toCents(num(r[8])), + rent: toCents(num(r[9])), + rateVar: optNum(r[10]), + rateFix: optNum(r[11]) + })); + + for (let i = 0; i < rows.length; i += 100) { + const batch = rows.slice(i, i + 100); + const result = await client.mutation(api.seed.seedBudget, { + rows: batch + }); + console.log( + ` budget: inserted ${result.inserted} (batch ${Math.floor(i / 100) + 1})` + ); + } + console.log(` budget: ${rows.length} total rows`); + } + + // ── Crypto Transactions ─────────────────────────────────── + { + const ws = wb.Sheets['Crypto']; + const data = XLSX.utils.sheet_to_json(ws, { header: 1 }); + + const txns: Array<{ + platform: 'platform_a' | 'platform_b'; + date?: number; + type: 'deposit' | 'withdrawal'; + amount: number; + }> = []; + + // Platform A section: rows after header until "Platform B" label + for (let i = 1; i < data.length; i++) { + const r = data[i]; + if (!r || !r[0]) continue; + if (typeof r[0] === 'string' && ['Total', 'Value', 'Net'].includes(r[0])) + continue; + if (typeof r[0] === 'string' && r[0] === 'Swyftx') break; + + if (typeof r[0] === 'number' && num(r[1]) > 0) { + txns.push({ + platform: 'platform_a', + date: excelDateToTimestamp(num(r[0])), + type: 'deposit', + amount: toCents(num(r[1])) + }); + } + if (typeof r[0] === 'number' && num(r[2]) > 0) { + txns.push({ + platform: 'platform_a', + date: excelDateToTimestamp(num(r[0])), + type: 'withdrawal', + amount: toCents(num(r[2])) + }); + } + } + + if (txns.length > 0) { + const result = await client.mutation(api.seed.seedCryptoTransactions, { + rows: txns + }); + console.log( + ` cryptoTransactions (platform_a): inserted ${result.inserted}` + ); + } + console.log(` cryptoTransactions: ${txns.length} total rows`); + } + + // ── Crypto Summaries ────────────────────────────────────── + { + const ws = wb.Sheets['Crypto']; + const data = XLSX.utils.sheet_to_json(ws, { header: 1 }); + + const summaries: Array<{ + platform: 'platform_a' | 'platform_b'; + totalDeposited: number; + totalWithdrawn: number; + currentValue: number; + }> = [ + { + platform: 'platform_a', + totalDeposited: 0, + totalWithdrawn: 0, + currentValue: 0 + }, + { + platform: 'platform_b', + totalDeposited: 0, + totalWithdrawn: 0, + currentValue: 0 + } + ]; + + // Parse summary rows dynamically + let inPlatformB = false; + for (let i = 0; i < data.length; i++) { + const r = data[i]; + if (!r) continue; + if (r[0] === 'Swyftx') { + inPlatformB = true; + continue; + } + + const target = inPlatformB ? summaries[1] : summaries[0]; + + if (r[0] === 'Total' && !inPlatformB) { + target.totalDeposited = toCents(num(r[1])); + target.totalWithdrawn = toCents(num(r[2])); + } + if (r[0] === 'Value' && !inPlatformB && i > 20) { + target.currentValue = toCents(num(r[2])); + } + if (r[0] === 'Deposited Fiat') { + target.totalDeposited = toCents(num(r[2])); + } + if (r[0] === 'Withdrawn Fiat') { + target.totalWithdrawn = toCents(num(r[2])); + } + if (r[0] === 'Value' && inPlatformB) { + target.currentValue = toCents(num(r[2])); + } + } + + const result = await client.mutation(api.seed.seedCryptoSummaries, { + rows: summaries + }); + console.log(` cryptoSummaries: inserted ${result.inserted}`); + } + + console.log('\nSeed complete!'); +} + +main().catch(console.error); diff --git a/packages/convex/vitest.config.ts b/packages/convex/vitest.config.ts new file mode 100644 index 0000000..ab066af --- /dev/null +++ b/packages/convex/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['convex/**/*.test.ts'] + } +}); diff --git a/packages/shell/src/AppFrame.tsx b/packages/shell/src/AppFrame.tsx index 6888a85..cd23ac4 100644 --- a/packages/shell/src/AppFrame.tsx +++ b/packages/shell/src/AppFrame.tsx @@ -18,16 +18,14 @@ export function AppFrame({ children, onSignOut }: AppFrameProps) { - // Each app declares its own appId — that's authoritative for sidebar - // active state. We used to derive it from window.location.pathname, but - // that breaks in dev where each app serves at `/` on its own port (and - // would always look like "home"). return ( -
- -
-
-
{children}
+
+
+ +
+
+
{children}
+
); diff --git a/packages/shell/src/Header.tsx b/packages/shell/src/Header.tsx index b87a04b..c7a926d 100644 --- a/packages/shell/src/Header.tsx +++ b/packages/shell/src/Header.tsx @@ -2,20 +2,18 @@ import type { ReactNode } from 'react'; export interface HeaderProps { title: string; - brandLabel?: string; actions?: ReactNode; } -export function Header({ title, brandLabel = 'D', actions }: HeaderProps) { +export function Header({ title, actions }: HeaderProps) { return ( -
-
- {brandLabel} -
-

+
+

{title}

- {actions ?
{actions}
: null} + {actions ? ( +
{actions}
+ ) : null}
); } diff --git a/packages/shell/src/Sidebar.tsx b/packages/shell/src/Sidebar.tsx index 4246753..73cbc92 100644 --- a/packages/shell/src/Sidebar.tsx +++ b/packages/shell/src/Sidebar.tsx @@ -1,11 +1,6 @@ import { LogOut } from 'lucide-react'; import clsx from 'clsx'; -import { - APPS, - getAppHref, - type AppDescriptor, - type AppId -} from './apps'; +import { APPS, getAppHref, type AppDescriptor, type AppId } from './apps'; import { useUrlAuth } from './auth'; const homeApp = APPS.find((a) => a.id === 'home')!; @@ -21,9 +16,6 @@ export function Sidebar({ onSignOut, brandLabel = 'D' }: SidebarProps) { - // When signed-in via Clerk, append the dev session token to cross-origin - // URLs so the destination port auto-rehydrates the session. In production - // (same origin) and pre-Clerk dev (no provider), this is the identity. const urlAuth = useUrlAuth(); const buildHref = (app: AppDescriptor) => { const href = getAppHref(app); @@ -33,17 +25,19 @@ export function Sidebar({ return ( diff --git a/packages/shell/src/auth.tsx b/packages/shell/src/auth.tsx index 8a1e790..520aaf2 100644 --- a/packages/shell/src/auth.tsx +++ b/packages/shell/src/auth.tsx @@ -45,7 +45,6 @@ export function AuthGate({ publishableKey, children }: AuthGateProps) { if (!publishableKey) { if (typeof window !== 'undefined' && !warned.current) { - // eslint-disable-next-line no-console console.warn( '[doma] AuthGate is bypassed: VITE_CLERK_PUBLISHABLE_KEY is not set. ' + 'See docs/auth.md to enable sign-in.' diff --git a/packages/tokens/theme.css b/packages/tokens/theme.css index 878d2c6..783a38d 100644 --- a/packages/tokens/theme.css +++ b/packages/tokens/theme.css @@ -35,4 +35,25 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + + --color-warm-bg: var(--warm-bg); + --color-warm-bg-card: var(--warm-bg-card); + --color-warm-bg-card-soft: var(--warm-bg-card-soft); + --color-warm-border: var(--warm-border); + --color-warm-bg-dark: var(--warm-bg-dark); + --color-warm-bg-dark-muted: var(--warm-bg-dark-muted); + --color-warm-accent: var(--warm-accent); + --color-warm-accent-soft: var(--warm-accent-soft); + --color-warm-text-primary: var(--warm-text-primary); + --color-warm-text-secondary: var(--warm-text-secondary); + --color-warm-text-tertiary: var(--warm-text-tertiary); + --color-warm-text-on-dark: var(--warm-text-on-dark); + --color-warm-section-income: var(--warm-section-income); + --color-warm-section-spend: var(--warm-section-spend); + --color-warm-section-mortgage: var(--warm-section-mortgage); + --color-warm-positive: var(--warm-positive); + --color-warm-negative: var(--warm-negative); + + --font-warm-display: var(--warm-font-display); + --font-warm-body: var(--warm-font-body); } diff --git a/packages/tokens/tokens.css b/packages/tokens/tokens.css index f9fb8d6..dfed6c5 100644 --- a/packages/tokens/tokens.css +++ b/packages/tokens/tokens.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=DM+Serif+Display&display=swap'); + :root { --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); @@ -32,6 +34,28 @@ --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.871 0.006 286.286); + + /* Warm theme — Budget v3 design */ + --warm-bg: #FAF9F7; + --warm-bg-card: #FFFCF6; + --warm-bg-card-soft: #FFF8EE; + --warm-border: #EFE3D2; + --warm-bg-dark: #2D2D2D; + --warm-bg-dark-muted: #44403C; + --warm-accent: #D85A36; + --warm-accent-soft: #F4744A; + --warm-text-primary: #3D2E22; + --warm-text-secondary: #7C6755; + --warm-text-tertiary: #A8A29E; + --warm-text-on-dark: #FAF9F7; + --warm-section-income: #D8E9D2; + --warm-section-spend: #FFDFC7; + --warm-section-mortgage: #F2E4C9; + --warm-positive: #5F9466; + --warm-negative: var(--warm-accent); /* matches accent for now; can diverge later */ + + --warm-font-display: 'DM Serif Display', 'Times New Roman', serif; + --warm-font-body: 'DM Sans', system-ui, -apple-system, sans-serif; } .dark { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74d03a7..9adeb4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,7 +136,7 @@ importers: version: 0.561.0(react@19.2.0) nitro: specifier: npm:nitro-nightly@latest - version: nitro-nightly@3.0.1-20260512-093145-0498ce70(chokidar@5.0.0)(dotenv@17.2.4)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.57.1)(vite@7.3.1(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.47.1)(tsx@4.21.0)) + version: nitro-nightly@3.0.1-20260512-093145-0498ce70(chokidar@5.0.0)(dotenv@16.6.1)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.57.1)(vite@7.3.1(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.47.1)(tsx@4.21.0)) react: specifier: ^19.2.0 version: 19.2.0 @@ -275,7 +275,7 @@ importers: version: 0.561.0(react@19.2.0) nitro: specifier: npm:nitro-nightly@latest - version: nitro-nightly@3.0.1-20260512-093145-0498ce70(chokidar@5.0.0)(dotenv@17.2.4)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.57.1)(vite@7.3.1(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.47.1)(tsx@4.21.0)) + version: nitro-nightly@3.0.1-20260512-093145-0498ce70(chokidar@5.0.0)(dotenv@16.6.1)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.57.1)(vite@7.3.1(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.47.1)(tsx@4.21.0)) react: specifier: ^19.2.0 version: 19.2.0 @@ -346,6 +346,12 @@ importers: convex: specifier: ^1.27.3 version: 1.31.7(@clerk/clerk-react@5.61.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 devDependencies: '@repo/eslint-config': specifier: workspace:* @@ -359,9 +365,15 @@ importers: dotenv-cli: specifier: ^11.0.0 version: 11.0.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.7.2 version: 5.9.2 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(terser@5.47.1)(tsx@4.21.0) packages/eslint-config: devDependencies: @@ -11221,7 +11233,7 @@ snapshots: nf3@0.3.17: {} - nitro-nightly@3.0.1-20260512-093145-0498ce70(chokidar@5.0.0)(dotenv@17.2.4)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.57.1)(vite@7.3.1(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.47.1)(tsx@4.21.0)): + nitro-nightly@3.0.1-20260512-093145-0498ce70(chokidar@5.0.0)(dotenv@16.6.1)(jiti@2.6.1)(lru-cache@11.2.5)(rollup@4.57.1)(vite@7.3.1(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.47.1)(tsx@4.21.0)): dependencies: consola: 3.4.2 crossws: 0.4.5(srvx@0.11.15) @@ -11238,7 +11250,7 @@ snapshots: unenv: 2.0.0-rc.24 unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4)(lru-cache@11.2.5)(ofetch@2.0.0-alpha.3) optionalDependencies: - dotenv: 17.2.4 + dotenv: 16.6.1 jiti: 2.6.1 rollup: 4.57.1 vite: 7.3.1(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.47.1)(tsx@4.21.0)