updated some UI elements and calculations

This commit is contained in:
Deven Thiel 2026-03-05 22:35:34 -05:00
parent 409d2560bc
commit 55d7c736c9
13 changed files with 378 additions and 121 deletions

View file

@ -45,7 +45,7 @@ export function App() {
{saveError && <span className="text-danger" title={saveError}> Save failed</span>} {saveError && <span className="text-danger" title={saveError}> Save failed</span>}
</div> </div>
<button className="kofi-header-btn" onClick={() => setKofiOpen(true)}> <button className="kofi-header-btn" onClick={() => setKofiOpen(true)}>
Donate <span className="kofi-label">Donate</span>
</button> </button>
<button <button
className="hamburger-btn" className="hamburger-btn"

View file

@ -36,7 +36,7 @@ export function ChartSidebar({ tab, defaultRangeStart, defaultRangeEnd }: {
key={c.id} key={c.id}
config={c} config={c}
onChange={(patch) => updateLedgerChart(tab, c.id, patch)} onChange={(patch) => updateLedgerChart(tab, c.id, patch)}
onRemove={(charts?.length ?? 0) > 1 ? () => removeLedgerChart(tab, c.id) : undefined} onRemove={() => removeLedgerChart(tab, c.id)}
defaultRangeStart={defaultRangeStart} defaultRangeStart={defaultRangeStart}
defaultRangeEnd={defaultRangeEnd} defaultRangeEnd={defaultRangeEnd}
/> />
@ -55,7 +55,7 @@ export function ChartSidebar({ tab, defaultRangeStart, defaultRangeEnd }: {
key={c.id} key={c.id}
config={c} config={c}
onChange={(patch) => updateChart(c.id, patch)} onChange={(patch) => updateChart(c.id, patch)}
onRemove={dashCharts.length > 1 ? () => removeChart(c.id) : undefined} onRemove={() => removeChart(c.id)}
/> />
))} ))}
<button className="btn" onClick={() => addChart()}> <button className="btn" onClick={() => addChart()}>

View file

@ -94,6 +94,9 @@ export function WorkEntryForm({
const [hours, setHours] = useState(initial?.hours?.toString() ?? ''); const [hours, setHours] = useState(initial?.hours?.toString() ?? '');
const [rate, setRate] = useState(initial?.rate?.toString() ?? String(defaultRate)); const [rate, setRate] = useState(initial?.rate?.toString() ?? String(defaultRate));
const [client, setClient] = useState(initial?.client ?? ''); const [client, setClient] = useState(initial?.client ?? '');
const [paymentOutstanding, setPaymentOutstanding] = useState(
initial?.paymentOutstanding ?? true,
);
const computedValue = const computedValue =
mode === 'amount' mode === 'amount'
@ -102,7 +105,7 @@ export function WorkEntryForm({
const submit = (e: React.FormEvent) => { const submit = (e: React.FormEvent) => {
e.preventDefault(); 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; if (mode === 'amount') base.amount = parseFloat(amount) || 0;
else { else {
base.hours = parseFloat(hours) || 0; base.hours = parseFloat(hours) || 0;
@ -158,6 +161,15 @@ export function WorkEntryForm({
<div className="text-muted text-sm">Value: <span className="mono">${computedValue.toFixed(2)}</span></div> <div className="text-muted text-sm">Value: <span className="mono">${computedValue.toFixed(2)}</span></div>
<label className="checkbox">
<input
type="checkbox"
checked={paymentOutstanding}
onChange={(e) => setPaymentOutstanding(e.target.checked)}
/>
Payment outstanding (awaiting receipt)
</label>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn" onClick={onCancel}>Cancel</button> <button type="button" className="btn" onClick={onCancel}>Cancel</button>
<button type="submit" className="btn btn-primary">Save</button> <button type="submit" className="btn btn-primary">Save</button>

View file

@ -6,7 +6,7 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import type { HierNode } from '@/types'; import type { HierNode, WorkEntry } from '@/types';
import { fmtMoney } from '@/lib/format'; import { fmtMoney } from '@/lib/format';
export type ExpandLevel = 'year' | 'month' | 'day' | 'item'; export type ExpandLevel = 'year' | 'month' | 'day' | 'item';
@ -20,6 +20,8 @@ interface Props {
onDelete?: (node: HierNode) => void; onDelete?: (node: HierNode) => void;
/** Called when user clicks "+" on a day row; receives the ISO date string */ /** Called when user clicks "+" on a day row; receives the ISO date string */
onAddForDay?: (date: string) => void; 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") */ /** Label for the value column (e.g. "Earned", "Paid", "Spent") */
valueLabel: string; valueLabel: string;
} }
@ -55,7 +57,7 @@ function filterEmpty(nodes: HierNode[]): HierNode[] {
.map((n) => ({ ...n, children: filterEmpty(n.children) })); .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<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [hideEmpty, setHideEmpty] = useState(false); const [hideEmpty, setHideEmpty] = useState(false);
const [selectedLevel, setSelectedLevel] = useState<ExpandLevel>('year'); const [selectedLevel, setSelectedLevel] = useState<ExpandLevel>('year');
@ -168,7 +170,7 @@ export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay,
<tr> <tr>
<th>Period / Item</th> <th>Period / Item</th>
<th className="num">{valueLabel}</th> <th className="num">{valueLabel}</th>
<th style={{ width: 80 }}></th> <th style={{ width: onToggleOutstanding ? 110 : 80 }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -225,6 +227,19 @@ export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay,
+ +
</button> </button>
)} )}
{isItem && onToggleOutstanding && (() => {
const outstanding = !!(node.entry as WorkEntry)?.paymentOutstanding;
return (
<button
className="btn btn-sm btn-ghost ss-outstanding-btn"
data-outstanding={outstanding}
title={outstanding ? 'Payment outstanding — click to mark received' : 'Click to mark payment outstanding'}
onClick={() => onToggleOutstanding(node)}
>
{outstanding ? '⏳' : '·'}
</button>
);
})()}
{isItem && onEdit && ( {isItem && onEdit && (
<button <button
className="btn btn-sm btn-ghost" className="btn btn-sm btn-ghost"

View file

@ -146,10 +146,11 @@ function RangeBar({
type WorkDraft = { type WorkDraft = {
date: string; description: string; client: string; date: string; description: string; client: string;
hours: string; rate: string; amount: string; hours: string; rate: string; amount: string;
paymentOutstanding: boolean;
}; };
function blankWork(today: string, defaultRate: number): WorkDraft { function blankWork(today: string, defaultRate: number): WorkDraft {
return { date: today, description: '', client: '', hours: '', rate: String(defaultRate), amount: '' }; return { date: today, description: '', client: '', hours: '', rate: String(defaultRate), amount: '', paymentOutstanding: true };
} }
export function WorkSheet({ export function WorkSheet({
@ -192,6 +193,7 @@ export function WorkSheet({
hours, hours,
rate: hours != null ? rate : undefined, rate: hours != null ? rate : undefined,
amount, amount,
paymentOutstanding: draft.paymentOutstanding,
}); });
setDraft(blankWork(today, defaultRate)); setDraft(blankWork(today, defaultRate));
}; };
@ -211,6 +213,7 @@ export function WorkSheet({
<th className="ss-right">$/hr</th> <th className="ss-right">$/hr</th>
<th className="ss-right">Flat $</th> <th className="ss-right">Flat $</th>
<th className="ss-right">Value</th> <th className="ss-right">Value</th>
<th style={{ width: 56, textAlign: 'center' }} title="Payment outstanding">Unpaid</th>
<th style={{ width: 40 }}></th> <th style={{ width: 40 }}></th>
</tr> </tr>
</thead> </thead>
@ -245,6 +248,16 @@ export function WorkSheet({
<td className="ss-right mono ss-value text-muted"> <td className="ss-right mono ss-value text-muted">
{draftValue > 0 ? fmtMoney(draftValue) : '—'} {draftValue > 0 ? fmtMoney(draftValue) : '—'}
</td> </td>
<td style={{ textAlign: 'center', padding: '0 4px' }}>
<button
className="btn btn-sm btn-ghost ss-outstanding-btn"
data-outstanding={draft.paymentOutstanding}
title={draft.paymentOutstanding ? 'Marked unpaid — click to clear' : 'Click to mark unpaid'}
onClick={() => setDraft({ ...draft, paymentOutstanding: !draft.paymentOutstanding })}
>
{draft.paymentOutstanding ? '⏳' : '·'}
</button>
</td>
<td className="ss-actions"> <td className="ss-actions">
<button className="btn btn-sm btn-primary" onClick={addRow} <button className="btn btn-sm btn-primary" onClick={addRow}
disabled={!draft.description.trim()} title="Add row"> disabled={!draft.description.trim()} title="Add row">
@ -255,7 +268,7 @@ export function WorkSheet({
{filtered.length === 0 && ( {filtered.length === 0 && (
<tr> <tr>
<td colSpan={8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}> <td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
No entries in this period No entries in this period
</td> </td>
</tr> </tr>
@ -288,6 +301,16 @@ export function WorkSheet({
}); });
}} /> }} />
<td className="ss-right mono ss-value">{fmtMoney(val)}</td> <td className="ss-right mono ss-value">{fmtMoney(val)}</td>
<td style={{ textAlign: 'center', padding: '0 4px' }}>
<button
className="btn btn-sm btn-ghost ss-outstanding-btn"
data-outstanding={!!e.paymentOutstanding}
title={e.paymentOutstanding ? 'Payment outstanding — click to mark received' : 'Click to mark payment outstanding'}
onClick={() => onUpdate(e.id, { paymentOutstanding: !e.paymentOutstanding })}
>
{e.paymentOutstanding ? '⏳' : '·'}
</button>
</td>
<td className="ss-actions"> <td className="ss-actions">
<button className="btn btn-sm btn-ghost text-danger" <button className="btn btn-sm btn-ghost text-danger"
onClick={() => setDeleting(e.id)} title="Delete"> onClick={() => setDeleting(e.id)} title="Delete">
@ -302,7 +325,7 @@ export function WorkSheet({
<tr className="ss-total"> <tr className="ss-total">
<td colSpan={6}>Total</td> <td colSpan={6}>Total</td>
<td className="ss-right mono">{fmtMoney(total)}</td> <td className="ss-right mono">{fmtMoney(total)}</td>
<td></td> <td colSpan={2}></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>

View file

@ -55,6 +55,39 @@ export function fmtPct(n: number): string {
return (n * 100).toFixed(1) + '%'; return (n * 100).toFixed(1) + '%';
} }
export function todayISO(): string { // ─── Timezone ────────────────────────────────────────────────────────────────
return new Date().toISOString().slice(0, 10);
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;
} }

View file

@ -6,27 +6,57 @@ import { useMemo, useState } from 'react';
import { useAppStore } from '@/store/appStore'; import { useAppStore } from '@/store/appStore';
import { aggregate } from '@/lib/stats/aggregate'; import { aggregate } from '@/lib/stats/aggregate';
import { calculateTax } from '@/lib/tax/calculate'; 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 { ChartPanel } from '@/components/charts/ChartPanel';
import { Modal } from '@/components/common/Modal'; import { Modal } from '@/components/common/Modal';
import { workEntryValue } from '@/types';
import type { DashboardWidget } from '@/types'; import type { DashboardWidget } from '@/types';
const WIDGET_LABELS: Record<DashboardWidget, string> = { const WIDGET_LABELS: Record<DashboardWidget, string> = {
ytdWorkValue: 'YTD Work Value', ytdWorkValue: 'YTD Work Value',
ytdWorkProj: 'Work Value Projected', ytdWorkProj: 'Work Value Projected',
ytdPayments: 'YTD Payments', ytdDailyAvg: 'YTD Daily Average',
ytdPayments: 'YTD Payments Received',
ytdPaymentsProj: 'Payments Projected', ytdPaymentsProj: 'Payments Projected',
expectedPayments: 'Expected Payments',
ytdExpenses: 'YTD Expenses', ytdExpenses: 'YTD Expenses',
avgDailyExpenses: 'YTD Daily Average',
ytdNet: 'YTD Net Income', ytdNet: 'YTD Net Income',
ytdNetProj: 'Net Income Projected', ytdNetProj: 'Net Income Projected',
nextQuarterlyDue: 'Next Quarterly Due',
projectedAnnualTax: 'Projected Annual Tax',
ytdActualTax: 'YTD Actual Tax',
taxRemainingDue: 'Tax Remaining Due',
avgMonthlyNet: 'Avg Monthly Net', 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() { export function DashboardPage() {
const data = useAppStore((s) => s.data); const data = useAppStore((s) => s.data);
const addChart = useAppStore((s) => s.addChart); const addChart = useAppStore((s) => s.addChart);
@ -41,7 +71,7 @@ export function DashboardPage() {
[data.workEntries, data.payments, data.expenses], [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 currentYearStats = stats.years.find((y) => y.label === String(currentYear));
const taxInputs = data.taxInputs[currentYear] ?? { taxYear: currentYear, filingStatus: 'single' as const }; 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); const nextQuarter = taxResult.quarterlySchedule.find((q) => !q.isPastDue);
// Year projection fraction (day-based for work/payment/expense projections) // Days elapsed since Jan 1 (inclusive), using noon-UTC to avoid DST drift
const now = new Date(); const jan1Noon = Date.parse(`${currentYear}-01-01T12:00:00Z`);
const yearStart = new Date(now.getFullYear(), 0, 1).getTime(); const todayNoon = Date.parse(`${todayISO}T12:00:00Z`);
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime(); const daysElapsed = Math.max(1, Math.floor((todayNoon - jan1Noon) / 86400000) + 1);
const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart); const daysInYear = (currentYear % 4 === 0 && (currentYear % 100 !== 0 || currentYear % 400 === 0)) ? 366 : 365;
const proj = (v: number) => (v > 0 && yearFrac > 0 && yearFrac < 1) ? v / yearFrac : null; const dailyProj = (v: number) => v / daysElapsed * daysInYear;
const ytdWork = currentYearStats?.workValue ?? 0; const ytdWork = currentYearStats?.workValue ?? 0;
const ytdPayments = currentYearStats?.payments ?? 0; const ytdPayments = currentYearStats?.payments ?? 0;
const ytdExpenses = currentYearStats?.expenses ?? 0; const ytdExpenses = currentYearStats?.expenses ?? 0;
const ytdNet = currentYearStats?.net ?? 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<DashboardWidget, { value: string; sub?: string; className?: string }> = { const widgets: Record<DashboardWidget, { value: string; sub?: string; className?: string }> = {
ytdWorkValue: { value: fmtMoneyShort(ytdWork) }, // ── Work (income) ──────────────────────────────────────────────────────────
ytdWorkProj: { value: fmtMoneyShort(proj(ytdWork)), sub: 'full year est.' }, ytdWorkValue: { value: fmtMoneyShort(ytdWork), className: 'positive' },
ytdPayments: { value: fmtMoneyShort(ytdPayments) }, ytdWorkProj: { value: fmtMoneyShort(dailyProj(ytdWork)), sub: 'full year est.', className: 'positive' },
ytdPaymentsProj: { value: fmtMoneyShort(proj(ytdPayments)), sub: 'full year est.' }, ytdDailyAvg: { value: fmtMoney(dailyAvgWork), sub: `over ${daysElapsed} days YTD`, className: 'positive' },
ytdExpenses: { value: fmtMoneyShort(ytdExpenses), className: 'negative' }, // ── Payments (income) ──────────────────────────────────────────────────────
ytdNet: { ytdPayments: { value: fmtMoneyShort(ytdPayments), className: 'positive' },
value: fmtMoneyShort(ytdNet), ytdPaymentsProj: { value: fmtMoneyShort(dailyProj(ytdPayments)), sub: 'full year est.', className: 'positive' },
className: ytdNet >= 0 ? 'positive' : 'negative', expectedPayments: { value: fmtMoneyShort(outstandingVal), sub: 'work logged, payment pending', className: 'positive' },
}, // ── Expenses (cost) ────────────────────────────────────────────────────────
ytdNetProj: { value: fmtMoneyShort(proj(ytdNet)), sub: 'full year est.' }, 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: { nextQuarterlyDue: {
value: nextQuarter ? fmtMoneyShort(nextQuarter.remainingAmount) : '—', value: nextQuarter ? fmtMoneyShort(nextQuarter.remainingAmount) : '—',
sub: nextQuarter ? `Q${nextQuarter.quarter} due ${nextQuarter.dueDate}` : 'All paid', sub: nextQuarter ? `Q${nextQuarter.quarter} due ${nextQuarter.dueDate}` : 'All paid',
},
projectedAnnualTax: {
value: fmtMoneyShort(taxResult.totalFederalTax),
sub: 'full year est.',
className: 'negative', className: 'negative',
}, },
ytdActualTax: { ytdActualTax: { value: fmtMoneyShort(ytdTaxResult.totalFederalTax), className: 'negative' },
value: fmtMoneyShort(ytdTaxResult.totalFederalTax), projectedAnnualTax: { value: fmtMoneyShort(taxResult.totalFederalTax), sub: 'projected full year', className: 'negative' },
sub: 'based on actual YTD', taxRemainingDue: { value: fmtMoneyShort(ytdTaxResult.remainingDue), sub: 'actual tax vs. payments made', className: 'negative' },
className: 'negative', effectiveTaxRate: {
}, value: ytdTaxResult.grossReceipts > 0
taxRemainingDue: { ? `${(ytdTaxResult.totalFederalTax / ytdTaxResult.grossReceipts * 100).toFixed(1)}%`
value: fmtMoneyShort(taxResult.remainingDue), : '—',
sub: 'after payments made', sub: 'total tax ÷ gross income',
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`,
}, },
}; };
@ -138,15 +167,33 @@ export function DashboardPage() {
</button> </button>
</div> </div>
{/* Stat widgets */} {/* Stat widgets — grouped horizontally */}
<div className="stat-grid"> <div className="dashboard-groups">
{data.dashboard.widgets.map((w) => { {WIDGET_GROUPS.map((group) => {
const def = widgets[w]; 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 ( return (
<div key={w} className={`stat-card ${def.className ?? ''}`}> <div key={group.label} className="dashboard-group">
<div className="stat-label">{WIDGET_LABELS[w]}</div> <div className="dashboard-group-label">{group.label}</div>
<div className="stat-value">{def.value}</div> <div className="stat-grid">
{def.sub && <div className="stat-sub">{def.sub}</div>} {cards.map(({ actual, proj }) => {
const def = widgets[actual];
const projDef = proj ? widgets[proj] : undefined;
return (
<div key={actual} className={`stat-card ${def.className ?? ''}`}>
<div className="stat-label">{WIDGET_LABELS[actual]}</div>
<div className="stat-value">{def.value}</div>
{projDef && <div className="stat-proj">proj {projDef.value}</div>}
{def.sub && <div className="stat-sub">{def.sub}</div>}
</div>
);
})}
</div>
</div> </div>
); );
})} })}
@ -159,7 +206,7 @@ export function DashboardPage() {
key={c.id} key={c.id}
config={c} config={c}
onChange={(patch) => updateChart(c.id, patch)} onChange={(patch) => updateChart(c.id, patch)}
onRemove={data.dashboard.charts.length > 1 ? () => removeChart(c.id) : undefined} onRemove={() => removeChart(c.id)}
/> />
))} ))}
</div> </div>
@ -173,17 +220,23 @@ export function DashboardPage() {
onClose={() => setConfigOpen(false)} onClose={() => setConfigOpen(false)}
footer={<button className="btn btn-primary" onClick={() => setConfigOpen(false)}>Done</button>} footer={<button className="btn btn-primary" onClick={() => setConfigOpen(false)}>Done</button>}
> >
<div className="flex-col gap-2"> <div className="flex-col gap-4">
<p className="text-sm text-muted">Choose which stats appear at the top:</p> {WIDGET_GROUPS.map((group) => (
{(Object.keys(WIDGET_LABELS) as DashboardWidget[]).map((w) => ( <div key={group.label} className="flex-col gap-2">
<label key={w} className="checkbox"> <p className="text-sm" style={{ fontWeight: 600 }}>{group.label}</p>
<input {group.entries.flatMap(({ actual, proj }) =>
type="checkbox" [actual, ...(proj ? [proj] : [])].map((w) => (
checked={data.dashboard.widgets.includes(w)} <label key={w} className="checkbox">
onChange={() => toggleWidget(w)} <input
/> type="checkbox"
{WIDGET_LABELS[w]} checked={data.dashboard.widgets.includes(w)}
</label> onChange={() => toggleWidget(w)}
/>
{WIDGET_LABELS[w]}
</label>
))
)}
</div>
))} ))}
</div> </div>
</Modal> </Modal>

View file

@ -14,7 +14,7 @@ import { ChartSidebar } from '@/components/charts/ChartSidebar';
import { ResizableSplit } from '@/components/layout/ResizableSplit'; import { ResizableSplit } from '@/components/layout/ResizableSplit';
import { Modal, ConfirmDialog } from '@/components/common/Modal'; import { Modal, ConfirmDialog } from '@/components/common/Modal';
import { buildHierarchyForRange, buildHierarchy, aggregate } from '@/lib/stats/aggregate'; 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`; } 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)} onView={(n) => setEditing(n.entry as WorkEntry)}
onEdit={(n) => setEditing(n.entry as WorkEntry)} onEdit={(n) => setEditing(n.entry as WorkEntry)}
onDelete={(n) => setDeleting(n.entry!.id)} onDelete={(n) => setDeleting(n.entry!.id)}
onToggleOutstanding={(n) => {
const e = n.entry as WorkEntry;
updateWorkEntry(e.id, { paymentOutstanding: !e.paymentOutstanding });
}}
/> />
</div> </div>
@ -606,13 +610,19 @@ const LEDGER_TILE_LABELS: Record<LedgerTile, string> = {
avgMonth: 'Avg / month', avgMonth: 'Avg / month',
yearProj: 'Year projected', yearProj: 'Year projected',
thisMonth: 'This month', thisMonth: 'This month',
avgDay: 'Avg / day', avgDay: 'YTD daily avg',
monthProj: 'Month projected', monthProj: 'Month projected',
today: 'Today', today: 'Today',
}; };
const ALL_LEDGER_TILES: LedgerTile[] = ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', '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({ function PeriodSummaryRow({
stats, stats,
metric, metric,
@ -626,10 +636,8 @@ function PeriodSummaryRow({
tiles: LedgerTile[]; tiles: LedgerTile[];
onConfigure: () => void; onConfigure: () => void;
}) { }) {
const now = new Date(); const { year: nowYear, monthIdx, day: nowDay, isoDate: today, isoMonth: currentMonth } = nowInTZ();
const currentYear = String(now.getFullYear()); const currentYear = String(nowYear);
const currentMonth = now.toISOString().slice(0, 7);
const today = now.toISOString().slice(0, 10);
const y = stats.years.find((x) => x.label === currentYear); const y = stats.years.find((x) => x.label === currentYear);
const m = stats.months.get(currentMonth); const m = stats.months.get(currentMonth);
@ -638,19 +646,23 @@ function PeriodSummaryRow({
const yValue = y?.[metric] ?? 0; const yValue = y?.[metric] ?? 0;
const mValue = m?.[metric] ?? 0; const mValue = m?.[metric] ?? 0;
const monthsElapsed = now.getMonth() + 1; const monthsElapsed = monthIdx + 1;
const dayOfMonth = now.getDate(); const dayOfMonth = nowDay;
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); const daysInMonth = new Date(nowYear, monthIdx + 1, 0).getDate();
const yearStart = new Date(now.getFullYear(), 0, 1).getTime(); // Days elapsed since Jan 1 inclusive, using noon-UTC to avoid DST drift
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime(); const jan1Noon = Date.parse(`${nowYear}-01-01T12:00:00Z`);
const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart); 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<LedgerTile, number | null> = { const tileValues: Record<LedgerTile, number | null> = {
ytd: yValue, ytd: yValue,
avgMonth: yValue > 0 ? yValue / monthsElapsed : null, avgMonth: yValue > 0 ? yValue / monthsElapsed : null,
yearProj: yValue > 0 && yearFrac > 0 && yearFrac < 1 ? yValue / yearFrac : null, yearProj: yValue > 0 ? ytdDailyAvg * daysInYear : null,
thisMonth: mValue, thisMonth: mValue,
avgDay: mValue > 0 ? mValue / dayOfMonth : null, avgDay: yValue > 0 ? ytdDailyAvg : null,
monthProj: mValue > 0 && dayOfMonth > 0 && dayOfMonth < daysInMonth monthProj: mValue > 0 && dayOfMonth > 0 && dayOfMonth < daysInMonth
? (mValue / dayOfMonth) * daysInMonth ? (mValue / dayOfMonth) * daysInMonth
: null, : null,
@ -671,16 +683,16 @@ function PeriodSummaryRow({
{tiles.map((t) => { {tiles.map((t) => {
const value = tileValues[t]; const value = tileValues[t];
if (value == null) return null; if (value == null) return null;
return <StatTile key={t} label={tileDisplayLabel[t]} value={value} />; return <StatTile key={t} label={tileDisplayLabel[t]} value={value} className={METRIC_COLOR[metric]} />;
})} })}
</div> </div>
</div> </div>
); );
} }
function StatTile({ label, value }: { label: string; value: number }) { function StatTile({ label, value, className = '' }: { label: string; value: number; className?: string }) {
return ( return (
<div className="stat-card"> <div className={`stat-card ${className}`}>
<div className="stat-label">{label}</div> <div className="stat-label">{label}</div>
<div className="stat-value">{fmtMoney(value)}</div> <div className="stat-value">{fmtMoney(value)}</div>
</div> </div>

View file

@ -2,19 +2,33 @@
* Settings themes, default rate, and manual file import/export. * Settings themes, default rate, and manual file import/export.
*/ */
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useAppStore } from '@/store/appStore'; import { useAppStore } from '@/store/appStore';
import { THEME_NAMES } from '@/themes/ThemeProvider'; import { THEME_NAMES } from '@/themes/ThemeProvider';
import type { ThemeName, ThemeMode } from '@/types'; import type { ThemeName, ThemeMode } from '@/types';
import { isT99Encrypted } from '@/lib/storage/vault'; 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() { export function SettingsPage() {
const settings = useAppStore((s) => s.data.settings); const settings = useAppStore((s) => s.data.settings);
const setTheme = useAppStore((s) => s.setTheme); const setTheme = useAppStore((s) => s.setTheme);
const setDefaultRate = useAppStore((s) => s.setDefaultRate); const setDefaultRate = useAppStore((s) => s.setDefaultRate);
const setTimezone = useAppStore((s) => s.setTimezone);
const exportFile = useAppStore((s) => s.exportFile); const exportFile = useAppStore((s) => s.exportFile);
const importFile = useAppStore((s) => s.importFile); 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 [exportPwd, setExportPwd] = useState('');
const [importFileObj, setImportFileObj] = useState<File | null>(null); const [importFileObj, setImportFileObj] = useState<File | null>(null);
@ -116,6 +130,52 @@ export function SettingsPage() {
</div> </div>
</div> </div>
{/* ─── Timezone ──────────────────────────────────────────────────── */}
<div className="card">
<div className="card-header"><span className="card-title">Timezone</span></div>
<p className="text-sm text-muted" style={{ marginBottom: 10 }}>
Used for date calculations and "today" boundaries.
Browser default: <strong>{BROWSER_TZ}</strong>
</p>
<div className="flex-col gap-2">
<div className="flex gap-2 items-center">
<input
className="input"
style={{ flex: 1 }}
placeholder="Search timezones…"
value={tzSearch}
onChange={(e) => setTzSearch(e.target.value)}
/>
{settings.timezone && (
<button
className="btn btn-sm btn-ghost"
onClick={() => { setTimezone(undefined); setTzSearch(''); }}
title="Reset to browser default"
>
Reset
</button>
)}
</div>
<select
className="select"
size={6}
value={settings.timezone ?? ''}
onChange={(e) => setTimezone(e.target.value || undefined)}
style={{ width: '100%', height: 'auto' }}
>
<option value=""> Use browser default ({BROWSER_TZ})</option>
{filteredTZ.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
{settings.timezone && (
<p className="text-sm text-muted">
Active: <strong>{settings.timezone}</strong>
</p>
)}
</div>
</div>
{/* ─── Import / Export ───────────────────────────────────────────── */} {/* ─── Import / Export ───────────────────────────────────────────── */}
<div className="card"> <div className="card">
<div className="card-header"><span className="card-title">Backup &amp; Restore</span></div> <div className="card-header"><span className="card-title">Backup &amp; Restore</span></div>

View file

@ -8,7 +8,6 @@ import { useAppStore } from '@/store/appStore';
import { calculateTax } from '@/lib/tax/calculate'; import { calculateTax } from '@/lib/tax/calculate';
import { availableTaxYears } from '@/lib/tax/brackets'; import { availableTaxYears } from '@/lib/tax/brackets';
import type { FilingStatus, TaxInputs, TaxTile } from '@/types'; import type { FilingStatus, TaxInputs, TaxTile } from '@/types';
import { ChartSidebar } from '@/components/charts/ChartSidebar';
import { Modal } from '@/components/common/Modal'; import { Modal } from '@/components/common/Modal';
import { fmtMoney, fmtMoneyShort, todayISO } from '@/lib/format'; import { fmtMoney, fmtMoneyShort, todayISO } from '@/lib/format';
@ -113,10 +112,9 @@ export function TaxPage() {
setNewPaymentNote(''); setNewPaymentNote('');
}; };
return ( const left = (
<div className="split-layout"> <div className="flex-col gap-4">
<div className="left"> {/* ─── Year / filing status / projection toggle ───────────────── */}
{/* ─── Year / filing status / projection toggle ───────────────── */}
<div className="card"> <div className="card">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -205,9 +203,9 @@ export function TaxPage() {
<div className="stat-label">{TAX_TILE_LABELS[t]}</div> <div className="stat-label">{TAX_TILE_LABELS[t]}</div>
<div className="stat-value">{fmtMoneyShort(value)}</div> <div className="stat-value">{fmtMoneyShort(value)}</div>
{projValue !== undefined && ( {projValue !== undefined && (
<div className="stat-sub">projected: {fmtMoneyShort(projValue)}</div> <div className="stat-proj">proj {fmtMoneyShort(projValue)}</div>
)} )}
{sub && !projValue && <div className="stat-sub">{sub}</div>} {sub && <div className="stat-sub">{sub}</div>}
</div> </div>
); );
})} })}
@ -430,13 +428,10 @@ export function TaxPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="right">
<ChartSidebar tab="tax" />
</div>
</div> </div>
); );
return left;
} }
function BreakdownRow({ label, value, proj, bold, highlight }: { function BreakdownRow({ label, value, proj, bold, highlight }: {

View file

@ -26,7 +26,7 @@ import type {
} from '@/types'; } from '@/types';
import { uid } from '@/lib/id'; import { uid } from '@/lib/id';
import { Vault, deserializeT99 } from '@/lib/storage/vault'; import { Vault, deserializeT99 } from '@/lib/storage/vault';
import { todayISO } from '@/lib/format'; import { todayISO, setActiveTZ } from '@/lib/format';
// ─── Defaults ──────────────────────────────────────────────────────────────── // ─── Defaults ────────────────────────────────────────────────────────────────
@ -60,15 +60,19 @@ const defaultDashboard = (): DashboardConfig => ({
widgets: [ widgets: [
'ytdWorkValue', 'ytdWorkValue',
'ytdWorkProj', 'ytdWorkProj',
'ytdDailyAvg',
'ytdPayments', 'ytdPayments',
'ytdPaymentsProj', 'ytdPaymentsProj',
'expectedPayments',
'ytdExpenses', 'ytdExpenses',
'avgDailyExpenses',
'ytdNet', 'ytdNet',
'ytdNetProj', 'ytdNetProj',
'nextQuarterlyDue', 'nextQuarterlyDue',
'projectedAnnualTax',
'ytdActualTax', 'ytdActualTax',
'projectedAnnualTax',
'taxRemainingDue', 'taxRemainingDue',
'effectiveTaxRate',
], ],
workCharts: [defaultLedgerChart('workValue', 'Work Value by Day', 10)], workCharts: [defaultLedgerChart('workValue', 'Work Value by Day', 10)],
paymentsCharts: [defaultLedgerChart('payments', 'Payments by Day')], paymentsCharts: [defaultLedgerChart('payments', 'Payments by Day')],
@ -205,6 +209,7 @@ interface AppStore {
// ─── Settings ───────────────────────────────────────────────────────────── // ─── Settings ─────────────────────────────────────────────────────────────
setTheme: (theme: ThemeName, mode: ThemeMode) => void; setTheme: (theme: ThemeName, mode: ThemeMode) => void;
setDefaultRate: (rate: number) => void; setDefaultRate: (rate: number) => void;
setTimezone: (tz: string | undefined) => void;
// ─── File import/export ─────────────────────────────────────────────────── // ─── File import/export ───────────────────────────────────────────────────
/** Export a backup. Without a password the file is unencrypted plaintext. */ /** Export a backup. Without a password the file is unencrypted plaintext. */
@ -281,6 +286,14 @@ export const useAppStore = create<AppStore>((set, get) => {
['ytdWorkValue', 'ytdPaymentsProj', 'ytdNetProj', 'ytdActualTax', 'taxRemainingDue'].includes(w), ['ytdWorkValue', 'ytdPaymentsProj', 'ytdNetProj', 'ytdActualTax', 'taxRemainingDue'].includes(w),
); );
if (!hasNewWidgets) data.dashboard.widgets = dd.widgets; 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 }); set({ vault, data, ready: true });
// Apply any pending recurring expense occurrences // Apply any pending recurring expense occurrences
setTimeout(() => get().applyRecurringExpenses(), 0); setTimeout(() => get().applyRecurringExpenses(), 0);
@ -506,6 +519,11 @@ export const useAppStore = create<AppStore>((set, get) => {
mutate((d) => { d.settings.defaultRate = rate; }); mutate((d) => { d.settings.defaultRate = rate; });
}, },
setTimezone: (tz) => {
setActiveTZ(tz);
mutate((d) => { d.settings.timezone = tz; });
},
// ─── File import/export ───────────────────────────────────────────────── // ─── File import/export ─────────────────────────────────────────────────
exportFile: async (password) => { exportFile: async (password) => {

View file

@ -444,10 +444,33 @@ a:hover { text-decoration: underline; }
/* ─── Stats widgets ───────────────────────────────────────────────────────── */ /* ─── 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 { .stat-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(2, 1fr);
gap: 16px; gap: 10px;
} }
.stat-card { .stat-card {
@ -455,9 +478,12 @@ a:hover { text-decoration: underline; }
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 14px; 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-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-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 .stat-sub { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
.stat-card.positive .stat-value { color: var(--success); } .stat-card.positive .stat-value { color: var(--success); }
.stat-card.negative .stat-value { color: var(--danger); } .stat-card.negative .stat-value { color: var(--danger); }
@ -655,6 +681,10 @@ select.ss-input { cursor: pointer; }
.ss-value { padding: 7px 10px; } .ss-value { padding: 7px 10px; }
.ss-actions { padding: 0 4px; text-align: center; white-space: nowrap; } .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 */ /* Existing data rows */
.ss-row:hover { background: var(--bg-elev-2); } .ss-row:hover { background: var(--bg-elev-2); }
.ss-row:hover .ss-cell:hover { background: var(--accent-muted); } .ss-row:hover .ss-cell:hover { background: var(--accent-muted); }
@ -829,7 +859,7 @@ select.ss-input { cursor: pointer; }
.app-header { position: relative; } .app-header { position: relative; }
.hamburger-btn { display: inline-flex; } .hamburger-btn { display: inline-flex; }
.header-status { margin-left: auto; } .header-status { margin-left: auto; }
.kofi-header-btn { display: none; } .kofi-header-btn span.kofi-label { display: none; }
.app-nav { .app-nav {
display: none; display: none;
flex-direction: column; flex-direction: column;
@ -851,7 +881,6 @@ select.ss-input { cursor: pointer; }
/* Small phones (≤640px) — extra compact spacing */ /* Small phones (≤640px) — extra compact spacing */
@media (max-width: 640px) { @media (max-width: 640px) {
.app-body { padding: 10px; } .app-body { padding: 10px; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.stat-card .stat-value { font-size: 20px; } .stat-card .stat-value { font-size: 20px; }
.timer-display { font-size: 36px; } .timer-display { font-size: 36px; }
.timer-earned { font-size: 20px; } .timer-earned { font-size: 20px; }

View file

@ -27,6 +27,8 @@ export interface WorkEntry {
rate?: number; rate?: number;
/** Optional client/project tag */ /** Optional client/project tag */
client?: string; client?: string;
/** Payment has not yet been received for this work item */
paymentOutstanding?: boolean;
createdAt: EpochMs; createdAt: EpochMs;
updatedAt: EpochMs; updatedAt: EpochMs;
} }
@ -292,17 +294,20 @@ export interface DashboardConfig {
export type DashboardWidget = export type DashboardWidget =
| 'ytdWorkValue' | 'ytdWorkValue'
| 'ytdWorkProj' | 'ytdWorkProj'
| 'ytdDailyAvg'
| 'ytdPayments' | 'ytdPayments'
| 'ytdPaymentsProj' | 'ytdPaymentsProj'
| 'expectedPayments'
| 'ytdExpenses' | 'ytdExpenses'
| 'avgDailyExpenses'
| 'ytdNet' | 'ytdNet'
| 'ytdNetProj' | 'ytdNetProj'
| 'nextQuarterlyDue'
| 'projectedAnnualTax'
| 'ytdActualTax'
| 'taxRemainingDue'
| 'avgMonthlyNet' | 'avgMonthlyNet'
| 'avgDailyWork'; | 'nextQuarterlyDue'
| 'ytdActualTax'
| 'projectedAnnualTax'
| 'taxRemainingDue'
| 'effectiveTaxRate';
export type LedgerTile = export type LedgerTile =
| 'ytd' | 'ytd'
@ -355,6 +360,8 @@ export interface Settings {
mode: ThemeMode; mode: ThemeMode;
/** Default hourly rate, pre-fills timer & new work entries */ /** Default hourly rate, pre-fills timer & new work entries */
defaultRate: number; defaultRate: number;
/** IANA timezone string e.g. "America/Chicago". Undefined = use browser default. */
timezone?: string;
} }
/** /**