From 55d7c736c9247c94ffa378a647b36f94dcd20e70 Mon Sep 17 00:00:00 2001 From: Deven Thiel Date: Thu, 5 Mar 2026 22:35:34 -0500 Subject: [PATCH] updated some UI elements and calculations --- src/App.tsx | 2 +- src/components/charts/ChartSidebar.tsx | 4 +- src/components/spreadsheet/EntryForm.tsx | 14 +- .../spreadsheet/HierSpreadsheet.tsx | 21 +- src/components/spreadsheet/InlineSheet.tsx | 29 ++- src/lib/format.ts | 37 +++- src/pages/DashboardPage.tsx | 189 +++++++++++------- src/pages/LedgerPage.tsx | 46 +++-- src/pages/SettingsPage.tsx | 62 +++++- src/pages/TaxPage.tsx | 19 +- src/store/appStore.ts | 22 +- src/themes/global.css | 37 +++- src/types/index.ts | 17 +- 13 files changed, 378 insertions(+), 121 deletions(-) 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} 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 ( + + ); + })()} {isItem && onEdit && ( + + - {/* 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={} > -
-

Choose which stats appear at the top:

- {(Object.keys(WIDGET_LABELS) as DashboardWidget[]).map((w) => ( - +
+ {WIDGET_GROUPS.map((group) => ( +
+

{group.label}

+ {group.entries.flatMap(({ actual, proj }) => + [actual, ...(proj ? [proj] : [])].map((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 && ( + + )} +
+ + {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; } /**