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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user