diff --git a/src/App.tsx b/src/App.tsx index 772c9c0..b1ec526 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,7 +45,7 @@ export function App() { {saveError && ⚠ Save failed} setKofiOpen(true)}> - ☕ Donate + ☕ Donate updateLedgerChart(tab, c.id, patch)} - onRemove={(charts?.length ?? 0) > 1 ? () => removeLedgerChart(tab, c.id) : undefined} + onRemove={() => removeLedgerChart(tab, c.id)} defaultRangeStart={defaultRangeStart} defaultRangeEnd={defaultRangeEnd} /> @@ -55,7 +55,7 @@ export function ChartSidebar({ tab, defaultRangeStart, defaultRangeEnd }: { key={c.id} config={c} onChange={(patch) => updateChart(c.id, patch)} - onRemove={dashCharts.length > 1 ? () => removeChart(c.id) : undefined} + onRemove={() => removeChart(c.id)} /> ))} addChart()}> diff --git a/src/components/spreadsheet/EntryForm.tsx b/src/components/spreadsheet/EntryForm.tsx index c2d79dc..ceff076 100644 --- a/src/components/spreadsheet/EntryForm.tsx +++ b/src/components/spreadsheet/EntryForm.tsx @@ -94,6 +94,9 @@ export function WorkEntryForm({ const [hours, setHours] = useState(initial?.hours?.toString() ?? ''); const [rate, setRate] = useState(initial?.rate?.toString() ?? String(defaultRate)); const [client, setClient] = useState(initial?.client ?? ''); + const [paymentOutstanding, setPaymentOutstanding] = useState( + initial?.paymentOutstanding ?? true, + ); const computedValue = mode === 'amount' @@ -102,7 +105,7 @@ export function WorkEntryForm({ const submit = (e: React.FormEvent) => { e.preventDefault(); - const base: WorkDraft = { date, description: desc, client: client || undefined }; + const base: WorkDraft = { date, description: desc, client: client || undefined, paymentOutstanding }; if (mode === 'amount') base.amount = parseFloat(amount) || 0; else { base.hours = parseFloat(hours) || 0; @@ -158,6 +161,15 @@ export function WorkEntryForm({ Value: ${computedValue.toFixed(2)} + + setPaymentOutstanding(e.target.checked)} + /> + Payment outstanding (awaiting receipt) + + Cancel Save diff --git a/src/components/spreadsheet/HierSpreadsheet.tsx b/src/components/spreadsheet/HierSpreadsheet.tsx index e60eac7..efb7e49 100644 --- a/src/components/spreadsheet/HierSpreadsheet.tsx +++ b/src/components/spreadsheet/HierSpreadsheet.tsx @@ -6,7 +6,7 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import clsx from 'clsx'; -import type { HierNode } from '@/types'; +import type { HierNode, WorkEntry } from '@/types'; import { fmtMoney } from '@/lib/format'; export type ExpandLevel = 'year' | 'month' | 'day' | 'item'; @@ -20,6 +20,8 @@ interface Props { onDelete?: (node: HierNode) => void; /** Called when user clicks "+" on a day row; receives the ISO date string */ onAddForDay?: (date: string) => void; + /** When provided, shows a payment-outstanding toggle on item rows */ + onToggleOutstanding?: (node: HierNode) => void; /** Label for the value column (e.g. "Earned", "Paid", "Spent") */ valueLabel: string; } @@ -55,7 +57,7 @@ function filterEmpty(nodes: HierNode[]): HierNode[] { .map((n) => ({ ...n, children: filterEmpty(n.children) })); } -export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay, valueLabel }: Props) { +export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay, onToggleOutstanding, valueLabel }: Props) { const [expanded, setExpanded] = useState>(new Set()); const [hideEmpty, setHideEmpty] = useState(false); const [selectedLevel, setSelectedLevel] = useState('year'); @@ -168,7 +170,7 @@ export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay, Period / Item {valueLabel} - + @@ -225,6 +227,19 @@ export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay, + )} + {isItem && onToggleOutstanding && (() => { + const outstanding = !!(node.entry as WorkEntry)?.paymentOutstanding; + return ( + onToggleOutstanding(node)} + > + {outstanding ? '⏳' : '·'} + + ); + })()} {isItem && onEdit && ( $/hr Flat $ Value + Unpaid @@ -245,6 +248,16 @@ export function WorkSheet({ {draftValue > 0 ? fmtMoney(draftValue) : '—'} + + setDraft({ ...draft, paymentOutstanding: !draft.paymentOutstanding })} + > + {draft.paymentOutstanding ? '⏳' : '·'} + + @@ -255,7 +268,7 @@ export function WorkSheet({ {filtered.length === 0 && ( - + No entries in this period @@ -288,6 +301,16 @@ export function WorkSheet({ }); }} /> {fmtMoney(val)} + + onUpdate(e.id, { paymentOutstanding: !e.paymentOutstanding })} + > + {e.paymentOutstanding ? '⏳' : '·'} + + setDeleting(e.id)} title="Delete"> @@ -302,7 +325,7 @@ export function WorkSheet({ Total {fmtMoney(total)} - + diff --git a/src/lib/format.ts b/src/lib/format.ts index 5169631..08c537b 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -55,6 +55,39 @@ export function fmtPct(n: number): string { return (n * 100).toFixed(1) + '%'; } -export function todayISO(): string { - return new Date().toISOString().slice(0, 10); +// ─── Timezone ──────────────────────────────────────────────────────────────── + +let _tz: string | undefined; + +/** Called by the store whenever the timezone setting changes. */ +export function setActiveTZ(tz: string | undefined) { _tz = tz; } + +/** + * Returns the current date/time broken down in the active timezone. + * All date-sensitive calculations should use this instead of `new Date()` directly. + */ +export function nowInTZ(): { + year: number; monthIdx: number; day: number; + isoDate: string; isoMonth: string; +} { + const d = new Date(); + if (_tz) { + try { + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone: _tz, year: 'numeric', month: '2-digit', day: '2-digit', + }); + const isoDate = fmt.format(d); // YYYY-MM-DD + const [year, month, day] = isoDate.split('-').map(Number); + return { year, monthIdx: month - 1, day, isoDate, isoMonth: isoDate.slice(0, 7) }; + } catch { /* fall through to system time */ } + } + const year = d.getFullYear(); + const monthIdx = d.getMonth(); + const day = d.getDate(); + const isoDate = `${year}-${String(monthIdx + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + return { year, monthIdx, day, isoDate, isoMonth: isoDate.slice(0, 7) }; +} + +export function todayISO(): string { + return nowInTZ().isoDate; } diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 406a51f..d481d90 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -6,27 +6,57 @@ import { useMemo, useState } from 'react'; import { useAppStore } from '@/store/appStore'; import { aggregate } from '@/lib/stats/aggregate'; import { calculateTax } from '@/lib/tax/calculate'; -import { fmtMoney, fmtMoneyShort } from '@/lib/format'; +import { fmtMoney, fmtMoneyShort, nowInTZ } from '@/lib/format'; import { ChartPanel } from '@/components/charts/ChartPanel'; import { Modal } from '@/components/common/Modal'; +import { workEntryValue } from '@/types'; import type { DashboardWidget } from '@/types'; const WIDGET_LABELS: Record = { ytdWorkValue: 'YTD Work Value', ytdWorkProj: 'Work Value Projected', - ytdPayments: 'YTD Payments', + ytdDailyAvg: 'YTD Daily Average', + ytdPayments: 'YTD Payments Received', ytdPaymentsProj: 'Payments Projected', + expectedPayments: 'Expected Payments', ytdExpenses: 'YTD Expenses', + avgDailyExpenses: 'YTD Daily Average', ytdNet: 'YTD Net Income', ytdNetProj: 'Net Income Projected', - nextQuarterlyDue: 'Next Quarterly Due', - projectedAnnualTax: 'Projected Annual Tax', - ytdActualTax: 'YTD Actual Tax', - taxRemainingDue: 'Tax Remaining Due', avgMonthlyNet: 'Avg Monthly Net', - avgDailyWork: 'Avg Daily Work', + nextQuarterlyDue: 'Next Quarterly Due', + ytdActualTax: 'YTD Actual Tax', + projectedAnnualTax: 'Projected Annual Tax', + taxRemainingDue: 'Tax Remaining Due', + effectiveTaxRate: 'Effective Tax Rate', }; +// Pairs: actual widget + optional projected widget displayed together in one card +type WidgetEntry = { actual: DashboardWidget; proj?: DashboardWidget }; + +const WIDGET_GROUPS: { label: string; entries: WidgetEntry[] }[] = [ + { label: 'Work', entries: [ + { actual: 'ytdWorkValue', proj: 'ytdWorkProj' }, + { actual: 'ytdDailyAvg' }, + ]}, + { label: 'Payments', entries: [ + { actual: 'ytdPayments', proj: 'ytdPaymentsProj' }, + { actual: 'expectedPayments' }, + ]}, + { label: 'Expenses', entries: [ + { actual: 'ytdExpenses' }, + { actual: 'avgDailyExpenses' }, + { actual: 'ytdNet', proj: 'ytdNetProj' }, + { actual: 'avgMonthlyNet' }, + ]}, + { label: 'Tax', entries: [ + { actual: 'nextQuarterlyDue' }, + { actual: 'ytdActualTax', proj: 'projectedAnnualTax' }, + { actual: 'taxRemainingDue' }, + { actual: 'effectiveTaxRate' }, + ]}, +]; + export function DashboardPage() { const data = useAppStore((s) => s.data); const addChart = useAppStore((s) => s.addChart); @@ -41,7 +71,7 @@ export function DashboardPage() { [data.workEntries, data.payments, data.expenses], ); - const currentYear = new Date().getFullYear(); + const { year: currentYear, isoDate: todayISO } = nowInTZ(); const currentYearStats = stats.years.find((y) => y.label === String(currentYear)); const taxInputs = data.taxInputs[currentYear] ?? { taxYear: currentYear, filingStatus: 'single' as const }; @@ -67,58 +97,57 @@ export function DashboardPage() { const nextQuarter = taxResult.quarterlySchedule.find((q) => !q.isPastDue); - // Year projection fraction (day-based for work/payment/expense projections) - const now = new Date(); - const yearStart = new Date(now.getFullYear(), 0, 1).getTime(); - const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime(); - const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart); - const proj = (v: number) => (v > 0 && yearFrac > 0 && yearFrac < 1) ? v / yearFrac : null; + // Days elapsed since Jan 1 (inclusive), using noon-UTC to avoid DST drift + const jan1Noon = Date.parse(`${currentYear}-01-01T12:00:00Z`); + const todayNoon = Date.parse(`${todayISO}T12:00:00Z`); + const daysElapsed = Math.max(1, Math.floor((todayNoon - jan1Noon) / 86400000) + 1); + const daysInYear = (currentYear % 4 === 0 && (currentYear % 100 !== 0 || currentYear % 400 === 0)) ? 366 : 365; + const dailyProj = (v: number) => v / daysElapsed * daysInYear; const ytdWork = currentYearStats?.workValue ?? 0; const ytdPayments = currentYearStats?.payments ?? 0; const ytdExpenses = currentYearStats?.expenses ?? 0; const ytdNet = currentYearStats?.net ?? 0; + const dailyAvgWork = ytdWork / daysElapsed; + const dailyAvgExpenses = ytdExpenses / daysElapsed; + + const projNet = dailyProj(ytdNet); + const avgMonthlyNetVal = currentYearStats?.avgPerChild ?? 0; + const outstandingVal = data.workEntries + .filter((e) => e.date.startsWith(String(currentYear)) && e.paymentOutstanding) + .reduce((s, e) => s + workEntryValue(e), 0); + const widgets: Record = { - ytdWorkValue: { value: fmtMoneyShort(ytdWork) }, - ytdWorkProj: { value: fmtMoneyShort(proj(ytdWork)), sub: 'full year est.' }, - ytdPayments: { value: fmtMoneyShort(ytdPayments) }, - ytdPaymentsProj: { value: fmtMoneyShort(proj(ytdPayments)), sub: 'full year est.' }, - ytdExpenses: { value: fmtMoneyShort(ytdExpenses), className: 'negative' }, - ytdNet: { - value: fmtMoneyShort(ytdNet), - className: ytdNet >= 0 ? 'positive' : 'negative', - }, - ytdNetProj: { value: fmtMoneyShort(proj(ytdNet)), sub: 'full year est.' }, + // ── Work (income) ────────────────────────────────────────────────────────── + ytdWorkValue: { value: fmtMoneyShort(ytdWork), className: 'positive' }, + ytdWorkProj: { value: fmtMoneyShort(dailyProj(ytdWork)), sub: 'full year est.', className: 'positive' }, + ytdDailyAvg: { value: fmtMoney(dailyAvgWork), sub: `over ${daysElapsed} days YTD`, className: 'positive' }, + // ── Payments (income) ────────────────────────────────────────────────────── + ytdPayments: { value: fmtMoneyShort(ytdPayments), className: 'positive' }, + ytdPaymentsProj: { value: fmtMoneyShort(dailyProj(ytdPayments)), sub: 'full year est.', className: 'positive' }, + expectedPayments: { value: fmtMoneyShort(outstandingVal), sub: 'work logged, payment pending', className: 'positive' }, + // ── Expenses (cost) ──────────────────────────────────────────────────────── + ytdExpenses: { value: fmtMoneyShort(ytdExpenses), className: 'negative' }, + avgDailyExpenses: { value: fmtMoney(dailyAvgExpenses), sub: `over ${daysElapsed} days YTD`, className: 'negative' }, + // ── Net (conditional) ────────────────────────────────────────────────────── + ytdNet: { value: fmtMoneyShort(ytdNet), className: ytdNet >= 0 ? 'positive' : 'negative' }, + ytdNetProj: { value: fmtMoneyShort(projNet), sub: 'full year est.', className: projNet >= 0 ? 'positive' : 'negative' }, + avgMonthlyNet: { value: fmtMoneyShort(avgMonthlyNetVal), sub: `${currentYearStats?.childCount ?? 0} months`, className: avgMonthlyNetVal >= 0 ? 'positive' : 'negative' }, + // ── Tax (cost / neutral) ─────────────────────────────────────────────────── nextQuarterlyDue: { value: nextQuarter ? fmtMoneyShort(nextQuarter.remainingAmount) : '—', sub: nextQuarter ? `Q${nextQuarter.quarter} due ${nextQuarter.dueDate}` : 'All paid', - }, - projectedAnnualTax: { - value: fmtMoneyShort(taxResult.totalFederalTax), - sub: 'full year est.', className: 'negative', }, - ytdActualTax: { - value: fmtMoneyShort(ytdTaxResult.totalFederalTax), - sub: 'based on actual YTD', - className: 'negative', - }, - taxRemainingDue: { - value: fmtMoneyShort(taxResult.remainingDue), - sub: 'after payments made', - className: 'negative', - }, - avgMonthlyNet: { - value: fmtMoneyShort(currentYearStats?.avgPerChild ?? 0), - sub: `${currentYearStats?.childCount ?? 0} months`, - }, - avgDailyWork: { - value: fmtMoneyShort( - [...stats.days.values()].reduce((s, d) => s + d.workValue, 0) / - Math.max(1, stats.days.size), - ), - sub: `${stats.days.size} days logged`, + ytdActualTax: { value: fmtMoneyShort(ytdTaxResult.totalFederalTax), className: 'negative' }, + projectedAnnualTax: { value: fmtMoneyShort(taxResult.totalFederalTax), sub: 'projected full year', className: 'negative' }, + taxRemainingDue: { value: fmtMoneyShort(ytdTaxResult.remainingDue), sub: 'actual tax vs. payments made', className: 'negative' }, + effectiveTaxRate: { + value: ytdTaxResult.grossReceipts > 0 + ? `${(ytdTaxResult.totalFederalTax / ytdTaxResult.grossReceipts * 100).toFixed(1)}%` + : '—', + sub: 'total tax ÷ gross income', }, }; @@ -138,15 +167,33 @@ export function DashboardPage() { - {/* Stat widgets */} - - {data.dashboard.widgets.map((w) => { - const def = widgets[w]; + {/* Stat widgets — grouped horizontally */} + + {WIDGET_GROUPS.map((group) => { + const cards = group.entries.flatMap(({ actual, proj }) => { + const aActive = data.dashboard.widgets.includes(actual); + const pActive = !!proj && data.dashboard.widgets.includes(proj); + if (!aActive && !pActive) return []; + return [{ actual: aActive ? actual : proj!, proj: aActive && pActive ? proj : undefined }]; + }); + if (cards.length === 0) return null; return ( - - {WIDGET_LABELS[w]} - {def.value} - {def.sub && {def.sub}} + + {group.label} + + {cards.map(({ actual, proj }) => { + const def = widgets[actual]; + const projDef = proj ? widgets[proj] : undefined; + return ( + + {WIDGET_LABELS[actual]} + {def.value} + {projDef && proj {projDef.value}} + {def.sub && {def.sub}} + + ); + })} + ); })} @@ -159,7 +206,7 @@ export function DashboardPage() { key={c.id} config={c} onChange={(patch) => updateChart(c.id, patch)} - onRemove={data.dashboard.charts.length > 1 ? () => removeChart(c.id) : undefined} + onRemove={() => removeChart(c.id)} /> ))} @@ -173,17 +220,23 @@ export function DashboardPage() { onClose={() => setConfigOpen(false)} footer={ setConfigOpen(false)}>Done} > - - Choose which stats appear at the top: - {(Object.keys(WIDGET_LABELS) as DashboardWidget[]).map((w) => ( - - toggleWidget(w)} - /> - {WIDGET_LABELS[w]} - + + {WIDGET_GROUPS.map((group) => ( + + {group.label} + {group.entries.flatMap(({ actual, proj }) => + [actual, ...(proj ? [proj] : [])].map((w) => ( + + toggleWidget(w)} + /> + {WIDGET_LABELS[w]} + + )) + )} + ))} diff --git a/src/pages/LedgerPage.tsx b/src/pages/LedgerPage.tsx index 805c7a1..ed2773b 100644 --- a/src/pages/LedgerPage.tsx +++ b/src/pages/LedgerPage.tsx @@ -14,7 +14,7 @@ import { ChartSidebar } from '@/components/charts/ChartSidebar'; import { ResizableSplit } from '@/components/layout/ResizableSplit'; import { Modal, ConfirmDialog } from '@/components/common/Modal'; import { buildHierarchyForRange, buildHierarchy, aggregate } from '@/lib/stats/aggregate'; -import { fmtMoney, todayISO } from '@/lib/format'; +import { fmtMoney, todayISO, nowInTZ } from '@/lib/format'; function yearStart() { return `${new Date().getFullYear()}-01-01`; } @@ -148,6 +148,10 @@ function WorkTab({ startDate, setStartDate }: { startDate: string; setStartDate: onView={(n) => setEditing(n.entry as WorkEntry)} onEdit={(n) => setEditing(n.entry as WorkEntry)} onDelete={(n) => setDeleting(n.entry!.id)} + onToggleOutstanding={(n) => { + const e = n.entry as WorkEntry; + updateWorkEntry(e.id, { paymentOutstanding: !e.paymentOutstanding }); + }} /> @@ -606,13 +610,19 @@ const LEDGER_TILE_LABELS: Record = { avgMonth: 'Avg / month', yearProj: 'Year projected', thisMonth: 'This month', - avgDay: 'Avg / day', + avgDay: 'YTD daily avg', monthProj: 'Month projected', today: 'Today', }; const ALL_LEDGER_TILES: LedgerTile[] = ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today']; +const METRIC_COLOR: Record<'workValue' | 'payments' | 'expenses', string> = { + workValue: 'positive', + payments: 'positive', + expenses: 'negative', +}; + function PeriodSummaryRow({ stats, metric, @@ -626,10 +636,8 @@ function PeriodSummaryRow({ tiles: LedgerTile[]; onConfigure: () => void; }) { - const now = new Date(); - const currentYear = String(now.getFullYear()); - const currentMonth = now.toISOString().slice(0, 7); - const today = now.toISOString().slice(0, 10); + const { year: nowYear, monthIdx, day: nowDay, isoDate: today, isoMonth: currentMonth } = nowInTZ(); + const currentYear = String(nowYear); const y = stats.years.find((x) => x.label === currentYear); const m = stats.months.get(currentMonth); @@ -638,19 +646,23 @@ function PeriodSummaryRow({ const yValue = y?.[metric] ?? 0; const mValue = m?.[metric] ?? 0; - const monthsElapsed = now.getMonth() + 1; - const dayOfMonth = now.getDate(); - const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); - const yearStart = new Date(now.getFullYear(), 0, 1).getTime(); - const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime(); - const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart); + const monthsElapsed = monthIdx + 1; + const dayOfMonth = nowDay; + const daysInMonth = new Date(nowYear, monthIdx + 1, 0).getDate(); + // Days elapsed since Jan 1 inclusive, using noon-UTC to avoid DST drift + const jan1Noon = Date.parse(`${nowYear}-01-01T12:00:00Z`); + const todayNoon = Date.parse(`${today}T12:00:00Z`); + const daysElapsed = Math.max(1, Math.floor((todayNoon - jan1Noon) / 86400000) + 1); + const daysInYear = (nowYear % 4 === 0 && (nowYear % 100 !== 0 || nowYear % 400 === 0)) ? 366 : 365; + + const ytdDailyAvg = yValue / daysElapsed; const tileValues: Record = { ytd: yValue, avgMonth: yValue > 0 ? yValue / monthsElapsed : null, - yearProj: yValue > 0 && yearFrac > 0 && yearFrac < 1 ? yValue / yearFrac : null, + yearProj: yValue > 0 ? ytdDailyAvg * daysInYear : null, thisMonth: mValue, - avgDay: mValue > 0 ? mValue / dayOfMonth : null, + avgDay: yValue > 0 ? ytdDailyAvg : null, monthProj: mValue > 0 && dayOfMonth > 0 && dayOfMonth < daysInMonth ? (mValue / dayOfMonth) * daysInMonth : null, @@ -671,16 +683,16 @@ function PeriodSummaryRow({ {tiles.map((t) => { const value = tileValues[t]; if (value == null) return null; - return ; + return ; })} ); } -function StatTile({ label, value }: { label: string; value: number }) { +function StatTile({ label, value, className = '' }: { label: string; value: number; className?: string }) { return ( - + {label} {fmtMoney(value)} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 852a0f0..6d43f39 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -2,19 +2,33 @@ * Settings — themes, default rate, and manual file import/export. */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useAppStore } from '@/store/appStore'; import { THEME_NAMES } from '@/themes/ThemeProvider'; import type { ThemeName, ThemeMode } from '@/types'; import { isT99Encrypted } from '@/lib/storage/vault'; +const ALL_TIMEZONES: string[] = (() => { + try { return Intl.supportedValuesOf('timeZone'); } catch { return []; } +})(); +const BROWSER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone; + export function SettingsPage() { const settings = useAppStore((s) => s.data.settings); const setTheme = useAppStore((s) => s.setTheme); const setDefaultRate = useAppStore((s) => s.setDefaultRate); + const setTimezone = useAppStore((s) => s.setTimezone); const exportFile = useAppStore((s) => s.exportFile); const importFile = useAppStore((s) => s.importFile); + const [tzSearch, setTzSearch] = useState(''); + const filteredTZ = useMemo( + () => tzSearch.trim() + ? ALL_TIMEZONES.filter((tz) => tz.toLowerCase().includes(tzSearch.toLowerCase())) + : ALL_TIMEZONES, + [tzSearch], + ); + const [exportPwd, setExportPwd] = useState(''); const [importFileObj, setImportFileObj] = useState(null); @@ -116,6 +130,52 @@ export function SettingsPage() { + {/* ─── Timezone ──────────────────────────────────────────────────── */} + + Timezone + + Used for date calculations and "today" boundaries. + Browser default: {BROWSER_TZ} + + + + setTzSearch(e.target.value)} + /> + {settings.timezone && ( + { setTimezone(undefined); setTzSearch(''); }} + title="Reset to browser default" + > + Reset + + )} + + setTimezone(e.target.value || undefined)} + style={{ width: '100%', height: 'auto' }} + > + — Use browser default ({BROWSER_TZ}) + {filteredTZ.map((tz) => ( + {tz} + ))} + + {settings.timezone && ( + + Active: {settings.timezone} + + )} + + + {/* ─── Import / Export ───────────────────────────────────────────── */} Backup & Restore diff --git a/src/pages/TaxPage.tsx b/src/pages/TaxPage.tsx index 548f37f..019065b 100644 --- a/src/pages/TaxPage.tsx +++ b/src/pages/TaxPage.tsx @@ -8,7 +8,6 @@ import { useAppStore } from '@/store/appStore'; import { calculateTax } from '@/lib/tax/calculate'; import { availableTaxYears } from '@/lib/tax/brackets'; import type { FilingStatus, TaxInputs, TaxTile } from '@/types'; -import { ChartSidebar } from '@/components/charts/ChartSidebar'; import { Modal } from '@/components/common/Modal'; import { fmtMoney, fmtMoneyShort, todayISO } from '@/lib/format'; @@ -113,10 +112,9 @@ export function TaxPage() { setNewPaymentNote(''); }; - return ( - - - {/* ─── Year / filing status / projection toggle ───────────────── */} + const left = ( + + {/* ─── Year / filing status / projection toggle ───────────────── */} @@ -205,9 +203,9 @@ export function TaxPage() { {TAX_TILE_LABELS[t]} {fmtMoneyShort(value)} {projValue !== undefined && ( - projected: {fmtMoneyShort(projValue)} + proj {fmtMoneyShort(projValue)} )} - {sub && !projValue && {sub}} + {sub && {sub}} ); })} @@ -430,13 +428,10 @@ export function TaxPage() { - - - - - ); + + return left; } function BreakdownRow({ label, value, proj, bold, highlight }: { diff --git a/src/store/appStore.ts b/src/store/appStore.ts index 263c1d2..132276e 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -26,7 +26,7 @@ import type { } from '@/types'; import { uid } from '@/lib/id'; import { Vault, deserializeT99 } from '@/lib/storage/vault'; -import { todayISO } from '@/lib/format'; +import { todayISO, setActiveTZ } from '@/lib/format'; // ─── Defaults ──────────────────────────────────────────────────────────────── @@ -60,15 +60,19 @@ const defaultDashboard = (): DashboardConfig => ({ widgets: [ 'ytdWorkValue', 'ytdWorkProj', + 'ytdDailyAvg', 'ytdPayments', 'ytdPaymentsProj', + 'expectedPayments', 'ytdExpenses', + 'avgDailyExpenses', 'ytdNet', 'ytdNetProj', 'nextQuarterlyDue', - 'projectedAnnualTax', 'ytdActualTax', + 'projectedAnnualTax', 'taxRemainingDue', + 'effectiveTaxRate', ], workCharts: [defaultLedgerChart('workValue', 'Work Value by Day', 10)], paymentsCharts: [defaultLedgerChart('payments', 'Payments by Day')], @@ -205,6 +209,7 @@ interface AppStore { // ─── Settings ───────────────────────────────────────────────────────────── setTheme: (theme: ThemeName, mode: ThemeMode) => void; setDefaultRate: (rate: number) => void; + setTimezone: (tz: string | undefined) => void; // ─── File import/export ─────────────────────────────────────────────────── /** Export a backup. Without a password the file is unencrypted plaintext. */ @@ -281,6 +286,14 @@ export const useAppStore = create((set, get) => { ['ytdWorkValue', 'ytdPaymentsProj', 'ytdNetProj', 'ytdActualTax', 'taxRemainingDue'].includes(w), ); if (!hasNewWidgets) data.dashboard.widgets = dd.widgets; + // Migrate: rename avgDailyWork → ytdDailyAvg, add avgDailyExpenses + data.dashboard.widgets = data.dashboard.widgets.map( + (w) => (w as string) === 'avgDailyWork' ? 'ytdDailyAvg' : w + ) as typeof data.dashboard.widgets; + if (!data.dashboard.widgets.includes('ytdDailyAvg')) data.dashboard.widgets.push('ytdDailyAvg'); + if (!data.dashboard.widgets.includes('avgDailyExpenses')) data.dashboard.widgets.push('avgDailyExpenses'); + if (!data.dashboard.widgets.includes('effectiveTaxRate')) data.dashboard.widgets.push('effectiveTaxRate'); + setActiveTZ(data.settings.timezone); set({ vault, data, ready: true }); // Apply any pending recurring expense occurrences setTimeout(() => get().applyRecurringExpenses(), 0); @@ -506,6 +519,11 @@ export const useAppStore = create((set, get) => { mutate((d) => { d.settings.defaultRate = rate; }); }, + setTimezone: (tz) => { + setActiveTZ(tz); + mutate((d) => { d.settings.timezone = tz; }); + }, + // ─── File import/export ───────────────────────────────────────────────── exportFile: async (password) => { diff --git a/src/themes/global.css b/src/themes/global.css index ec7660e..e13d728 100644 --- a/src/themes/global.css +++ b/src/themes/global.css @@ -444,10 +444,33 @@ a:hover { text-decoration: underline; } /* ─── Stats widgets ───────────────────────────────────────────────────────── */ +/* Horizontal group grid — always 4 columns, 2x2, or 1 column; never 3+1 */ +.dashboard-groups { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + align-items: start; +} +@media (min-width: 1200px) { + .dashboard-groups { grid-template-columns: repeat(4, 1fr); } +} +@media (max-width: 540px) { + .dashboard-groups { grid-template-columns: 1fr; } +} + +.dashboard-group-label { + font-size: 11px; + font-weight: 600; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 8px; +} + .stat-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 16px; + grid-template-columns: repeat(2, 1fr); + gap: 10px; } .stat-card { @@ -455,9 +478,12 @@ a:hover { text-decoration: underline; } border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; + min-height: 88px; + box-sizing: border-box; } .stat-card .stat-label { font-size: 11px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.5px; } .stat-card .stat-value { font-size: 24px; font-weight: 700; font-family: var(--font-mono); margin-top: 4px; } +.stat-card .stat-proj { font-size: 13px; font-family: var(--font-mono); color: var(--fg-muted); margin-top: 3px; } .stat-card .stat-sub { font-size: 12px; color: var(--fg-muted); margin-top: 2px; } .stat-card.positive .stat-value { color: var(--success); } .stat-card.negative .stat-value { color: var(--danger); } @@ -655,6 +681,10 @@ select.ss-input { cursor: pointer; } .ss-value { padding: 7px 10px; } .ss-actions { padding: 0 4px; text-align: center; white-space: nowrap; } +/* Outstanding payment toggle */ +.ss-outstanding-btn { font-size: 14px; padding: 2px 6px; opacity: 0.4; } +.ss-outstanding-btn[data-outstanding="true"] { opacity: 1; color: var(--warning, #f59e0b); } + /* Existing data rows */ .ss-row:hover { background: var(--bg-elev-2); } .ss-row:hover .ss-cell:hover { background: var(--accent-muted); } @@ -829,7 +859,7 @@ select.ss-input { cursor: pointer; } .app-header { position: relative; } .hamburger-btn { display: inline-flex; } .header-status { margin-left: auto; } - .kofi-header-btn { display: none; } + .kofi-header-btn span.kofi-label { display: none; } .app-nav { display: none; flex-direction: column; @@ -851,7 +881,6 @@ select.ss-input { cursor: pointer; } /* Small phones (≤640px) — extra compact spacing */ @media (max-width: 640px) { .app-body { padding: 10px; } - .stat-grid { grid-template-columns: repeat(2, 1fr); } .stat-card .stat-value { font-size: 20px; } .timer-display { font-size: 36px; } .timer-earned { font-size: 20px; } diff --git a/src/types/index.ts b/src/types/index.ts index c615617..ac21898 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,6 +27,8 @@ export interface WorkEntry { rate?: number; /** Optional client/project tag */ client?: string; + /** Payment has not yet been received for this work item */ + paymentOutstanding?: boolean; createdAt: EpochMs; updatedAt: EpochMs; } @@ -292,17 +294,20 @@ export interface DashboardConfig { export type DashboardWidget = | 'ytdWorkValue' | 'ytdWorkProj' + | 'ytdDailyAvg' | 'ytdPayments' | 'ytdPaymentsProj' + | 'expectedPayments' | 'ytdExpenses' + | 'avgDailyExpenses' | 'ytdNet' | 'ytdNetProj' - | 'nextQuarterlyDue' - | 'projectedAnnualTax' - | 'ytdActualTax' - | 'taxRemainingDue' | 'avgMonthlyNet' - | 'avgDailyWork'; + | 'nextQuarterlyDue' + | 'ytdActualTax' + | 'projectedAnnualTax' + | 'taxRemainingDue' + | 'effectiveTaxRate'; export type LedgerTile = | 'ytd' @@ -355,6 +360,8 @@ export interface Settings { mode: ThemeMode; /** Default hourly rate, pre-fills timer & new work entries */ defaultRate: number; + /** IANA timezone string e.g. "America/Chicago". Undefined = use browser default. */ + timezone?: string; } /**
Choose which stats appear at the top:
{group.label}
+ Used for date calculations and "today" boundaries. + Browser default: {BROWSER_TZ} +
+ Active: {settings.timezone} +