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>}
</div>
<button className="kofi-header-btn" onClick={() => setKofiOpen(true)}>
Donate
<span className="kofi-label">Donate</span>
</button>
<button
className="hamburger-btn"

View file

@ -36,7 +36,7 @@ export function ChartSidebar({ tab, defaultRangeStart, defaultRangeEnd }: {
key={c.id}
config={c}
onChange={(patch) => 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)}
/>
))}
<button className="btn" onClick={() => addChart()}>

View file

@ -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({
<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">
<button type="button" className="btn" onClick={onCancel}>Cancel</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 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<Set<string>>(new Set());
const [hideEmpty, setHideEmpty] = useState(false);
const [selectedLevel, setSelectedLevel] = useState<ExpandLevel>('year');
@ -168,7 +170,7 @@ export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay,
<tr>
<th>Period / Item</th>
<th className="num">{valueLabel}</th>
<th style={{ width: 80 }}></th>
<th style={{ width: onToggleOutstanding ? 110 : 80 }}></th>
</tr>
</thead>
<tbody>
@ -225,6 +227,19 @@ export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay,
+
</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 && (
<button
className="btn btn-sm btn-ghost"

View file

@ -146,10 +146,11 @@ function RangeBar({
type WorkDraft = {
date: string; description: string; client: string;
hours: string; rate: string; amount: string;
paymentOutstanding: boolean;
};
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({
@ -192,6 +193,7 @@ export function WorkSheet({
hours,
rate: hours != null ? rate : undefined,
amount,
paymentOutstanding: draft.paymentOutstanding,
});
setDraft(blankWork(today, defaultRate));
};
@ -211,6 +213,7 @@ export function WorkSheet({
<th className="ss-right">$/hr</th>
<th className="ss-right">Flat $</th>
<th className="ss-right">Value</th>
<th style={{ width: 56, textAlign: 'center' }} title="Payment outstanding">Unpaid</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
@ -245,6 +248,16 @@ export function WorkSheet({
<td className="ss-right mono ss-value text-muted">
{draftValue > 0 ? fmtMoney(draftValue) : '—'}
</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">
<button className="btn btn-sm btn-primary" onClick={addRow}
disabled={!draft.description.trim()} title="Add row">
@ -255,7 +268,7 @@ export function WorkSheet({
{filtered.length === 0 && (
<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
</td>
</tr>
@ -288,6 +301,16 @@ export function WorkSheet({
});
}} />
<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">
<button className="btn btn-sm btn-ghost text-danger"
onClick={() => setDeleting(e.id)} title="Delete">
@ -302,7 +325,7 @@ export function WorkSheet({
<tr className="ss-total">
<td colSpan={6}>Total</td>
<td className="ss-right mono">{fmtMoney(total)}</td>
<td></td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>

View file

@ -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;
}

View file

@ -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<DashboardWidget, string> = {
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<DashboardWidget, { value: string; sub?: string; className?: string }> = {
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() {
</button>
</div>
{/* Stat widgets */}
<div className="stat-grid">
{data.dashboard.widgets.map((w) => {
const def = widgets[w];
{/* Stat widgets — grouped horizontally */}
<div className="dashboard-groups">
{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 (
<div key={w} className={`stat-card ${def.className ?? ''}`}>
<div className="stat-label">{WIDGET_LABELS[w]}</div>
<div className="stat-value">{def.value}</div>
{def.sub && <div className="stat-sub">{def.sub}</div>}
<div key={group.label} className="dashboard-group">
<div className="dashboard-group-label">{group.label}</div>
<div className="stat-grid">
{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>
);
})}
@ -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)}
/>
))}
</div>
@ -173,17 +220,23 @@ export function DashboardPage() {
onClose={() => setConfigOpen(false)}
footer={<button className="btn btn-primary" onClick={() => setConfigOpen(false)}>Done</button>}
>
<div className="flex-col gap-2">
<p className="text-sm text-muted">Choose which stats appear at the top:</p>
{(Object.keys(WIDGET_LABELS) as DashboardWidget[]).map((w) => (
<label key={w} className="checkbox">
<input
type="checkbox"
checked={data.dashboard.widgets.includes(w)}
onChange={() => toggleWidget(w)}
/>
{WIDGET_LABELS[w]}
</label>
<div className="flex-col gap-4">
{WIDGET_GROUPS.map((group) => (
<div key={group.label} className="flex-col gap-2">
<p className="text-sm" style={{ fontWeight: 600 }}>{group.label}</p>
{group.entries.flatMap(({ actual, proj }) =>
[actual, ...(proj ? [proj] : [])].map((w) => (
<label key={w} className="checkbox">
<input
type="checkbox"
checked={data.dashboard.widgets.includes(w)}
onChange={() => toggleWidget(w)}
/>
{WIDGET_LABELS[w]}
</label>
))
)}
</div>
))}
</div>
</Modal>

View file

@ -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 });
}}
/>
</div>
@ -606,13 +610,19 @@ const LEDGER_TILE_LABELS: Record<LedgerTile, string> = {
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<LedgerTile, number | null> = {
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 <StatTile key={t} label={tileDisplayLabel[t]} value={value} />;
return <StatTile key={t} label={tileDisplayLabel[t]} value={value} className={METRIC_COLOR[metric]} />;
})}
</div>
</div>
);
}
function StatTile({ label, value }: { label: string; value: number }) {
function StatTile({ label, value, className = '' }: { label: string; value: number; className?: string }) {
return (
<div className="stat-card">
<div className={`stat-card ${className}`}>
<div className="stat-label">{label}</div>
<div className="stat-value">{fmtMoney(value)}</div>
</div>

View file

@ -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<File | null>(null);
@ -116,6 +130,52 @@ export function SettingsPage() {
</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 ───────────────────────────────────────────── */}
<div className="card">
<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 { 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 (
<div className="split-layout">
<div className="left">
{/* ─── Year / filing status / projection toggle ───────────────── */}
const left = (
<div className="flex-col gap-4">
{/* ─── Year / filing status / projection toggle ───────────────── */}
<div className="card">
<div className="flex items-center justify-between flex-wrap gap-3">
<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-value">{fmtMoneyShort(value)}</div>
{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>
);
})}
@ -430,13 +428,10 @@ export function TaxPage() {
</div>
</div>
</div>
</div>
<div className="right">
<ChartSidebar tab="tax" />
</div>
</div>
);
return left;
}
function BreakdownRow({ label, value, proj, bold, highlight }: {

View file

@ -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<AppStore>((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<AppStore>((set, get) => {
mutate((d) => { d.settings.defaultRate = rate; });
},
setTimezone: (tz) => {
setActiveTZ(tz);
mutate((d) => { d.settings.timezone = tz; });
},
// ─── File import/export ─────────────────────────────────────────────────
exportFile: async (password) => {

View file

@ -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; }

View file

@ -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;
}
/**