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,104 @@
|
||||
import { Loader2, LogIn } from "lucide-react";
|
||||
import { useState, type CSSProperties, type FormEvent } from "react";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import { ShellBackdrop } from "../ShellBackdrop";
|
||||
import type { SetupStatus } from "../../types";
|
||||
|
||||
type AuthMode = "login" | "signup";
|
||||
|
||||
type AuthViewProps = {
|
||||
authError: string;
|
||||
busy: boolean;
|
||||
setupStatus: SetupStatus;
|
||||
shellStyle: CSSProperties;
|
||||
themeId: string;
|
||||
resolvedMode: "light" | "dark";
|
||||
onLogin: (email: string, password: string) => Promise<void>;
|
||||
onSignup: (name: string, email: string, password: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function AuthView({
|
||||
authError,
|
||||
busy,
|
||||
setupStatus,
|
||||
shellStyle,
|
||||
themeId,
|
||||
resolvedMode,
|
||||
onLogin,
|
||||
onSignup,
|
||||
}: AuthViewProps) {
|
||||
const [mode, setMode] = useState<AuthMode>("login");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (mode === "signup") {
|
||||
void onSignup(name, email, password);
|
||||
return;
|
||||
}
|
||||
void onLogin(email, password);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell min-h-screen" data-theme={themeId} data-mode={resolvedMode} style={shellStyle}>
|
||||
<ShellBackdrop />
|
||||
<main className="flex min-h-screen items-center justify-center p-6">
|
||||
<div className="auth-panel-shell">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="hero-name text-3xl">Red Bull tracker</h1>
|
||||
<p className="hero-copy mt-2 text-sm">Track intake, sync across devices.</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-panel-card">
|
||||
{setupStatus.state !== "ok" && (
|
||||
<div className="limit-alert mb-4 px-3 py-2 text-xs">
|
||||
{setupStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="auth-mode-toggle mb-5">
|
||||
<button className={mode === "login" ? "auth-mode-active" : ""} type="button" onClick={() => setMode("login")}>
|
||||
Log in
|
||||
</button>
|
||||
<button className={mode === "signup" ? "auth-mode-active" : ""} type="button" onClick={() => setMode("signup")}>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="grid gap-3" onSubmit={submit}>
|
||||
{mode === "signup" && (
|
||||
<label className="field-label">
|
||||
Name
|
||||
<input className="field-control" type="text" value={name} onChange={(event) => setName(event.target.value)} placeholder="Ned" />
|
||||
</label>
|
||||
)}
|
||||
<label className="field-label">
|
||||
Email
|
||||
<input className="field-control" type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="you@example.com" required />
|
||||
</label>
|
||||
<label className="field-label">
|
||||
Password
|
||||
<input className="field-control" minLength={8} type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="8+ characters" required />
|
||||
</label>
|
||||
|
||||
{authError && (
|
||||
<div className="rounded-md px-3 py-2 text-sm" style={{ border: "1px solid #ffc9c2", background: "#fff3f1", color: "#9f1c16" }}>
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="primary-button w-full mt-1" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <LogIn size={17} aria-hidden="true" />}
|
||||
{mode === "signup" ? "Create account" : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<LegalFootnote className="mt-5" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { EntryLedger } from "../EntryLedger";
|
||||
import { FiltersPanel } from "../FiltersPanel";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import type { Filters, Flavour, RedBullEntry } from "../../types";
|
||||
|
||||
type LogbookViewProps = {
|
||||
entries: RedBullEntry[];
|
||||
totalEntries: number;
|
||||
filters: Filters;
|
||||
flavours: Flavour[];
|
||||
onFilterChange: (filters: Filters) => void;
|
||||
onAdd: () => void;
|
||||
onEdit: (entry: RedBullEntry) => void;
|
||||
onDelete: (id: string) => void;
|
||||
};
|
||||
|
||||
export function LogbookView({
|
||||
entries,
|
||||
totalEntries,
|
||||
filters,
|
||||
flavours,
|
||||
onFilterChange,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: LogbookViewProps) {
|
||||
return (
|
||||
<section className="grid gap-4">
|
||||
<div className="logbook-layout grid gap-4">
|
||||
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} />
|
||||
<EntryLedger entries={entries} totalEntries={totalEntries} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />
|
||||
</div>
|
||||
<LegalFootnote />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Cloud, FileJson, FileSpreadsheet, Loader2, LogOut, RefreshCcw, RotateCcw, Sparkles, Upload } from "lucide-react";
|
||||
import { MATERIAL_ACCENTS } from "../../data/accents";
|
||||
import type { AppTheme, ThemeMode } from "../../data/themes";
|
||||
import { appwriteConfig } from "../../lib/appwrite";
|
||||
import { userInitial } from "../../lib/userDisplay";
|
||||
import { AppCard } from "../AppCard";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import { LimitsSettingsForm } from "../LimitsSettingsForm";
|
||||
import { MiniMetric } from "../MetricTiles";
|
||||
import { ThemePicker } from "../ThemePicker";
|
||||
import type { Dashboard } from "../../lib/dashboard";
|
||||
import type { AuthUser, LimitCheckResult, RedBullEntry, SetupStatus, UserLimits } from "../../types";
|
||||
|
||||
type SettingsViewProps = {
|
||||
activeTheme: AppTheme;
|
||||
summary: Dashboard;
|
||||
dataLoading: boolean;
|
||||
entries: RedBullEntry[];
|
||||
notice: string;
|
||||
setupStatus: SetupStatus;
|
||||
themeId: string;
|
||||
themeMode: ThemeMode;
|
||||
user: AuthUser | null;
|
||||
userLimits: UserLimits;
|
||||
limitCheck: LimitCheckResult;
|
||||
busyAction: string | null;
|
||||
onExportExcel: () => void;
|
||||
onImportExcel: () => void;
|
||||
onExportJson: () => void;
|
||||
onImportJson: () => void;
|
||||
onLogout: () => void;
|
||||
onReset: () => void;
|
||||
onThemeChange: (id: string) => void;
|
||||
onThemeModeChange: (mode: ThemeMode) => void;
|
||||
onSaveLimits: (limits: UserLimits) => void;
|
||||
onRerunOnboarding: () => void;
|
||||
};
|
||||
|
||||
export function SettingsView({
|
||||
activeTheme,
|
||||
summary,
|
||||
dataLoading,
|
||||
entries,
|
||||
notice,
|
||||
setupStatus,
|
||||
themeId,
|
||||
themeMode,
|
||||
user,
|
||||
userLimits,
|
||||
limitCheck,
|
||||
busyAction,
|
||||
onExportExcel,
|
||||
onImportExcel,
|
||||
onExportJson,
|
||||
onImportJson,
|
||||
onLogout,
|
||||
onReset,
|
||||
onThemeChange,
|
||||
onThemeModeChange,
|
||||
onSaveLimits,
|
||||
onRerunOnboarding,
|
||||
}: SettingsViewProps) {
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
|
||||
<div className="grid gap-4">
|
||||
<AppCard title="Daily limits" subtitle="Personal caps for cans, spend, and stop time (BST)">
|
||||
<LimitsSettingsForm
|
||||
limits={userLimits}
|
||||
check={limitCheck}
|
||||
saving={busyAction === "save-limits"}
|
||||
onSave={onSaveLimits}
|
||||
/>
|
||||
<div className="mt-4 border-t border-white/5 pt-4 flex justify-end">
|
||||
<button
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-white/5 border border-white/10 px-4 text-xs font-bold text-slate-300 hover:bg-white/10 transition active:scale-95"
|
||||
type="button"
|
||||
onClick={onRerunOnboarding}
|
||||
>
|
||||
<Sparkles size={14} className="text-cyan-400" />
|
||||
Run setup again
|
||||
</button>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Appearance" subtitle={`${activeTheme.label} theme active`}>
|
||||
<ThemePicker themeId={themeId} themeMode={themeMode} onChange={onThemeChange} onThemeModeChange={onThemeModeChange} />
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Data & sync" subtitle={`${entries.length} entries synced for this user`}>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MiniMetric label="All-time cans" value={summary.allTimeCans} accent={MATERIAL_ACCENTS.primary} />
|
||||
<MiniMetric label="Total spend" value={summary.totalSpend} accent={MATERIAL_ACCENTS.tertiary} />
|
||||
<MiniMetric label="Favourite" value={summary.favouriteFlavour} accent={MATERIAL_ACCENTS.secondary} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<button className="secondary-button justify-center" type="button" onClick={() => window.location.reload()} disabled={dataLoading}>
|
||||
{dataLoading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <RefreshCcw size={17} aria-hidden="true" />}
|
||||
Sync now
|
||||
</button>
|
||||
<button className="excel-button justify-center" type="button" onClick={onExportExcel} disabled={!entries.length || Boolean(busyAction)}>
|
||||
<FileSpreadsheet size={17} aria-hidden="true" />
|
||||
Export XLSX
|
||||
</button>
|
||||
<button className="excel-button justify-center" type="button" onClick={onImportExcel} disabled={Boolean(busyAction)}>
|
||||
<Upload size={17} aria-hidden="true" />
|
||||
Import XLSX
|
||||
</button>
|
||||
<button className="secondary-button justify-center" type="button" onClick={onExportJson} disabled={!entries.length || Boolean(busyAction)}>
|
||||
<FileJson size={17} aria-hidden="true" />
|
||||
Export JSON
|
||||
</button>
|
||||
<button className="secondary-button justify-center" type="button" onClick={onImportJson} disabled={Boolean(busyAction)}>
|
||||
<Upload size={17} aria-hidden="true" />
|
||||
Import JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-white/10 bg-white/[0.05] p-4">
|
||||
<p className="text-sm font-semibold text-white">Configured Appwrite IDs</p>
|
||||
<dl className="mt-3 grid gap-2 text-sm text-slate-300">
|
||||
<DataPair label="Endpoint" value={appwriteConfig.endpoint} />
|
||||
<DataPair label="Project" value={appwriteConfig.projectId} />
|
||||
<DataPair label="Database" value={appwriteConfig.databaseId} />
|
||||
<DataPair label="Collection" value={appwriteConfig.collectionId} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<button className="danger-button mt-5 justify-center" type="button" onClick={onReset} disabled={!entries.length || Boolean(busyAction)}>
|
||||
<RotateCcw size={17} aria-hidden="true" />
|
||||
Delete all entries
|
||||
</button>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<AppCard title="Account" subtitle="Signed in with Appwrite">
|
||||
<div className="account-card">
|
||||
<div className="account-avatar">{userInitial(user)}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-medium text-slate-950">{user?.name || "Appwrite user"}</p>
|
||||
<p className="truncate text-sm text-slate-500">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.05] p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||
{dataLoading ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Cloud size={16} aria-hidden="true" />}
|
||||
{notice}
|
||||
</div>
|
||||
<p className={`mt-2 text-xs ${setupStatus.state === "ok" ? "text-emerald-700" : "text-amber-700"}`}>{setupStatus.message}</p>
|
||||
</div>
|
||||
<button className="secondary-button mt-4 justify-center" type="button" onClick={onLogout}>
|
||||
<LogOut size={17} aria-hidden="true" />
|
||||
Log out
|
||||
</button>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<LegalFootnote className="mt-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataPair({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[110px_1fr]">
|
||||
<dt className="text-slate-500">{label}</dt>
|
||||
<dd className="truncate font-mono text-xs" style={{ color: "#174ea6" }}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart as RechartsLineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { AppCard } from "../AppCard";
|
||||
import { ChartTooltip } from "../ChartTooltip";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
import { FiltersPanel } from "../FiltersPanel";
|
||||
import { InsightCard } from "../InsightCard";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import { SpendForecastCard } from "../SpendForecastCard";
|
||||
import type { Insight } from "../../lib/dashboard";
|
||||
import type { Filters, Flavour, RedBullEntry, UserLimits } from "../../types";
|
||||
|
||||
type TrendsViewProps = {
|
||||
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
||||
weekData: Array<{ label: string; spend: number; cans: number }>;
|
||||
flavourData: Array<{ name: string; value: number; spend: number; accent: string }>;
|
||||
insights: Insight[];
|
||||
entries: RedBullEntry[];
|
||||
filters: Filters;
|
||||
flavours: Flavour[];
|
||||
userLimits: UserLimits;
|
||||
onFilterChange: (filters: Filters) => void;
|
||||
onSaveLimits: (limits: UserLimits) => void;
|
||||
};
|
||||
|
||||
export function TrendsView({
|
||||
chartData,
|
||||
weekData,
|
||||
flavourData,
|
||||
insights,
|
||||
entries,
|
||||
filters,
|
||||
flavours,
|
||||
userLimits,
|
||||
onFilterChange,
|
||||
onSaveLimits,
|
||||
}: TrendsViewProps) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<section className="logbook-layout grid gap-4">
|
||||
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} compact />
|
||||
<AppCard title="Cans and spend" subtitle={`${entries.length} entries in view`}>
|
||||
{chartData.length ? (
|
||||
<ResponsiveContainer width="100%" height={340}>
|
||||
<AreaChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||
<defs>
|
||||
<linearGradient id="trendSpend" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#39d5ff" stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor="#39d5ff" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="trendCans" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ff3448" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#ff3448" stopOpacity={0.02} />
|
||||
</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(#trendSpend)" strokeWidth={3} />
|
||||
<Area type="monotone" dataKey="cans" name="Cans" stroke="#ff3448" fill="url(#trendCans)" strokeWidth={3} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="No trend data" copy="Filtered chart data appears here." />
|
||||
)}
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<AppCard title="Caffeine by day" subtitle="Estimated mg">
|
||||
{chartData.length ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||
<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 />} />
|
||||
<Bar dataKey="caffeine" name="Caffeine" fill="#39d5ff" radius={[8, 8, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="No caffeine data" copy="Add entries to estimate caffeine over time." />
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Weekly comparison" subtitle="Spend and cans">
|
||||
{weekData.length ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RechartsLineChart data={weekData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||
<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 />} />
|
||||
<Line type="monotone" dataKey="spend" name="Spend" stroke="#ffd84d" strokeWidth={3} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="cans" name="Cans" stroke="#ffb7d9" strokeWidth={3} dot={{ r: 3 }} />
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="No weekly comparison" copy="Weekly comparisons appear as your history grows." />
|
||||
)}
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
|
||||
<AppCard title="Flavour split" subtitle="Cans by flavour">
|
||||
{flavourData.length ? (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<PieChart>
|
||||
<Pie data={flavourData} dataKey="value" nameKey="name" innerRadius={76} outerRadius={118} paddingAngle={4} stroke="#080d1f" strokeWidth={4}>
|
||||
{flavourData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.accent} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="No flavour split" copy="Entries will form a flavour mix here." />
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-3 xl:grid-cols-1">
|
||||
{insights.map((insight) => (
|
||||
<InsightCard key={insight.label} insight={insight} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4">
|
||||
<SpendForecastCard
|
||||
entries={entries}
|
||||
userLimits={userLimits}
|
||||
onSaveLimits={onSaveLimits}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<LegalFootnote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user