diff --git a/src/App.tsx b/src/App.tsx index 74abae3..096f1c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,66 +1,8 @@ -import type { Models } from "appwrite"; -import { - Activity, - AlertTriangle, - CalendarDays, - Camera, - ChevronRight, - Cloud, - Command, - Edit3, - FileJson, - FileSpreadsheet, - Gauge, - Home, - LineChart, - Loader2, - LogIn, - LogOut, - Plus, - PoundSterling, - RefreshCcw, - RotateCcw, - Search, - Settings2, - Sparkles, - TimerReset, - Trash2, - Upload, - User, - X, - Zap, -} from "lucide-react"; -import type { LucideIcon } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { flavourMeta, mergedFlavours } from "./data/flavours"; +import { QUICK_ADDS, type QuickAddItem } from "./data/quickAdds"; import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type CSSProperties, - type FormEvent, - type ReactNode, -} from "react"; -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Cell, - Line, - LineChart as RechartsLineChart, - Pie, - PieChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta, mergedFlavours } from "./data/flavours"; -import { - APP_THEMES, THEME_MODE_STORAGE_KEY, THEME_STORAGE_KEY, getThemeById, @@ -69,7 +11,6 @@ import { normaliseThemeMode, readStoredThemeId, readStoredThemeMode, - type AppTheme, type ThemeMode, } from "./data/themes"; import { themeTokensToStyle } from "./lib/themeTokens"; @@ -84,11 +25,23 @@ import { updateEntry, } from "./lib/appwriteEntries"; import { BarcodeScannerModal } from "./components/BarcodeScannerModal"; -import { DailyLimitsCard } from "./components/DailyLimitsCard"; -import { LegalFootnote } from "./components/LegalFootnote"; -import { LimitsSettingsForm } from "./components/LimitsSettingsForm"; +import { ConfirmDialog } from "./components/ConfirmDialog"; +import { EntryModal } from "./components/EntryModal"; +import { ImportPreviewModal } from "./components/ImportPreviewModal"; +import { LoadingScreen } from "./components/LoadingScreen"; +import { MobileNav } from "./components/MobileNav"; import { OnboardingScreen } from "./components/OnboardingScreen"; -import { buildDynamicGreeting } from "./lib/greeting"; +import { ShellBackdrop } from "./components/ShellBackdrop"; +import { Sidebar } from "./components/Sidebar"; +import { StatusRail } from "./components/StatusRail"; +import { TopBar } from "./components/TopBar"; +import { AuthView } from "./components/views/AuthView"; +import { LogbookView } from "./components/views/LogbookView"; +import { OverviewView } from "./components/views/OverviewView"; +import { SettingsView } from "./components/views/SettingsView"; +import { TrendsView } from "./components/views/TrendsView"; +import { buildDashboard, buildInsights } from "./lib/dashboard"; +import { applyFilters, DEFAULT_FILTERS } from "./lib/filters"; import { evaluateLimits, limitStatusMessage, @@ -96,56 +49,19 @@ import { parseUserLimits, } from "./lib/userLimits"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; -import { - caffeineFor, - caffeinePerCan, - canLimitFromSpend, - currency, - currentStreak, - daysSinceLast, - defaultPriceForSize, - entriesInRange, - formatLocalInput, - groupByDay, - groupByFlavour, - groupByWeek, - highestAveragePrice, - humanDateTime, - makeId, - oneDecimal, - spendFor, - startOfDay, - startOfMonth, - startOfWeek, - sugarFor, - sum, - topByCans, - trackedWeeks, - wholeNumber, -} from "./lib/metrics"; +import { groupByDay, groupByFlavour, groupByWeek, makeId } from "./lib/metrics"; import { exportPayload, parseImport } from "./lib/storage"; import type { - DateFilter, + AppView, + AuthUser, EntryDraft, Filters, - Flavour, ImportPreview, - LimitCheckResult, RedBullEntry, + SetupStatus, UserLimits, } from "./types"; -type AppView = "overview" | "logbook" | "trends" | "settings"; -type AuthMode = "login" | "signup"; -type AuthUser = Models.User; -type SetupStatus = { state: "checking" | "ok" | "error"; message: string }; -type ForecastPoint = { - label: string; - current: number; - lower: number; - limit?: number; -}; - type PendingLimitAction = { kind: "save" | "quick"; draft: EntryDraft; @@ -153,42 +69,6 @@ type PendingLimitAction = { quickLabel?: string; }; -const MATERIAL_ACCENTS = { - primary: "var(--chart-primary)", - secondary: "var(--chart-secondary)", - tertiary: "var(--chart-tertiary)", - error: "var(--chart-error)", - custom: "#b85d84", -}; - -const DEFAULT_FILTERS: Filters = { - flavour: "all", - dateRange: "all", - store: "", - from: "", - to: "", -}; - -const QUICK_ADDS = [ - { label: "Original", flavour: "Original", sizeMl: 250, pricePerCan: 1.75 }, - { label: "Sugar Free", flavour: "Sugar Free", sizeMl: 250, pricePerCan: 1.75 }, - { label: "Iced Vanilla", flavour: "Iced Vanilla", sizeMl: 250, pricePerCan: 1.75 }, - { label: "473ml Original", flavour: "Original", sizeMl: 473, pricePerCan: 2.85 }, -]; - -const NAV_ITEMS: Array<{ id: AppView; label: string; icon: LucideIcon }> = [ - { id: "overview", label: "Overview", icon: Home }, - { id: "logbook", label: "Logbook", icon: CalendarDays }, - { id: "trends", label: "Trends", icon: LineChart }, - { id: "settings", label: "Settings", icon: Settings2 }, -]; - -const THEME_MODES: Array<{ id: ThemeMode; label: string }> = [ - { id: "light", label: "Light" }, - { id: "dark", label: "Dark" }, - { id: "system", label: "System" }, -]; - function App() { const [themeId, setThemeId] = useState(() => readStoredThemeId()); const [themeMode, setThemeMode] = useState(() => readStoredThemeMode()); @@ -517,7 +397,7 @@ function App() { saveDraftWithLimitCheck(draft, editingEntry?.id); } - async function quickAdd(item: (typeof QUICK_ADDS)[number]) { + async function quickAdd(item: QuickAddItem) { if (!user) return; const meta = flavourMeta(item.flavour); const draft: EntryDraft = { @@ -890,2082 +770,8 @@ function App() { ); } -function ShellBackdrop() { - return ( - <> -
-
-
- - ); -} - -function LoadingScreen({ - setupStatus, - shellStyle, - themeId, - resolvedMode, -}: { - setupStatus: SetupStatus; - shellStyle: CSSProperties; - themeId: string; - resolvedMode: "light" | "dark"; -}) { - return ( -
- -
-
-
-
-

Red Bull tracker

-

{setupStatus.message}

-
-
-
- ); -} - -function AuthView({ - authError, - busy, - setupStatus, - shellStyle, - themeId, - resolvedMode, - onLogin, - onSignup, -}: { - authError: string; - busy: boolean; - setupStatus: SetupStatus; - shellStyle: CSSProperties; - themeId: string; - resolvedMode: "light" | "dark"; - onLogin: (email: string, password: string) => Promise; - onSignup: (name: string, email: string, password: string) => Promise; -}) { - const [mode, setMode] = useState("login"); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - - function submit(event: FormEvent) { - event.preventDefault(); - if (mode === "signup") { - void onSignup(name, email, password); - return; - } - void onLogin(email, password); - } - - return ( -
- -
-
-
-

Red Bull tracker

-

Track intake, sync across devices.

-
- -
- {setupStatus.state !== "ok" && ( -
- {setupStatus.message} -
- )} - -
- - -
- -
- {mode === "signup" && ( - - )} - - - - {authError && ( -
- {authError} -
- )} - - -
- - -
-
-
-
- ); -} - -function ThemePicker({ - themeId, - themeMode, - onChange, - onThemeModeChange, -}: { - themeId: string; - themeMode: ThemeMode; - onChange: (id: string) => void; - onThemeModeChange: (mode: ThemeMode) => void; -}) { - const activeTheme = getThemeById(themeId); - - return ( -
-
- {THEME_MODES.map((mode) => ( - - ))} -
- -
-
Button
-
Panel
-
- Chart -
-
- -
- {APP_THEMES.map((theme) => ( - - ))} -
- -

- Current theme: {activeTheme.label} -

-
- ); -} - -function Sidebar({ - activeView, - dataLoading, - notice, - setupStatus, - user, - onAdd, - onScan, - onChange, - onOpenSettings, -}: { - activeView: AppView; - dataLoading: boolean; - notice: string; - setupStatus: SetupStatus; - user: AuthUser; - onAdd: () => void; - onScan: () => void; - onChange: (view: AppView) => void; - onOpenSettings: () => void; -}) { - return ( - - ); -} - -function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (view: AppView) => void }) { - return ( - - ); -} - -function TopBar({ - activeView, - busyAction, - onAdd, - onScan, - className = "", -}: { - activeView: AppView; - busyAction: string | null; - onAdd: () => void; - onScan: () => void; - className?: string; -}) { - const activeItem = NAV_ITEMS.find((item) => item.id === activeView) ?? NAV_ITEMS[0]; - const ActiveIcon = activeItem.icon; - const title = activeItem.label; - const subtitle = new Intl.DateTimeFormat("en-GB", { - weekday: "long", - day: "numeric", - month: "long", - }).format(new Date()); - - return ( -
-
-
- - -
-

{subtitle}

-

{title}

-
-
-
- -
- - -
-
- ); -} - -function StatusRail({ - busyAction, - syncError, - setupStatus, -}: { - busyAction: string | null; - syncError: string; - setupStatus: SetupStatus; -}) { - if (!busyAction && !syncError && setupStatus.state === "ok") return null; - return ( -
- {busyAction && ( -
-
- )} - {syncError && ( -
-
- )} - {setupStatus.state === "error" && ( -
-
- )} -
- ); -} - -function OverviewView({ - summary, - entries, - insights, - quickAdds, - recentEntries, - chartData, - flavourData, - user, - userLimits, - limitCheck, - onQuickAdd, - onAdd, - onScan, - onOpenLogbook, - onOpenSettings, -}: { - summary: Dashboard; - entries: RedBullEntry[]; - insights: Insight[]; - quickAdds: typeof QUICK_ADDS; - 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: (typeof QUICK_ADDS)[number]) => void; - onAdd: () => void; - onScan: () => void; - onOpenLogbook: () => void; - onOpenSettings: () => void; -}) { - const todaySpendRaw = limitCheck.todaySpend; - const spendLimitDetail = - userLimits.dailySpendLimit != null - ? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today` - : `${summary.monthSpend} this month`; - - return ( -
- - - - - - -
- -
- - {limitCheck.violations.length ? ( -
-
-
-
- ) : null} - -
- - - - -
- -
- - {chartData.length ? ( -
- - - - - - - - - - - - } /> - - - -
- ) : ( - - )} -
- - - {recentEntries.length ? ( -
- {recentEntries.map((entry) => ( - - ))} - -
- ) : ( - - )} -
-
- -
- {insights.map((insight) => ( - - ))} -
- -
- - {flavourData.length ? ( -
- - - - {flavourData.map((entry) => ( - - ))} - - } /> - - -
- ) : ( - - )} -
-
- - -
- ); -} - -function GreetingPanel({ - summary, - user, - userLimits, - limitCheck, - onAdd, - onScan, -}: { - summary: Dashboard; - user: AuthUser; - userLimits: UserLimits; - limitCheck: LimitCheckResult; - onAdd: () => void; - onScan: () => void; -}) { - const todayNumber = Number.parseFloat(summary.todayCans) || 0; - const canLimit = userLimits.dailyCanLimit; - const name = firstName(user); - const greeting = buildDynamicGreeting({ - name, - todayCans: todayNumber, - favouriteFlavour: summary.favouriteFlavour, - currentStreak: Number.parseInt(summary.currentStreak, 10) || 0, - todayCaffeineMg: Number.parseFloat(summary.todayCaffeine.replace(/[^\d.]/g, "")) || 0, - allTimeCans: Number.parseFloat(summary.allTimeCans) || 0, - dailyCanLimit: canLimit, - limitCheck, - }); - - return ( -
- - -
{userInitial(user)}
-

{greeting.badge}

-

{name}

-

{greeting.subline}

- -
- - -
- -
- - - - -
-
- ); -} - -function statHint(label: string) { - return label === "Caffeine" || label === "Sugar" - ? "estimated from the logged can. check the label if it matters." - : undefined; -} - -function WellnessPill({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function TodayPanel({ - summary, - entries, - userLimits, - limitCheck, - onAdd, - onScan, -}: { - summary: Dashboard; - entries: RedBullEntry[]; - userLimits: UserLimits; - limitCheck: LimitCheckResult; - onAdd: () => void; - onScan: () => void; -}) { - const limitSummary = - userLimits.dailyCanLimit != null || userLimits.dailySpendLimit != null - ? limitCheck.violations.length - ? limitStatusMessage(limitCheck.violations, limitCheck, userLimits) - : `${limitCheck.todayCans} cans · ${currency.format(limitCheck.todaySpend)} spent today` - : ""; - - return ( -
-

Today

-
-
-

{summary.todayCans}

-

cans logged

- {limitSummary ?

{limitSummary}

: null} -
-
- - - -
-
-
- - - - {entries.length ? `${summary.allTimeCans} all-time cans` : "Ready for your first entry"} - -
-
- ); -} - -function QuickAddPanel({ items, onQuickAdd }: { items: typeof QUICK_ADDS; onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void }) { - return ( - -
- {items.map((item) => { - const meta = flavourMeta(item.flavour); - return ( - - ); - })} -
-
- ); -} - -function LogbookView({ - entries, - totalEntries, - filters, - flavours, - onFilterChange, - onAdd, - onEdit, - onDelete, -}: { - entries: RedBullEntry[]; - totalEntries: number; - filters: Filters; - flavours: Flavour[]; - onFilterChange: (filters: Filters) => void; - onAdd: () => void; - onEdit: (entry: RedBullEntry) => void; - onDelete: (id: string) => void; -}) { - return ( -
-
- - -
- -
- ); -} - -function TrendsView({ - chartData, - weekData, - flavourData, - insights, - entries, - filters, - flavours, - userLimits, - onFilterChange, - onSaveLimits, -}: { - 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; -}) { - return ( -
-
- - - {chartData.length ? ( - - - - - - - - - - - - - - - - } /> - - - - - ) : ( - - )} - -
- -
- - {chartData.length ? ( - - - - - - } /> - - - - ) : ( - - )} - - - - {weekData.length ? ( - - - - - - } /> - - - - - ) : ( - - )} - -
- -
- - {flavourData.length ? ( - - - - {flavourData.map((entry) => ( - - ))} - - } /> - - - ) : ( - - )} - - -
- {insights.map((insight) => ( - - ))} -
-
- -
- -
- - -
- ); -} - -function SpendForecastCard({ - entries, - userLimits, - onSaveLimits, -}: { - entries: RedBullEntry[]; - userLimits: UserLimits; - onSaveLimits?: (limits: UserLimits) => void; -}) { - const [projectionDays, setProjectionDays] = useState<7 | 30 | 90 | 365>(30); - const now = useMemo(() => new Date(), []); - - const firstEntryDate = useMemo(() => { - if (!entries.length) return now; - return new Date( - [...entries].sort( - (a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime(), - )[0].dateTime, - ); - }, [entries, now]); - - const activePeriodDays = useMemo(() => { - const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime()); - return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24))); - }, [firstEntryDate, now]); - - const stats = useMemo(() => { - const periodStart = new Date(now.getTime() - activePeriodDays * 86_400_000); - const recentEntries = entriesInRange(entries, periodStart, now); - const totalSpend = sum(recentEntries, spendFor); - const totalCans = sum(recentEntries, (entry) => entry.cans); - const hasData = recentEntries.length > 0; - return { - hasData, - avgDailySpend: hasData ? totalSpend / activePeriodDays : 0, - avgDailyCans: hasData ? totalCans / activePeriodDays : 0, - }; - }, [entries, activePeriodDays, now]); - - const projectionData = useMemo(() => { - return Array.from({ length: projectionDays }).map((_, index) => { - const day = index + 1; - const dataPoint: ForecastPoint = { - label: `day ${day}`, - current: Number((day * stats.avgDailySpend).toFixed(2)), - lower: Number((day * stats.avgDailySpend * 0.8).toFixed(2)), - }; - if (userLimits.dailySpendLimit != null) { - dataPoint.limit = Number((day * userLimits.dailySpendLimit).toFixed(2)); - } - return dataPoint; - }); - }, [projectionDays, stats.avgDailySpend, userLimits?.dailySpendLimit]); - - if (!stats.hasData) { - return ( - - - - ); - } - - const projectedSpend = stats.avgDailySpend * projectionDays; - const projectedCans = stats.avgDailyCans * projectionDays; - const lowerSpend = projectedSpend * 0.8; - const possibleSavings = projectedSpend - lowerSpend; - - const saveLowerLimit = () => { - if (!onSaveLimits) return; - const lowerDailyLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100; - const size = userLimits.limitCanSizeMl ?? 250; - onSaveLimits({ - ...userLimits, - limitCanSizeMl: size, - dailySpendLimit: lowerDailyLimit, - dailyCanLimit: canLimitFromSpend(lowerDailyLimit, size), - }); - }; - - return ( - -
-
-

Forecast window

-
- {([7, 30, 90, 365] as const).map((days) => ( - - ))} -
-
- -
-
- Projected spend -

{currency.format(projectedSpend)}

- - ~{oneDecimal.format(projectedCans)} cans logged - -
- -
- 20 percent lower -

{currency.format(lowerSpend)}

- - ~{oneDecimal.format(projectedCans * 0.8)} cans logged - -
- -
-
- Possible savings -

{currency.format(possibleSavings)}

-
- {onSaveLimits && ( - - )} -
-
- -
- - - - - - - - - - - - - - - `£${val}`} /> - } /> - - - {userLimits.dailySpendLimit != null && ( - - )} - - -
-
-
- ); -} - -function SettingsView({ - activeTheme, - summary, - dataLoading, - entries, - notice, - setupStatus, - themeId, - themeMode, - user, - userLimits, - limitCheck, - busyAction, - onExportExcel, - onImportExcel, - onExportJson, - onImportJson, - onLogout, - onReset, - onThemeChange, - onThemeModeChange, - onSaveLimits, - onRerunOnboarding, -}: { - 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; -}) { - return ( -
-
- - -
- -
-
- - - - - - -
- - - -
- -
- - - - - -
- -
-

Configured Appwrite IDs

-
- - - - -
-
- - -
-
- -
- -
-
{userInitial(user)}
-
-

{user?.name || "Appwrite user"}

-

{user?.email}

-
-
-
-
- {dataLoading ?
-

{setupStatus.message}

-
- -
-
- - -
- ); -} - -function DataPair({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function MetricTile({ - icon: Icon, - label, - value, - detail, - accent, -}: { - icon: LucideIcon; - label: string; - value: string; - detail: string; - accent: string; -}) { - return ( - -
-
-

{label}

-

{value}

-
-
-
-
-

{detail}

-
- ); -} - -function MiniMetric({ label, value, accent }: { label: string; value: string; accent: string }) { - return ( -
-

{label}

-

- {value} -

-
- ); -} - -function InsightCard({ insight }: { insight: Insight }) { - return ( -
-
-
-

{insight.value}

-

{insight.detail}

-
- ); -} - -function AppCard({ - title, - subtitle, - children, -}: { - title: string; - subtitle?: string; - children: ReactNode; -}) { - return ( -
-
-

{title}

- {subtitle &&

{subtitle}

} -
- {children} -
- ); -} - -function ChartTooltip({ - active, - payload, - label, -}: { - active?: boolean; - payload?: Array<{ name: string; value: number; color?: string }>; - label?: string; -}) { - if (!active || !payload?.length) return null; - return ( -
-

{label}

- {payload.map((item) => ( -

- {item.name}: {formatMetricValue(item.name, item.value)} -

- ))} -
- ); -} - -function EmptyState({ - title, - copy, - actionLabel, - onAction, -}: { - title: string; - copy: string; - actionLabel?: string; - onAction?: () => void; -}) { - return ( -
-
-
-

{title}

-

{copy}

- {actionLabel && onAction && ( - - )} -
- ); -} - -function FiltersPanel({ - filters, - flavours, - compact = false, - onChange, -}: { - filters: Filters; - flavours: Flavour[]; - compact?: boolean; - onChange: (filters: Filters) => void; -}) { - const set = (key: Key, value: Filters[Key]) => { - onChange({ ...filters, [key]: value }); - }; - - return ( - -
- - - - - {filters.dateRange === "custom" && ( - <> - - - - )} - - - - -
-
- ); -} - -function EntryLedger({ - entries, - totalEntries, - onAdd, - onEdit, - onDelete, -}: { - entries: RedBullEntry[]; - totalEntries: number; - onAdd: () => void; - onEdit: (entry: RedBullEntry) => void; - onDelete: (id: string) => void; -}) { - return ( - - {entries.length ? ( -
- {entries.map((entry) => ( - - ))} -
- ) : ( - - )} -
- ); -} - -function EntryRow({ - entry, - onEdit, - onDelete, -}: { - entry: RedBullEntry; - onEdit: (entry: RedBullEntry) => void; - onDelete: (id: string) => void; -}) { - return ( -
-
-
- -

{entry.flavour}

- - {entry.cans} can{entry.cans === 1 ? "" : "s"} · {entry.sizeMl}ml - - - {entry.source} - -
-

{humanDateTime(entry.dateTime)}

-

- {currency.format(spendFor(entry))} · {wholeNumber.format(caffeineFor(entry))}mg caffeine · {oneDecimal.format(sugarFor(entry))}g sugar -

- {(entry.store || entry.notes) && ( -

- {entry.store ? `${entry.store}` : ""} - {entry.store && entry.notes ? " · " : ""} - {entry.notes} -

- )} -
-
- - -
-
- ); -} - -function MiniEntry({ entry }: { entry: RedBullEntry }) { - return ( -
- -
-

{entry.flavour}

-

{humanDateTime(entry.dateTime)}

-
-

{currency.format(spendFor(entry))}

-
- ); -} - -function EntryModal({ - open, - entry, - initialDraft, - flavours, - saving, - onClose, - onSave, -}: { - open: boolean; - entry: RedBullEntry | null; - initialDraft: EntryDraft | null; - flavours: Flavour[]; - saving: boolean; - onClose: () => void; - onSave: (draft: EntryDraft) => void; -}) { - const firstFieldRef = useRef(null); - const activeDraft = entry ?? initialDraft; - const initialFlavour = activeDraft?.flavour ?? DEFAULT_FLAVOUR.name; - const [selectedFlavour, setSelectedFlavour] = useState(initialFlavour); - const [customFlavour, setCustomFlavour] = useState(""); - const [customAccent, setCustomAccent] = useState(MATERIAL_ACCENTS.custom); - const [cans, setCans] = useState(activeDraft?.cans.toString() ?? "1"); - const [sizePreset, setSizePreset] = useState(sizeToPreset(activeDraft?.sizeMl ?? 250)); - const [customSize, setCustomSize] = useState(activeDraft?.sizeMl.toString() ?? "250"); - const [pricePerCan, setPricePerCan] = useState(activeDraft?.pricePerCan.toString() ?? "1.75"); - const [dateTime, setDateTime] = useState(formatLocalInput(activeDraft ? new Date(activeDraft.dateTime) : new Date())); - const [store, setStore] = useState(activeDraft?.store ?? ""); - const [notes, setNotes] = useState(activeDraft?.notes ?? ""); - const [sugarFree, setSugarFree] = useState(activeDraft?.sugarFree ?? false); - const [caffeineOverride, setCaffeineOverride] = useState(activeDraft?.caffeineMgPerCan?.toString() ?? ""); - - useEffect(() => { - if (!open) return; - const draft = entry ?? initialDraft; - const editingCustom = draft && !BUILT_IN_FLAVOURS.some((flavour) => flavour.name === draft.flavour); - setSelectedFlavour(editingCustom ? draft.flavour : draft?.flavour ?? DEFAULT_FLAVOUR.name); - setCustomFlavour(editingCustom ? draft.flavour : ""); - setCustomAccent(draft?.flavourAccent ?? MATERIAL_ACCENTS.custom); - setCans(draft?.cans.toString() ?? "1"); - setSizePreset(sizeToPreset(draft?.sizeMl ?? 250)); - setCustomSize(draft?.sizeMl.toString() ?? "250"); - setPricePerCan(draft?.pricePerCan.toString() ?? defaultPriceForSize(250).toString()); - setDateTime(formatLocalInput(draft ? new Date(draft.dateTime) : new Date())); - setStore(draft?.store ?? ""); - setNotes(draft?.notes ?? ""); - setSugarFree(draft?.sugarFree ?? false); - setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? ""); - window.setTimeout(() => firstFieldRef.current?.focus(), 80); - }, [entry, initialDraft, open]); - - useEffect(() => { - if (!open) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [onClose, open]); - - const selectedMeta = flavourMeta(selectedFlavour); - const isOther = selectedFlavour === "Other"; - const numericSize = Math.max(1, sizePreset === "custom" ? Number(customSize) || 250 : Number(sizePreset)); - const finalAccent = isOther ? customAccent : selectedMeta.accent; - const caffeinePreview = caffeinePerCan( - numericSize, - sizePreset === "custom" && caffeineOverride.trim() ? Number(caffeineOverride) : undefined, - ); - - const draftPreview = useMemo(() => { - const numericCans = Math.max(0.25, Number(cans) || 1); - const numericPrice = Math.max(0, Number(pricePerCan) || 0); - const finalFlavour = isOther ? customFlavour.trim() || "Other" : selectedFlavour; - const meta = flavourMeta(finalFlavour); - const override = - sizePreset === "custom" && caffeineOverride.trim() - ? Math.max(0, Number(caffeineOverride) || 0) - : undefined; - - return { - cans: numericCans, - flavour: finalFlavour, - flavourAccent: isOther ? customAccent || accentForCustomFlavour(finalFlavour) : meta.accent, - sizeMl: numericSize, - pricePerCan: numericPrice, - dateTime: new Date(dateTime).toISOString(), - notes: notes.trim(), - store: store.trim(), - sugarFree: sugarFree || Boolean(meta.sugarFree), - caffeineMgPerCan: override, - source: entry?.source ?? initialDraft?.source ?? "manual", - }; - }, [ - cans, - pricePerCan, - isOther, - customFlavour, - selectedFlavour, - customAccent, - numericSize, - dateTime, - notes, - store, - sugarFree, - sizePreset, - caffeineOverride, - entry?.source, - initialDraft?.source, - ]); - - function submit(event: FormEvent) { - event.preventDefault(); - if (!draftPreview) return; - onSave(draftPreview); - } - - return ( - - {open && ( - - -
-
-

Intake details

-

- {entry ? "Edit entry" : "Add intake"} -

-
- -
- -
- - - - - - - - - {isOther && ( - <> - - - - )} - - {sizePreset === "custom" && ( - <> - - - - )} - - - - - -