refactor: decompose monolithic App.tsx into per-component files

- Extract views to src/components/views/ (Overview, Logbook, Trends, Settings, Auth)
- Extract standalone components (EntryModal, ImportPreviewModal, ConfirmDialog,
  Sidebar, TopBar, MobileNav, EntryLedger, FiltersPanel, QuickAddPanel, etc.)
- Move shared helpers to src/lib (dashboard, filters, userDisplay) and static
  data to src/data (accents, quickAdds)
- App.tsx now orchestration-only (2971 -> 777 lines); no behavior change
This commit is contained in:
nh9961
2026-07-19 19:31:40 +00:00
parent 2cd8dd5130
commit 50c729eae7
34 changed files with 2312 additions and 2218 deletions
+196
View File
@@ -0,0 +1,196 @@
import { Activity, AlertTriangle, CalendarDays, ChevronRight, PoundSterling, TimerReset } from "lucide-react";
import {
Area,
AreaChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { MATERIAL_ACCENTS } from "../../data/accents";
import type { QuickAddItem } from "../../data/quickAdds";
import { currency, humanDateTime, spendFor } from "../../lib/metrics";
import { limitStatusMessage } from "../../lib/userLimits";
import { AppCard } from "../AppCard";
import { ChartTooltip } from "../ChartTooltip";
import { DailyLimitsCard } from "../DailyLimitsCard";
import { EmptyState } from "../EmptyState";
import { GreetingPanel } from "../GreetingPanel";
import { InsightCard } from "../InsightCard";
import { LegalFootnote } from "../LegalFootnote";
import { MetricTile } from "../MetricTiles";
import { QuickAddPanel } from "../QuickAddPanel";
import { TodayPanel } from "../TodayPanel";
import type { Dashboard, Insight } from "../../lib/dashboard";
import type { AuthUser, LimitCheckResult, RedBullEntry, UserLimits } from "../../types";
type OverviewViewProps = {
summary: Dashboard;
entries: RedBullEntry[];
insights: Insight[];
quickAdds: QuickAddItem[];
recentEntries: RedBullEntry[];
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
flavourData: Array<{ name: string; value: number; spend: number; accent: string }>;
user: AuthUser;
userLimits: UserLimits;
limitCheck: LimitCheckResult;
onQuickAdd: (item: QuickAddItem) => void;
onAdd: () => void;
onScan: () => void;
onOpenLogbook: () => void;
onOpenSettings: () => void;
};
export function OverviewView({
summary,
entries,
insights,
quickAdds,
recentEntries,
chartData,
flavourData,
user,
userLimits,
limitCheck,
onQuickAdd,
onAdd,
onScan,
onOpenLogbook,
onOpenSettings,
}: OverviewViewProps) {
const todaySpendRaw = limitCheck.todaySpend;
const spendLimitDetail =
userLimits.dailySpendLimit != null
? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today`
: `${summary.monthSpend} this month`;
return (
<div className="grid gap-4">
<GreetingPanel summary={summary} user={user} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
<DailyLimitsCard limits={userLimits} check={limitCheck} onOpenSettings={onOpenSettings} />
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
<div className="hidden lg:block">
<TodayPanel summary={summary} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
</div>
{limitCheck.violations.length ? (
<section className="limit-alert">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 shrink-0" size={20} aria-hidden="true" style={{ color: "#b06000" }} />
<div>
<p className="limit-alert-title">Limit alerts</p>
<p className="limit-alert-copy mt-1">
{limitStatusMessage(limitCheck.violations, limitCheck, userLimits)}
</p>
</div>
</div>
</section>
) : null}
<section className="overview-metrics-grid grid gap-3">
<MetricTile icon={CalendarDays} label="This month" value={summary.monthCans} detail={`${summary.monthSpend} spent`} accent={MATERIAL_ACCENTS.primary} />
<MetricTile
icon={PoundSterling}
label={userLimits.dailySpendLimit != null ? "Today's budget" : "Total spend"}
value={userLimits.dailySpendLimit != null ? currency.format(todaySpendRaw) : summary.totalSpend}
detail={spendLimitDetail}
accent={MATERIAL_ACCENTS.secondary}
/>
<MetricTile icon={Activity} label="Favourite" value={summary.favouriteFlavour} detail="by total cans" accent={MATERIAL_ACCENTS.tertiary} />
<MetricTile icon={TimerReset} label="Days without" value={summary.daysWithoutRedBull} detail={`${summary.currentStreak} day streak`} accent={MATERIAL_ACCENTS.error} />
</section>
<section className="overview-charts-grid grid gap-4">
<AppCard title="Spend overview" subtitle="Last 30 logged days">
{chartData.length ? (
<div className="chart-shell chart-shell--area">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
<defs>
<linearGradient id="mikuSpend" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#39d5ff" stopOpacity={0.36} />
<stop offset="100%" stopColor="#39d5ff" stopOpacity={0.03} />
</linearGradient>
</defs>
<CartesianGrid stroke="rgba(203,213,225,0.12)" vertical={false} />
<XAxis dataKey="label" stroke="#94a3b8" tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" tickLine={false} axisLine={false} />
<Tooltip content={<ChartTooltip />} />
<Area type="monotone" dataKey="spend" name="Spend" stroke="#39d5ff" fill="url(#mikuSpend)" strokeWidth={3} />
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<EmptyState title="No spend data yet" copy="Add an intake or use quick add to start the chart." actionLabel="Add intake" onAction={onAdd} />
)}
</AppCard>
<AppCard title="Recent entries" subtitle={`${recentEntries.length} shown`}>
{recentEntries.length ? (
<div className="grid gap-2">
{recentEntries.map((entry) => (
<MiniEntry key={entry.id} entry={entry} />
))}
<button className="list-button" type="button" onClick={onOpenLogbook}>
Open logbook
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
) : (
<EmptyState title="Nothing logged" copy="Your newest entries will appear here." actionLabel="Add intake" onAction={onAdd} />
)}
</AppCard>
</section>
<section className="overview-insights-grid grid gap-3">
{insights.map((insight) => (
<InsightCard key={insight.label} insight={insight} />
))}
</section>
<section className="grid gap-4">
<AppCard title="Flavour mix" subtitle="Cans by flavour">
{flavourData.length ? (
<div className="chart-shell chart-shell--pie">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={flavourData} dataKey="value" nameKey="name" innerRadius={70} outerRadius={104} paddingAngle={4} stroke="#080d1f" strokeWidth={4}>
{flavourData.map((entry) => (
<Cell key={entry.name} fill={entry.accent} />
))}
</Pie>
<Tooltip content={<ChartTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
) : (
<EmptyState title="No flavours yet" copy="Flavour breakdown appears after your first entry." />
)}
</AppCard>
</section>
<LegalFootnote className="mt-2" />
</div>
);
}
function MiniEntry({ entry }: { entry: RedBullEntry }) {
return (
<div className="mini-entry-card">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: entry.flavourAccent }} />
<div className="min-w-0">
<p className="mini-entry-title truncate">{entry.flavour}</p>
<p className="mini-entry-meta truncate">{humanDateTime(entry.dateTime)}</p>
</div>
<p className="mini-entry-price">{currency.format(spendFor(entry))}</p>
</div>
);
}