Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57ba6fd8c5 | ||
|
|
7f64eec207 | ||
|
|
767967f25d | ||
|
|
9e8f3c55d6 | ||
|
|
bb5750e928 | ||
|
|
3706021326 | ||
|
|
733c6cbae8 | ||
|
|
f8d06cbafb | ||
|
|
50c729eae7 | ||
|
|
2cd8dd5130 |
@@ -15,10 +15,14 @@ personalized, and privacy-first self-tracking experience.
|
|||||||
- `dist/` – Contains static HTML, JavaScript, and CSS bundle files output by the production build process.
|
- `dist/` – Contains static HTML, JavaScript, and CSS bundle files output by the production build process.
|
||||||
- `node_modules/` – Stores third-party library dependencies and packages managed by npm.
|
- `node_modules/` – Stores third-party library dependencies and packages managed by npm.
|
||||||
- `scripts/` – Houses automation scripts, including database schema configuration tools for Appwrite.
|
- `scripts/` – Houses automation scripts, including database schema configuration tools for Appwrite.
|
||||||
|
- `public/` – PWA assets: web manifest and app icons, served at the site root.
|
||||||
- `src/` – Contains client-side React source code, components, utility models, and stylesheets.
|
- `src/` – Contains client-side React source code, components, utility models, and stylesheets.
|
||||||
- `src/components/` – Reusable UI panel elements, forms, and splash screen wrappers.
|
- `src/components/` – Feature components (modals, panels, ledger, shell pieces); `src/components/views/`
|
||||||
|
holds the four main views plus auth, and `src/components/ui/` holds the shared design primitives
|
||||||
|
(Button, Card, Field, Badge, EmptyState).
|
||||||
- `src/data/` – Static configurations, including theme lists and built-in flavours mapping.
|
- `src/data/` – Static configurations, including theme lists and built-in flavours mapping.
|
||||||
- `src/lib/` – Business logic engines for calculations, file parsers, and Appwrite client connections.
|
- `src/lib/` – Business logic engines for calculations, file parsers, Appwrite client connections,
|
||||||
|
motion constants, toast context, and the localStorage entry cache.
|
||||||
|
|
||||||
## Build & Development Commands
|
## Build & Development Commands
|
||||||
|
|
||||||
@@ -204,6 +208,10 @@ The repository does not currently feature automated test files. Testing is execu
|
|||||||
can be integrated by adding definitions to the `APP_THEMES` array in `src/data/themes.ts`.
|
can be integrated by adding definitions to the `APP_THEMES` array in `src/data/themes.ts`.
|
||||||
- **Proxy Endpoint Rerouting**: The Ollama upstream proxy route in `vite.config.ts` and
|
- **Proxy Endpoint Rerouting**: The Ollama upstream proxy route in `vite.config.ts` and
|
||||||
`api/ollama-chat.js` can be adjusted to point to alternative LLM hosts or local server instances.
|
`api/ollama-chat.js` can be adjusted to point to alternative LLM hosts or local server instances.
|
||||||
|
- **Motion Language**: Shared animation durations and the standard ease-out curve live in
|
||||||
|
`src/lib/motion.ts` (`VIEW_TRANSITION`, `MODAL_TRANSITION`, `TOAST_TRANSITION`, `TILE_TRANSITION`).
|
||||||
|
The root tree is wrapped in `MotionConfig reducedMotion="user"` (`src/main.tsx`), and `src/index.css`
|
||||||
|
carries the global `prefers-reduced-motion` neutralizer plus the shared hover/press interaction rules.
|
||||||
- **Configurable Environment Parameters**:
|
- **Configurable Environment Parameters**:
|
||||||
- `VITE_APPWRITE_ENDPOINT` – Base URL for the Appwrite API server.
|
- `VITE_APPWRITE_ENDPOINT` – Base URL for the Appwrite API server.
|
||||||
- `VITE_APPWRITE_PROJECT_ID` – The Appwrite project instance identifier.
|
- `VITE_APPWRITE_PROJECT_ID` – The Appwrite project instance identifier.
|
||||||
|
|||||||
+4
-1
@@ -5,8 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A local-first Red Bull intake web app for tracking cans, spending, caffeine, sugar, flavours, and trends."
|
content="A Red Bull intake tracker for logging cans, spending, caffeine, sugar, flavours, and trends, synced across devices with Appwrite Cloud."
|
||||||
/>
|
/>
|
||||||
|
<meta name="theme-color" content="#2563c7" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
<title>Red Bull Intake Tracker</title>
|
<title>Red Bull Intake Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "Red Bull Intake Tracker",
|
||||||
|
"short_name": "Intake",
|
||||||
|
"description": "Track Red Bull and caffeine intake — cans, spending, flavours, and trends — synced across devices.",
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#eef3fb",
|
||||||
|
"theme_color": "#2563c7",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+194
-2213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type AppCardProps = {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppCard({ title, subtitle, children }: AppCardProps) {
|
||||||
|
return (
|
||||||
|
<section className="app-card p-4 sm:p-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="app-card-title text-xl">{title}</h2>
|
||||||
|
{subtitle && <p className="app-card-subtitle mt-1">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react";
|
import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "../lib/barcodeScanner";
|
} from "../lib/barcodeScanner";
|
||||||
import { listBarcodeCatalog, upsertCloudUserBarcodeMapping } from "../lib/appwriteBarcodes";
|
import { listBarcodeCatalog, upsertCloudUserBarcodeMapping } from "../lib/appwriteBarcodes";
|
||||||
import { caffeinePerCan, currency, defaultPriceForSize, wholeNumber } from "../lib/metrics";
|
import { caffeinePerCan, currency, defaultPriceForSize, wholeNumber } from "../lib/metrics";
|
||||||
|
import { MODAL_TRANSITION } from "../lib/motion";
|
||||||
import {
|
import {
|
||||||
loadUserBarcodeMappings,
|
loadUserBarcodeMappings,
|
||||||
upsertUserBarcodeMapping,
|
upsertUserBarcodeMapping,
|
||||||
@@ -39,6 +40,7 @@ import type {
|
|||||||
UserBarcodeMapping,
|
UserBarcodeMapping,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { BarcodeProductPreview } from "./BarcodeProductPreview";
|
import { BarcodeProductPreview } from "./BarcodeProductPreview";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error";
|
type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error";
|
||||||
|
|
||||||
@@ -188,7 +190,6 @@ export function BarcodeScannerModal({
|
|||||||
setManualMessage("");
|
setManualMessage("");
|
||||||
setMappingSaving(false);
|
setMappingSaving(false);
|
||||||
applyManualDefaults();
|
applyManualDefaults();
|
||||||
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
|
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
@@ -237,15 +238,6 @@ export function BarcodeScannerModal({
|
|||||||
};
|
};
|
||||||
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
|
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return undefined;
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [onClose, open]);
|
|
||||||
|
|
||||||
function submitTypedBarcode(event: FormEvent<HTMLFormElement>) {
|
function submitTypedBarcode(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
resolveBarcodeValue(typedBarcode);
|
resolveBarcodeValue(typedBarcode);
|
||||||
@@ -307,23 +299,20 @@ export function BarcodeScannerModal({
|
|||||||
: "Scanner paused";
|
: "Scanner paused";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{open && (
|
open={open}
|
||||||
<motion.div
|
onClose={onClose}
|
||||||
|
labelledBy="barcode-scanner-title"
|
||||||
|
initialFocusRef={closeButtonRef}
|
||||||
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/70 backdrop-blur-xl"
|
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/70 backdrop-blur-xl"
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="barcode-scanner-title"
|
|
||||||
>
|
>
|
||||||
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="modal-panel max-w-4xl"
|
className="modal-panel max-w-4xl"
|
||||||
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||||
transition={{ duration: 0.22 }}
|
transition={MODAL_TRANSITION}
|
||||||
>
|
>
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -528,9 +517,8 @@ export function BarcodeScannerModal({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { currency, oneDecimal, wholeNumber } from "../lib/metrics";
|
||||||
|
|
||||||
|
type ChartTooltipProps = {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ name: string; value: number; color?: string }>;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, payload, label }: ChartTooltipProps) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="chart-tooltip">
|
||||||
|
<p className="mb-1 text-sm font-medium" style={{ color: "var(--text)" }}>{label}</p>
|
||||||
|
{payload.map((item) => (
|
||||||
|
<p key={item.name} className="text-sm" style={{ color: "var(--muted)" }}>
|
||||||
|
<span style={{ color: item.color }}>{item.name}</span>: {formatMetricValue(item.name, item.value)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetricValue(name: string, value: number) {
|
||||||
|
if (/spend/i.test(name)) return currency.format(value);
|
||||||
|
if (/caffeine/i.test(name)) return `${wholeNumber.format(value)}mg`;
|
||||||
|
if (/sugar/i.test(name)) return `${oneDecimal.format(value)}g`;
|
||||||
|
return oneDecimal.format(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import { MODAL_TRANSITION } from "../lib/motion";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
|
type ConfirmDialogProps = {
|
||||||
|
busy: boolean;
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
tone?: "danger" | "warning";
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
busy,
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
confirmLabel,
|
||||||
|
tone = "danger",
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const ConfirmIcon = tone === "warning" ? AlertTriangle : Trash2;
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onCancel} labelledBy="confirm-title">
|
||||||
|
<motion.div
|
||||||
|
className="glass-panel w-full max-w-md p-5"
|
||||||
|
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
transition={MODAL_TRANSITION}
|
||||||
|
>
|
||||||
|
<h2 id="confirm-title" className="text-2xl font-semibold tracking-tight text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-slate-400">{body}</p>
|
||||||
|
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button className="secondary-button justify-center" type="button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${tone === "warning" ? "warning-button" : "danger-button"} justify-center`}
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <ConfirmIcon size={17} aria-hidden="true" />}
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -96,7 +96,14 @@ function LimitRow({
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<strong>{value}</strong>
|
<strong>{value}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="limit-progress" aria-hidden="true">
|
<div
|
||||||
|
className="limit-progress"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={`${label}: ${value}`}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={progress}
|
||||||
|
>
|
||||||
<div className="limit-progress-fill" style={{ width: `${progress}%` }} />
|
<div className="limit-progress-fill" style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Plus, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
type EmptyStateProps = {
|
||||||
|
title: string;
|
||||||
|
copy: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyState({ title, copy, actionLabel, onAction }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">
|
||||||
|
<Zap size={22} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h3 className="empty-state-title">{title}</h3>
|
||||||
|
<p className="empty-state-copy mt-2 max-w-sm">{copy}</p>
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<button className="primary-button mt-4" type="button" onClick={onAction}>
|
||||||
|
<Plus size={17} aria-hidden="true" />
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { Edit3, Loader2, RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
|
import {
|
||||||
|
caffeineFor,
|
||||||
|
currency,
|
||||||
|
formatDateKey,
|
||||||
|
oneDecimal,
|
||||||
|
spendFor,
|
||||||
|
sugarFor,
|
||||||
|
wholeNumber,
|
||||||
|
} from "../lib/metrics";
|
||||||
|
import { AppCard } from "./AppCard";
|
||||||
|
import { EmptyState } from "./EmptyState";
|
||||||
|
import { Skeleton } from "./Skeleton";
|
||||||
|
import type { EntryDerived, Filters, RedBullEntry } from "../types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const SKELETON_ROWS = 6;
|
||||||
|
|
||||||
|
type EntryLedgerProps = {
|
||||||
|
entries: RedBullEntry[];
|
||||||
|
totalEntries: number;
|
||||||
|
entryDerived: Map<string, EntryDerived>;
|
||||||
|
filters: Filters;
|
||||||
|
loading: boolean;
|
||||||
|
deletingId: string | null;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (entry: RedBullEntry) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onLogAgain?: (entry: RedBullEntry) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DayGroup = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
cans: number;
|
||||||
|
caffeine: number;
|
||||||
|
spend: number;
|
||||||
|
entries: RedBullEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EntryLedger({
|
||||||
|
entries,
|
||||||
|
totalEntries,
|
||||||
|
entryDerived,
|
||||||
|
filters,
|
||||||
|
loading,
|
||||||
|
deletingId,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onLogAgain,
|
||||||
|
}: EntryLedgerProps) {
|
||||||
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleCount(PAGE_SIZE);
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const visibleEntries = useMemo(() => entries.slice(0, visibleCount), [entries, visibleCount]);
|
||||||
|
const dayGroups = useMemo(() => groupEntriesByDay(visibleEntries, entryDerived), [visibleEntries, entryDerived]);
|
||||||
|
const showSkeleton = loading && entries.length === 0;
|
||||||
|
const remaining = entries.length - visibleEntries.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
|
||||||
|
{showSkeleton ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{Array.from({ length: SKELETON_ROWS }, (_, index) => (
|
||||||
|
<Skeleton key={index} className="h-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : entries.length ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{dayGroups.map((group) => (
|
||||||
|
<section className="day-group" key={group.key} aria-label={group.label}>
|
||||||
|
<div className="day-group-header">
|
||||||
|
<h3 className="day-group-title">{group.label}</h3>
|
||||||
|
<p className="day-group-subtotals">
|
||||||
|
{oneDecimal.format(group.cans)} can{group.cans === 1 ? "" : "s"} · {wholeNumber.format(group.caffeine)}mg · {currency.format(group.spend)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{group.entries.map((entry) => (
|
||||||
|
<EntryRow
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
derived={derivedFor(entryDerived, entry)}
|
||||||
|
deleting={deletingId === entry.id}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onLogAgain={onLogAgain}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{remaining > 0 ? (
|
||||||
|
<button
|
||||||
|
className="list-button mt-4 w-full"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
Load {Math.min(PAGE_SIZE, remaining)} more — {remaining} remaining
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No entries found" copy="Add your first intake or clear the current filters." actionLabel="Add intake" onAction={onAdd} />
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryRow({
|
||||||
|
entry,
|
||||||
|
derived,
|
||||||
|
deleting,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onLogAgain,
|
||||||
|
}: {
|
||||||
|
entry: RedBullEntry;
|
||||||
|
derived: EntryDerived;
|
||||||
|
deleting: boolean;
|
||||||
|
onEdit: (entry: RedBullEntry) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onLogAgain?: (entry: RedBullEntry) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className="entry-row" style={{ "--accent": entry.flavourAccent } as CSSProperties}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[var(--accent)]" aria-hidden="true" />
|
||||||
|
<h3 className="entry-title">{entry.flavour}</h3>
|
||||||
|
<span className="entry-chip">
|
||||||
|
{entry.cans} can{entry.cans === 1 ? "" : "s"} · {entry.sizeMl}ml
|
||||||
|
</span>
|
||||||
|
<span className="source-badge">{sourceLabel(entry.source)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="entry-meta">
|
||||||
|
{timeLabel(entry.dateTime)}
|
||||||
|
{entry.store ? ` · ${entry.store}` : ""}
|
||||||
|
</p>
|
||||||
|
<p className="entry-summary mt-2">
|
||||||
|
{currency.format(derived.spend)} · {wholeNumber.format(derived.caffeine)}mg caffeine · {oneDecimal.format(derived.sugar)}g sugar
|
||||||
|
</p>
|
||||||
|
{entry.notes ? <p className="entry-meta mt-2 leading-6">{entry.notes}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="entry-row-actions">
|
||||||
|
{onLogAgain ? (
|
||||||
|
<button className="list-button entry-log-again" type="button" onClick={() => onLogAgain(entry)}>
|
||||||
|
<RotateCcw size={15} aria-hidden="true" />
|
||||||
|
Log again
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button className="icon-button" type="button" onClick={() => onEdit(entry)} aria-label={`Edit ${entry.flavour} entry`}>
|
||||||
|
<Edit3 size={17} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
type="button"
|
||||||
|
style={{ color: "var(--error)" }}
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => onDelete(entry.id)}
|
||||||
|
aria-label={`Delete ${entry.flavour} entry`}
|
||||||
|
>
|
||||||
|
{deleting ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Trash2 size={17} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupEntriesByDay(entries: RedBullEntry[], entryDerived: Map<string, EntryDerived>): DayGroup[] {
|
||||||
|
const groups: DayGroup[] = [];
|
||||||
|
const byKey = new Map<string, DayGroup>();
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
// Local calendar-day boundaries, matching startOfDay/formatDateKey in metrics.ts.
|
||||||
|
const key = formatDateKey(new Date(entry.dateTime));
|
||||||
|
let group = byKey.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = { key, label: dayLabel(key), cans: 0, caffeine: 0, spend: 0, entries: [] };
|
||||||
|
byKey.set(key, group);
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
const derived = derivedFor(entryDerived, entry);
|
||||||
|
group.entries.push(entry);
|
||||||
|
group.cans += entry.cans;
|
||||||
|
group.caffeine += derived.caffeine;
|
||||||
|
group.spend += derived.spend;
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivedFor(entryDerived: Map<string, EntryDerived>, entry: RedBullEntry): EntryDerived {
|
||||||
|
return entryDerived.get(entry.id) ?? { spend: spendFor(entry), caffeine: caffeineFor(entry), sugar: sugarFor(entry) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayLabel(key: string) {
|
||||||
|
const todayKey = formatDateKey(new Date());
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
if (key === todayKey) return "Today";
|
||||||
|
if (key === formatDateKey(yesterday)) return "Yesterday";
|
||||||
|
// Noon anchor keeps the parsed date on the intended local day.
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(`${key}T12:00:00`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeLabel(dateTime: string) {
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(dateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(source: RedBullEntry["source"]) {
|
||||||
|
switch (source) {
|
||||||
|
case "quick-add":
|
||||||
|
return "Quick add";
|
||||||
|
case "excel":
|
||||||
|
case "json":
|
||||||
|
return "Imported";
|
||||||
|
default:
|
||||||
|
return "Manual";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Loader2, Plus, X } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent } from "react";
|
||||||
|
import { MATERIAL_ACCENTS } from "../data/accents";
|
||||||
|
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours";
|
||||||
|
import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics";
|
||||||
|
import { MODAL_TRANSITION } from "../lib/motion";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
import { Field } from "./ui";
|
||||||
|
import type { EntryDraft, Flavour, RedBullEntry } from "../types";
|
||||||
|
|
||||||
|
type EntryModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
entry: RedBullEntry | null;
|
||||||
|
initialDraft: EntryDraft | null;
|
||||||
|
flavours: Flavour[];
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (draft: EntryDraft) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EntryModal({
|
||||||
|
open,
|
||||||
|
entry,
|
||||||
|
initialDraft,
|
||||||
|
flavours,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: EntryModalProps) {
|
||||||
|
const firstFieldRef = useRef<HTMLInputElement>(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() ?? "");
|
||||||
|
}, [entry, initialDraft, 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 parsedDate = new Date(dateTime);
|
||||||
|
const dateValid = dateTime.trim() !== "" && !Number.isNaN(parsedDate.getTime());
|
||||||
|
const dateError = dateTime.trim() !== "" && !dateValid ? "Enter a valid date and time" : 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: dateValid ? 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,
|
||||||
|
dateValid,
|
||||||
|
entry?.source,
|
||||||
|
initialDraft?.source,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!draftPreview || !dateValid) return;
|
||||||
|
onSave(draftPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} labelledBy="entry-modal-title" initialFocusRef={firstFieldRef}>
|
||||||
|
<motion.form
|
||||||
|
className="modal-panel"
|
||||||
|
onSubmit={submit}
|
||||||
|
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||||
|
transition={MODAL_TRANSITION}
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="section-kicker">Intake details</p>
|
||||||
|
<h2 id="entry-modal-title" className="app-card-title mt-1 text-3xl">
|
||||||
|
{entry ? "Edit entry" : "Add intake"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button" type="button" onClick={onClose} aria-label="Close entry modal">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<fieldset className="entry-fieldset">
|
||||||
|
<legend className="entry-fieldset-legend">What</legend>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field id="entry-cans" label="Number of cans">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input ref={firstFieldRef} {...controlProps} className="field-control" min="0.25" step="0.25" type="number" value={cans} onChange={(event) => setCans(event.target.value)} required />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field id="entry-flavour" label="Flavour">
|
||||||
|
{(controlProps) => (
|
||||||
|
<select
|
||||||
|
{...controlProps}
|
||||||
|
className="field-control"
|
||||||
|
value={selectedFlavour}
|
||||||
|
onChange={(event) => {
|
||||||
|
const flavour = event.target.value;
|
||||||
|
setSelectedFlavour(flavour);
|
||||||
|
const meta = flavourMeta(flavour);
|
||||||
|
setSugarFree(Boolean(meta.sugarFree));
|
||||||
|
if (flavour !== "Other") setCustomAccent(meta.accent);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flavours.map((flavour) => (
|
||||||
|
<option key={flavour.name} value={flavour.name}>
|
||||||
|
{flavour.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{isOther && (
|
||||||
|
<>
|
||||||
|
<Field id="entry-custom-flavour" label="Custom flavour">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" type="text" value={customFlavour} onChange={(event) => setCustomFlavour(event.target.value)} placeholder="Fig Apple, Sea Blue..." />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field id="entry-custom-accent" label="Accent colour">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control h-12 p-1" type="color" value={customAccent} onChange={(event) => setCustomAccent(event.target.value)} aria-label="Custom flavour accent colour" />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field id="entry-size" label="Can size">
|
||||||
|
{(controlProps) => (
|
||||||
|
<select
|
||||||
|
{...controlProps}
|
||||||
|
className="field-control"
|
||||||
|
value={sizePreset}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
setSizePreset(next);
|
||||||
|
if (next !== "custom") {
|
||||||
|
const size = Number(next);
|
||||||
|
setCustomSize(next);
|
||||||
|
setPricePerCan(defaultPriceForSize(size).toFixed(2));
|
||||||
|
setCaffeineOverride("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="250">250ml</option>
|
||||||
|
<option value="355">355ml</option>
|
||||||
|
<option value="473">473ml</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{sizePreset === "custom" && (
|
||||||
|
<>
|
||||||
|
<Field id="entry-custom-size" label="Custom size in ml">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field id="entry-caffeine-override" label="Caffeine override mg/can">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" min="0" step="1" type="number" value={caffeineOverride} onChange={(event) => setCaffeineOverride(event.target.value)} placeholder={wholeNumber.format(caffeinePerCan(numericSize))} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg px-3 py-3 text-sm sm:col-span-2" style={{ border: "1px solid var(--outline-variant)", background: "var(--surface-container-low)", color: "var(--muted)" }}>
|
||||||
|
Estimated caffeine per can: {wholeNumber.format(caffeinePreview)}mg
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field-label flex-row items-center gap-3 rounded-lg border px-3 py-3 sm:col-span-2" style={{ borderColor: "var(--outline-variant)", background: "var(--surface-container-lowest)" }}>
|
||||||
|
<input className="h-4 w-4 accent-cyan-300" type="checkbox" checked={sugarFree} onChange={(event) => setSugarFree(event.target.checked)} />
|
||||||
|
Count this entry as sugar-free / zero sugar
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="entry-fieldset">
|
||||||
|
<legend className="entry-fieldset-legend">When</legend>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field id="entry-datetime" label="Date and time" error={dateError}>
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" type="datetime-local" value={dateTime} onChange={(event) => setDateTime(event.target.value)} required />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="entry-fieldset">
|
||||||
|
<legend className="entry-fieldset-legend">Where</legend>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Field id="entry-store" label="Location or store">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" type="text" value={store} onChange={(event) => setStore(event.target.value)} placeholder="BP, Tesco, airport..." />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field id="entry-notes" label="Notes">
|
||||||
|
{(controlProps) => (
|
||||||
|
<textarea {...controlProps} className="field-control min-h-24 resize-y" value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Late drive, gym stop, exam fuel..." />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="entry-fieldset">
|
||||||
|
<legend className="entry-fieldset-legend">Price</legend>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field id="entry-price" label="Price per can">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="primary-button justify-center" type="submit" disabled={saving} style={{ "--accent": finalAccent } as CSSProperties}>
|
||||||
|
{saving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Plus size={17} aria-hidden="true" />}
|
||||||
|
{entry ? "Save changes" : "Log intake"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeToPreset(size: number) {
|
||||||
|
if (size === 250 || size === 355 || size === 473) return size.toString();
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Search, SlidersHorizontal, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { DEFAULT_FILTERS } from "../lib/filters";
|
||||||
|
import type { DateFilter, Filters, Flavour } from "../types";
|
||||||
|
|
||||||
|
type FiltersPanelProps = {
|
||||||
|
filters: Filters;
|
||||||
|
flavours: Flavour[];
|
||||||
|
compact?: boolean;
|
||||||
|
onChange: (filters: Filters) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FiltersPanel({ filters, flavours, compact = false, onChange }: FiltersPanelProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const title = compact ? "Chart filters" : "Filters";
|
||||||
|
|
||||||
|
const set = <Key extends keyof Filters>(key: Key, value: Filters[Key]) => {
|
||||||
|
onChange({ ...filters, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="filter-bar" aria-label={title}>
|
||||||
|
<button
|
||||||
|
className="filter-bar-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="filter-bar-controls" data-open={open}>
|
||||||
|
<label className="field-label filter-bar-field filter-bar-field--search">
|
||||||
|
Store or location
|
||||||
|
<span className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
className="field-control pl-9"
|
||||||
|
type="search"
|
||||||
|
placeholder="Tesco, Shell, corner shop..."
|
||||||
|
value={filters.store}
|
||||||
|
onChange={(event) => set("store", event.target.value)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field-label filter-bar-field">
|
||||||
|
Flavour
|
||||||
|
<select className="field-control" value={filters.flavour} onChange={(event) => set("flavour", event.target.value)}>
|
||||||
|
<option value="all">All flavours</option>
|
||||||
|
{flavours.map((flavour) => (
|
||||||
|
<option key={flavour.name} value={flavour.name}>
|
||||||
|
{flavour.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field-label filter-bar-field">
|
||||||
|
Date range
|
||||||
|
<select className="field-control" value={filters.dateRange} onChange={(event) => set("dateRange", event.target.value as DateFilter)}>
|
||||||
|
<option value="all">All time</option>
|
||||||
|
<option value="today">Today</option>
|
||||||
|
<option value="week">This week</option>
|
||||||
|
<option value="month">This month</option>
|
||||||
|
<option value="custom">Custom range</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{filters.dateRange === "custom" && (
|
||||||
|
<>
|
||||||
|
<label className="field-label filter-bar-field">
|
||||||
|
From
|
||||||
|
<input className="field-control" type="date" value={filters.from} onChange={(event) => set("from", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field-label filter-bar-field">
|
||||||
|
To
|
||||||
|
<input className="field-control" type="date" value={filters.to} onChange={(event) => set("to", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="secondary-button filter-bar-clear" type="button" onClick={() => onChange(DEFAULT_FILTERS)}>
|
||||||
|
<X size={17} aria-hidden="true" />
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Activity, CalendarDays, Camera, Plus, PoundSterling, Zap } from "lucide-react";
|
||||||
|
import { buildDynamicGreeting } from "../lib/greeting";
|
||||||
|
import { currency } from "../lib/metrics";
|
||||||
|
import { userInitial } from "../lib/userDisplay";
|
||||||
|
import { WellnessPill } from "./MetricTiles";
|
||||||
|
import type { Dashboard } from "../lib/dashboard";
|
||||||
|
import type { AuthUser, LimitCheckResult, UserLimits } from "../types";
|
||||||
|
|
||||||
|
type GreetingPanelProps = {
|
||||||
|
summary: Dashboard;
|
||||||
|
user: AuthUser;
|
||||||
|
userLimits: UserLimits;
|
||||||
|
limitCheck: LimitCheckResult;
|
||||||
|
onAdd: () => void;
|
||||||
|
onScan: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GreetingPanel({ summary, user, userLimits, limitCheck, onAdd, onScan }: GreetingPanelProps) {
|
||||||
|
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 (
|
||||||
|
<section className="home-hero">
|
||||||
|
<div className="hero-icon-row" aria-hidden="true">
|
||||||
|
<span><Zap size={22} /></span>
|
||||||
|
<span><PoundSterling size={22} /></span>
|
||||||
|
<span><CalendarDays size={22} /></span>
|
||||||
|
<span><Activity size={22} /></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-avatar">{userInitial(user)}</div>
|
||||||
|
<p className="hero-kicker">{greeting.badge}</p>
|
||||||
|
<h2 className="hero-name">{name}</h2>
|
||||||
|
<p className="hero-copy">{greeting.subline}</p>
|
||||||
|
|
||||||
|
<div className="hero-action-row">
|
||||||
|
<button className="hero-search-button" type="button" onClick={onAdd}>
|
||||||
|
<Plus size={22} aria-hidden="true" />
|
||||||
|
Add intake
|
||||||
|
</button>
|
||||||
|
<button className="hero-scan-button secondary-button" type="button" onClick={onScan}>
|
||||||
|
<Camera size={22} aria-hidden="true" />
|
||||||
|
Scan barcode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-stat-row">
|
||||||
|
<WellnessPill label="Today" value={`${summary.todayCans} cans`} />
|
||||||
|
<WellnessPill label="Caffeine" value={summary.todayCaffeine} />
|
||||||
|
<WellnessPill label="Sugar" value={summary.todaySugar} />
|
||||||
|
<WellnessPill label="Spend" value={currency.format(limitCheck.todaySpend)} />
|
||||||
|
<WellnessPill label="Streak" value={`${summary.currentStreak} days`} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstName(user: AuthUser) {
|
||||||
|
const fallback = user.email?.split("@")[0] ?? "there";
|
||||||
|
const value = (user.name || fallback).trim();
|
||||||
|
return value.split(/\s+/)[0] || "there";
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { FileSpreadsheet, Loader2, X } from "lucide-react";
|
||||||
|
import { humanDateTime } from "../lib/metrics";
|
||||||
|
import { MODAL_TRANSITION } from "../lib/motion";
|
||||||
|
import { MiniMetric } from "./MetricTiles";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
import type { ImportPreview, ImportPreviewRow, ImportProgress } from "../types";
|
||||||
|
|
||||||
|
type ImportPreviewModalProps = {
|
||||||
|
busy: boolean;
|
||||||
|
preview: ImportPreview | null;
|
||||||
|
importProgress: ImportProgress | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImportPreviewModal({
|
||||||
|
busy,
|
||||||
|
preview,
|
||||||
|
importProgress,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: ImportPreviewModalProps) {
|
||||||
|
const validRows = preview?.rows.filter((row) => row.entry && !row.errors.length && !row.duplicate) ?? [];
|
||||||
|
const invalidRows = preview?.rows.filter((row) => row.errors.length) ?? [];
|
||||||
|
const duplicateRows = preview?.rows.filter((row) => row.duplicate) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={Boolean(preview)} onClose={onClose} labelledBy="import-preview-title">
|
||||||
|
{preview && (
|
||||||
|
<motion.div
|
||||||
|
className="modal-panel max-w-5xl"
|
||||||
|
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||||
|
transition={MODAL_TRANSITION}
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium uppercase tracking-[0.18em] text-pink-100">
|
||||||
|
{preview.fileName.toLowerCase().endsWith(".json") ? "JSON import" : "Excel import"}
|
||||||
|
</p>
|
||||||
|
<h2 id="import-preview-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
|
||||||
|
Preview rows
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">{preview.fileName}</p>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button" type="button" onClick={onClose} aria-label="Close import preview">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<MiniMetric label="Ready" value={`${validRows.length}`} accent="#39d5ff" />
|
||||||
|
<MiniMetric label="Duplicates" value={`${duplicateRows.length}`} accent="#ffd84d" />
|
||||||
|
<MiniMetric label="Invalid" value={`${invalidRows.length}`} accent="#ff3448" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden max-h-[48vh] overflow-auto rounded-lg border border-white/10 sm:block">
|
||||||
|
<table className="w-full min-w-[760px] border-collapse text-left text-sm">
|
||||||
|
<thead className="sticky top-0 bg-[#0d142c] text-xs uppercase tracking-[0.14em] text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-3">Row</th>
|
||||||
|
<th className="px-3 py-3">Status</th>
|
||||||
|
<th className="px-3 py-3">Date</th>
|
||||||
|
<th className="px-3 py-3">Flavour</th>
|
||||||
|
<th className="px-3 py-3">Size</th>
|
||||||
|
<th className="px-3 py-3">Cans</th>
|
||||||
|
<th className="px-3 py-3">Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.rows.map((row) => (
|
||||||
|
<tr key={row.rowNumber} className="border-t border-white/10">
|
||||||
|
<td className="px-3 py-3 text-slate-400">{row.rowNumber}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<ImportStatusBadge invalid={row.errors.length > 0} duplicate={row.duplicate} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-slate-300">{row.entry ? humanDateTime(row.entry.dateTime) : "-"}</td>
|
||||||
|
<td className="px-3 py-3 text-white">{row.entry?.flavour ?? "-"}</td>
|
||||||
|
<td className="px-3 py-3 text-slate-300">{row.entry ? `${row.entry.sizeMl}ml` : "-"}</td>
|
||||||
|
<td className="px-3 py-3 text-slate-300">{row.entry?.cans ?? "-"}</td>
|
||||||
|
<td className="px-3 py-3 text-slate-400">{rowMessage(row)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid max-h-[48vh] gap-2 overflow-auto sm:hidden">
|
||||||
|
{preview.rows.map((row) => (
|
||||||
|
<article key={row.rowNumber} className="rounded-lg border border-white/10 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-slate-400">Row {row.rowNumber}</span>
|
||||||
|
<ImportStatusBadge invalid={row.errors.length > 0} duplicate={row.duplicate} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-white">{row.entry?.flavour ?? "No entry parsed"}</p>
|
||||||
|
{row.entry ? (
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
{humanDateTime(row.entry.dateTime)} · {row.entry.sizeMl}ml · {row.entry.cans} can{row.entry.cans === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-1 text-xs text-slate-400">{rowMessage(row)}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
{busy && importProgress ? (
|
||||||
|
<p className="text-center text-sm text-slate-400 sm:mr-auto" role="status" aria-live="polite">
|
||||||
|
Importing {importProgress.done} of {importProgress.total}…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="excel-button justify-center" type="button" disabled={!validRows.length || busy} onClick={onConfirm}>
|
||||||
|
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <FileSpreadsheet size={17} aria-hidden="true" />}
|
||||||
|
Import {validRows.length} row{validRows.length === 1 ? "" : "s"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportStatusBadge({ invalid, duplicate }: { invalid: boolean; duplicate: boolean }) {
|
||||||
|
return (
|
||||||
|
<span className={`rounded px-2 py-1 text-xs font-semibold ${invalid ? "bg-red-500/15 text-red-100" : duplicate ? "bg-amber-300/15 text-amber-100" : "bg-cyan-300/15 text-cyan-100"}`}>
|
||||||
|
{invalid ? "Invalid" : duplicate ? "Duplicate" : "Ready"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMessage(row: ImportPreviewRow) {
|
||||||
|
return row.errors.join(" ") || row.duplicateReason || "Looks good.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Gauge } from "lucide-react";
|
||||||
|
import type { Insight } from "../lib/dashboard";
|
||||||
|
|
||||||
|
export function InsightCard({ insight }: { insight: Insight }) {
|
||||||
|
return (
|
||||||
|
<article className="glass-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2" style={{ color: "var(--primary, #2563c7)" }}>
|
||||||
|
<Gauge size={17} aria-hidden="true" />
|
||||||
|
<p className="insight-card-label">{insight.label}</p>
|
||||||
|
</div>
|
||||||
|
<p className="insight-card-value text-lg">{insight.value}</p>
|
||||||
|
<p className="insight-card-detail mt-2">{insight.detail}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -115,9 +115,9 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
onClick={() => handleCanSizeChange(size)}
|
onClick={() => handleCanSizeChange(size)}
|
||||||
className="rounded-full border px-4 py-2 text-sm transition"
|
className="rounded-full border px-4 py-2 text-sm transition"
|
||||||
style={{
|
style={{
|
||||||
borderColor: isActive ? "var(--primary, #2563eb)" : "#cbd5e1",
|
borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
|
||||||
background: isActive ? "#eff6ff" : "white",
|
background: isActive ? "var(--primary-container)" : "var(--surface-container-lowest)",
|
||||||
color: isActive ? "#1d4ed8" : "#475569",
|
color: isActive ? "var(--on-primary-container)" : "var(--muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{size}ml ({currency.format(priceForLimitSize(size))})
|
{size}ml ({currency.format(priceForLimitSize(size))})
|
||||||
@@ -142,7 +142,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
value={canInput}
|
value={canInput}
|
||||||
onChange={(event) => handleCanInputChange(event.target.value)}
|
onChange={(event) => handleCanInputChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-slate-500">Leave empty to remove. Counts use BST calendar days.</span>
|
<span className="text-xs text-slate-500">Leave empty to remove. Counts use device-local calendar days.</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm">
|
<label className="grid gap-2 text-sm">
|
||||||
@@ -170,11 +170,18 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
value={stopInput}
|
value={stopInput}
|
||||||
onChange={(event) => setStopInput(event.target.value)}
|
onChange={(event) => setStopInput(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-slate-500">Europe/London (BST/GMT). Leave empty to remove.</span>
|
<span className="text-xs text-slate-500">Your device's local time. Leave empty to remove.</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{previewParts.length ? (
|
{previewParts.length ? (
|
||||||
<p className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
|
<p
|
||||||
|
className="rounded-lg border px-3 py-2 text-sm"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--outline-variant)",
|
||||||
|
background: "var(--surface-container-low)",
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Today so far: {previewParts.join(" · ")}
|
Today so far: {previewParts.join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { ShellBackdrop } from "./ShellBackdrop";
|
||||||
|
import type { SetupStatus } from "../types";
|
||||||
|
|
||||||
|
type LoadingScreenProps = {
|
||||||
|
setupStatus: SetupStatus;
|
||||||
|
shellStyle: CSSProperties;
|
||||||
|
themeId: string;
|
||||||
|
resolvedMode: "light" | "dark";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingScreen({ setupStatus, shellStyle, themeId, resolvedMode }: LoadingScreenProps) {
|
||||||
|
return (
|
||||||
|
<div className="app-shell min-h-screen" data-theme={themeId} data-mode={resolvedMode} style={shellStyle}>
|
||||||
|
<ShellBackdrop />
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6">
|
||||||
|
<div className="glass-panel w-full max-w-md p-6 text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-lg metric-tile-icon">
|
||||||
|
<Loader2 className="animate-spin" size={24} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h1 className="app-card-title mt-5 text-2xl">Red Bull tracker</h1>
|
||||||
|
<p className="app-card-subtitle mt-3 leading-6">{setupStatus.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { TILE_TRANSITION } from "../lib/motion";
|
||||||
|
import { StatHint } from "./StatHint";
|
||||||
|
import { Card } from "./ui";
|
||||||
|
|
||||||
|
type MetricTileProps = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
accent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MetricTile({ icon: Icon, label, value, detail, accent }: MetricTileProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={TILE_TRANSITION}
|
||||||
|
>
|
||||||
|
<Card className="metric-tile">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="metric-tile-label">{label}</p>
|
||||||
|
<p className="metric-tile-value break-words">{value}</p>
|
||||||
|
</div>
|
||||||
|
<div className="metric-tile-icon" style={{ color: accent }}>
|
||||||
|
<Icon size={20} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="metric-tile-detail mt-4">{detail}</p>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiniMetric({ label, value, accent }: { label: string; value: string; accent: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mini-metric-card metric-soft">
|
||||||
|
<p className="mini-metric-label">
|
||||||
|
{label}
|
||||||
|
<StatHint label={label} hint={statHint(label)} />
|
||||||
|
</p>
|
||||||
|
<p className="mini-metric-value truncate" style={{ color: accent }}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WellnessPill({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="wellness-pill">
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
<StatHint label={label} hint={statHint(label)} />
|
||||||
|
</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statHint(label: string) {
|
||||||
|
return label === "Caffeine" || label === "Sugar"
|
||||||
|
? "estimated from the logged can. check the label if it matters."
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NAV_ITEMS } from "./navItems";
|
||||||
|
import type { AppView } from "../types";
|
||||||
|
|
||||||
|
export function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (view: AppView) => void }) {
|
||||||
|
return (
|
||||||
|
<nav className="mobile-nav-bar" aria-label="Main navigation">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`mobile-nav-item ${activeView === item.id ? "mobile-nav-item-active" : ""}`}
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
aria-current={activeView === item.id ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<item.icon size={20} aria-hidden="true" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||||
|
import { MODAL_TRANSITION } from "../lib/motion";
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
const DEFAULT_BACKDROP_CLASS =
|
||||||
|
"modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl";
|
||||||
|
|
||||||
|
// Tracks open modals in stacking order so only the topmost one answers
|
||||||
|
// keyboard events when dialogs are layered (e.g. limit confirm over EntryModal).
|
||||||
|
const openModalIds: number[] = [];
|
||||||
|
let modalIdCounter = 0;
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
labelledBy?: string;
|
||||||
|
label?: string;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
initialFocusRef?: RefObject<HTMLElement>;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
labelledBy,
|
||||||
|
label,
|
||||||
|
closeOnEscape = true,
|
||||||
|
closeOnBackdrop = false,
|
||||||
|
initialFocusRef,
|
||||||
|
className = DEFAULT_BACKDROP_CLASS,
|
||||||
|
children,
|
||||||
|
}: ModalProps) {
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
const modalIdRef = useRef<number | null>(null);
|
||||||
|
if (modalIdRef.current === null) modalIdRef.current = ++modalIdCounter;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
|
||||||
|
const modalId = modalIdRef.current as number;
|
||||||
|
openModalIds.push(modalId);
|
||||||
|
const isTopmost = () => openModalIds[openModalIds.length - 1] === modalId;
|
||||||
|
const backdrop = backdropRef.current;
|
||||||
|
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
const focusTimer = window.setTimeout(() => {
|
||||||
|
if (!isTopmost()) return;
|
||||||
|
if (!backdrop) return;
|
||||||
|
const target = initialFocusRef?.current ?? backdrop.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||||
|
if (target) target.focus();
|
||||||
|
else backdrop.focus();
|
||||||
|
}, 80);
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!isTopmost()) return;
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (closeOnEscape) onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
if (!backdrop) return;
|
||||||
|
const focusable = Array.from(backdrop.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||||
|
if (!focusable.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (active === first || !backdrop.contains(active)) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else if (active === last || !backdrop.contains(active)) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
const stackIndex = openModalIds.indexOf(modalId);
|
||||||
|
if (stackIndex !== -1) openModalIds.splice(stackIndex, 1);
|
||||||
|
window.clearTimeout(focusTimer);
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (
|
||||||
|
previouslyFocused &&
|
||||||
|
(!active || active === document.body || (backdrop?.contains(active) ?? false))
|
||||||
|
) {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [open, closeOnEscape, initialFocusRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
ref={backdropRef}
|
||||||
|
className={className}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={MODAL_TRANSITION}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
aria-label={label}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (closeOnBackdrop && event.target === event.currentTarget) onCloseRef.current();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { BuiltInSize, UserLimits } from "../types";
|
import type { BuiltInSize, UserLimits } from "../types";
|
||||||
|
|
||||||
type OnboardingScreenProps = {
|
type OnboardingScreenProps = {
|
||||||
onSave: (limits: UserLimits, themeId: string) => Promise<void>;
|
onSave: (limits: UserLimits, themeId: string) => Promise<boolean>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
activeThemeId: string;
|
activeThemeId: string;
|
||||||
onThemeChange: (themeId: string) => void;
|
onThemeChange: (themeId: string) => void;
|
||||||
@@ -44,6 +44,7 @@ export function OnboardingScreen({
|
|||||||
);
|
);
|
||||||
const [stopTime, setStopTime] = useState<string | "none">(initialLimits?.stopTime ?? "18:00");
|
const [stopTime, setStopTime] = useState<string | "none">(initialLimits?.stopTime ?? "18:00");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
const activeTheme = useMemo(() => {
|
const activeTheme = useMemo(() => {
|
||||||
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
||||||
}, [activeThemeId]);
|
}, [activeThemeId]);
|
||||||
@@ -56,6 +57,7 @@ export function OnboardingScreen({
|
|||||||
|
|
||||||
async function handleFinish() {
|
async function handleFinish() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setSaveError("");
|
||||||
try {
|
try {
|
||||||
const limits: UserLimits = {};
|
const limits: UserLimits = {};
|
||||||
if (dailyCanLimit !== "none") {
|
if (dailyCanLimit !== "none") {
|
||||||
@@ -65,10 +67,14 @@ export function OnboardingScreen({
|
|||||||
}
|
}
|
||||||
if (stopTime !== "none") limits.stopTime = stopTime;
|
if (stopTime !== "none") limits.stopTime = stopTime;
|
||||||
|
|
||||||
await onSave(limits, activeThemeId);
|
const saved = await onSave(limits, activeThemeId);
|
||||||
|
if (saved) {
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error("setup save failed", err);
|
setSaveError("Couldn't save your setup — check your connection and try again.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSaveError("Couldn't save your setup — check your connection and try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -404,6 +410,13 @@ export function OnboardingScreen({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<p className="max-w-xl text-sm font-normal" style={{ color: "var(--error)" }} role="alert">
|
||||||
|
{saveError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleFinish()}
|
onClick={() => void handleFinish()}
|
||||||
@@ -414,6 +427,16 @@ export function OnboardingScreen({
|
|||||||
{saving ? "Saving..." : "Start tracking"}
|
{saving ? "Saving..." : "Start tracking"}
|
||||||
{!saving && <ArrowRight size={16} />}
|
{!saving && <ArrowRight size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex min-h-12 w-fit items-center rounded-full border px-6 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
style={{ borderColor: "var(--outline-variant)", color: "var(--muted)" }}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Zap } from "lucide-react";
|
||||||
|
import { flavourMeta } from "../data/flavours";
|
||||||
|
import { currency } from "../lib/metrics";
|
||||||
|
import { AppCard } from "./AppCard";
|
||||||
|
import type { QuickAddItem } from "../data/quickAdds";
|
||||||
|
|
||||||
|
type QuickAddPanelProps = {
|
||||||
|
items: QuickAddItem[];
|
||||||
|
onQuickAdd: (item: QuickAddItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuickAddPanel({ items, onQuickAdd }: QuickAddPanelProps) {
|
||||||
|
return (
|
||||||
|
<AppCard title="Quick add" subtitle="One tap entries">
|
||||||
|
<div className="quick-add-grid grid gap-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const meta = flavourMeta(item.flavour);
|
||||||
|
return (
|
||||||
|
<button key={item.label} className="quick-add-button" type="button" onClick={() => onQuickAdd(item)}>
|
||||||
|
<span className="quick-add-icon">
|
||||||
|
<Zap size={17} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block font-medium">{item.label}</span>
|
||||||
|
<span className="quick-add-meta">
|
||||||
|
{item.sizeMl}ml · {item.flavour}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium" style={{ color: meta.accent }}>
|
||||||
|
{currency.format(item.pricePerCan)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function ShellBackdrop() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="backdrop-wash pointer-events-none fixed inset-0 -z-10" />
|
||||||
|
<div className="backdrop-grid pointer-events-none fixed inset-0 -z-10" />
|
||||||
|
<div className="backdrop-rail pointer-events-none fixed inset-x-0 top-0 -z-10 h-1" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Camera, Cloud, Command, Loader2, Plus, User } from "lucide-react";
|
||||||
|
import { LegalFootnote } from "./LegalFootnote";
|
||||||
|
import { NAV_ITEMS } from "./navItems";
|
||||||
|
import type { AppView, AuthUser, SetupStatus } from "../types";
|
||||||
|
|
||||||
|
type SidebarProps = {
|
||||||
|
activeView: AppView;
|
||||||
|
dataLoading: boolean;
|
||||||
|
setupStatus: SetupStatus;
|
||||||
|
user: AuthUser;
|
||||||
|
onAdd: () => void;
|
||||||
|
onScan: () => void;
|
||||||
|
onChange: (view: AppView) => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
activeView,
|
||||||
|
dataLoading,
|
||||||
|
setupStatus,
|
||||||
|
user,
|
||||||
|
onAdd,
|
||||||
|
onScan,
|
||||||
|
onChange,
|
||||||
|
onOpenSettings,
|
||||||
|
}: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="material-drawer glass-panel">
|
||||||
|
<div className="mb-7 flex items-center gap-3 px-2 pt-1">
|
||||||
|
<div className="can-emblem">
|
||||||
|
<Command size={22} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-lg font-medium text-slate-950">Red Bull</p>
|
||||||
|
<p className="truncate text-sm text-slate-600">Intake tracker</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="drawer-primary-action" type="button" onClick={onAdd}>
|
||||||
|
<Plus size={19} aria-hidden="true" />
|
||||||
|
Add intake
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="secondary-button w-full justify-center" type="button" onClick={onScan}>
|
||||||
|
<Camera size={18} aria-hidden="true" />
|
||||||
|
Scan barcode
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav className="drawer-nav" aria-label="Main navigation">
|
||||||
|
{NAV_ITEMS.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`nav-item ${activeView === item.id ? "nav-item-active" : ""}`}
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
>
|
||||||
|
<span className={`nav-icon-dot nav-icon-dot-${index}`} aria-hidden="true">
|
||||||
|
<item.icon size={21} />
|
||||||
|
</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<LegalFootnote className="mb-3 px-1" />
|
||||||
|
|
||||||
|
<div className="drawer-footer">
|
||||||
|
<div className="drawer-info-card">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
|
||||||
|
{dataLoading ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Cloud size={15} aria-hidden="true" />}
|
||||||
|
Sync
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs ${setupStatus.state === "ok" ? "text-emerald-700" : "text-amber-700"}`}>{setupStatus.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="account-pill" type="button" onClick={onOpenSettings}>
|
||||||
|
<User size={16} aria-hidden="true" />
|
||||||
|
{user.name || user.email || "Account & settings"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type SkeletonProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Skeleton({ className = "" }: SkeletonProps) {
|
||||||
|
return <div className={className ? `skeleton ${className}` : "skeleton"} aria-hidden="true" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { canLimitFromSpend, currency, entriesInRange, oneDecimal, spendFor, sum } from "../lib/metrics";
|
||||||
|
import { AppCard } from "./AppCard";
|
||||||
|
import { ChartTooltip } from "./ChartTooltip";
|
||||||
|
import { EmptyState } from "./EmptyState";
|
||||||
|
import type { RedBullEntry, UserLimits } from "../types";
|
||||||
|
|
||||||
|
type ForecastPoint = {
|
||||||
|
label: string;
|
||||||
|
current: number;
|
||||||
|
lower: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORECAST_WINDOWS = [7, 30, 90, 365] as const;
|
||||||
|
|
||||||
|
type SpendForecastCardProps = {
|
||||||
|
entries: RedBullEntry[];
|
||||||
|
userLimits: UserLimits;
|
||||||
|
onSaveLimits?: (limits: UserLimits) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendForecastCardProps) {
|
||||||
|
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<ForecastPoint[]>(() => {
|
||||||
|
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 (
|
||||||
|
<AppCard title="Spend forecast" subtitle="Based on past spending">
|
||||||
|
<EmptyState title="No spend forecast yet" copy="Add an intake first." />
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWindowKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const keys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
|
||||||
|
if (!keys.includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1;
|
||||||
|
const currentIndex = FORECAST_WINDOWS.indexOf(projectionDays);
|
||||||
|
const next = FORECAST_WINDOWS[(currentIndex + direction + FORECAST_WINDOWS.length) % FORECAST_WINDOWS.length];
|
||||||
|
setProjectionDays(next);
|
||||||
|
const buttons = event.currentTarget.querySelectorAll<HTMLButtonElement>("button");
|
||||||
|
buttons[FORECAST_WINDOWS.indexOf(next)]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const forecastChartLabel = `Projected spend over the next ${projectionDays} days: ${currency.format(projectedSpend)} at the current daily average of ${currency.format(stats.avgDailySpend)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppCard
|
||||||
|
title="Spend forecast"
|
||||||
|
subtitle={`${activePeriodDays} day average: ${currency.format(stats.avgDailySpend)} per day`}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 border-b pb-4 sm:flex-row sm:items-center sm:justify-between" style={{ borderColor: "var(--outline-variant)" }}>
|
||||||
|
<p className="app-card-subtitle">Forecast window</p>
|
||||||
|
<div
|
||||||
|
className="segmented-control max-w-xs self-start"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Forecast window"
|
||||||
|
onKeyDown={onWindowKeyDown}
|
||||||
|
>
|
||||||
|
{FORECAST_WINDOWS.map((days) => (
|
||||||
|
<button
|
||||||
|
key={days}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={projectionDays === days}
|
||||||
|
tabIndex={projectionDays === days ? 0 : -1}
|
||||||
|
onClick={() => setProjectionDays(days)}
|
||||||
|
className={projectionDays === days ? "segmented-control-active" : ""}
|
||||||
|
>
|
||||||
|
{days === 365 ? "1 year" : `${days} days`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="forecast-stat space-y-1">
|
||||||
|
<span className="forecast-stat-label">Projected spend</span>
|
||||||
|
<p className="forecast-stat-value">{currency.format(projectedSpend)}</p>
|
||||||
|
<span className="forecast-stat-note">
|
||||||
|
~{oneDecimal.format(projectedCans)} cans logged
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="forecast-stat forecast-stat--positive space-y-1">
|
||||||
|
<span className="forecast-stat-label">20 percent lower</span>
|
||||||
|
<p className="forecast-stat-value">{currency.format(lowerSpend)}</p>
|
||||||
|
<span className="forecast-stat-note">
|
||||||
|
~{oneDecimal.format(projectedCans * 0.8)} cans logged
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="forecast-stat forecast-stat--positive flex flex-col justify-between space-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="forecast-stat-label">Possible savings</span>
|
||||||
|
<p className="forecast-stat-value">{currency.format(possibleSavings)}</p>
|
||||||
|
</div>
|
||||||
|
{onSaveLimits && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveLowerLimit}
|
||||||
|
className="forecast-stat-note mt-1 block text-left underline"
|
||||||
|
style={{ color: "var(--success)" }}
|
||||||
|
>
|
||||||
|
Lock daily limit to {currency.format(stats.avgDailySpend * 0.8)}/day
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="forecast-chart-wrap relative" role="img" aria-label={forecastChartLabel}>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<AreaChart data={projectionData} margin={{ top: 12, right: 16, bottom: 0, left: -10 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="currentProj" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--primary)" stopOpacity={0.2} />
|
||||||
|
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0.0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="optimalProj" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--success)" stopOpacity={0.15} />
|
||||||
|
<stop offset="100%" stopColor="var(--success)" stopOpacity={0.0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" stroke="var(--subtle)" tickLine={false} axisLine={false} interval="preserveStartEnd" minTickGap={24} />
|
||||||
|
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} tickFormatter={(val) => `£${val}`} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="current"
|
||||||
|
name="current"
|
||||||
|
stroke="var(--primary)"
|
||||||
|
fill="url(#currentProj)"
|
||||||
|
strokeWidth={3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="lower"
|
||||||
|
name="20 percent lower"
|
||||||
|
stroke="var(--success)"
|
||||||
|
fill="url(#optimalProj)"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
/>
|
||||||
|
{userLimits.dailySpendLimit != null && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="limit"
|
||||||
|
name="daily limit"
|
||||||
|
stroke="var(--warning)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Info } from "lucide-react";
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type StatHintProps = {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatHint({ label, hint }: StatHintProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const tooltipId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!hint) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="stat-hint" ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stat-hint-trigger"
|
||||||
|
aria-label={`More about ${label}`}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-describedby={open ? tooltipId : undefined}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
<Info size={12} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<span className="stat-hint-popover" role="tooltip" id={tooltipId}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
|
import type { SetupStatus } from "../types";
|
||||||
|
|
||||||
|
type StatusRailProps = {
|
||||||
|
busyAction: string | null;
|
||||||
|
setupStatus: SetupStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusRail({ busyAction, setupStatus }: StatusRailProps) {
|
||||||
|
if (!busyAction && setupStatus.state === "ok") return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
{busyAction && (
|
||||||
|
<div className="status-card">
|
||||||
|
<Loader2 className="animate-spin" size={17} aria-hidden="true" />
|
||||||
|
Working on {actionLabel(busyAction)}...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{setupStatus.state === "error" && (
|
||||||
|
<div className="status-card status-card--warning">
|
||||||
|
<AlertTriangle size={17} aria-hidden="true" />
|
||||||
|
{setupStatus.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/^quick-/, "quick add ")
|
||||||
|
.replace(/-/g, " ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { APP_THEMES, getThemeById, type ThemeMode } from "../data/themes";
|
||||||
|
|
||||||
|
const THEME_MODES: Array<{ id: ThemeMode; label: string }> = [
|
||||||
|
{ id: "light", label: "Light" },
|
||||||
|
{ id: "dark", label: "Dark" },
|
||||||
|
{ id: "system", label: "System" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ThemePickerProps = {
|
||||||
|
themeId: string;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
onThemeModeChange: (mode: ThemeMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemePicker({ themeId, themeMode, onChange, onThemeModeChange }: ThemePickerProps) {
|
||||||
|
const activeTheme = getThemeById(themeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<div
|
||||||
|
className="segmented-control max-w-xs"
|
||||||
|
role="group"
|
||||||
|
aria-label="Colour mode"
|
||||||
|
style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}
|
||||||
|
>
|
||||||
|
{THEME_MODES.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={themeMode === mode.id}
|
||||||
|
className={themeMode === mode.id ? "segmented-control-active" : ""}
|
||||||
|
onClick={() => onThemeModeChange(mode.id)}
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="theme-preview-strip">
|
||||||
|
<div className="theme-preview-chip primary-button px-4 py-2 text-sm">Button</div>
|
||||||
|
<div className="theme-preview-chip glass-panel px-4 py-2 text-sm">Panel</div>
|
||||||
|
<div className="theme-preview-chip rounded-lg px-4 py-2 text-sm" style={{ background: "var(--chart-secondary)", color: "var(--on-secondary)" }}>
|
||||||
|
Chart
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="theme-picker-grid" role="group" aria-label="App themes">
|
||||||
|
{APP_THEMES.map((theme) => (
|
||||||
|
<button
|
||||||
|
key={theme.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={themeId === theme.id}
|
||||||
|
className={`theme-tile ${themeId === theme.id ? "theme-tile-active" : ""}`}
|
||||||
|
onClick={() => onChange(theme.id)}
|
||||||
|
>
|
||||||
|
<span className="theme-tile-swatch" style={{ background: theme.swatch }} aria-hidden="true" />
|
||||||
|
<span className="theme-tile-label">{theme.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 text-sm text-slate-500">
|
||||||
|
Current theme: <span className="font-semibold text-slate-900">{activeTheme.label}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { TOAST_TRANSITION } from "../lib/motion";
|
||||||
|
import { ToastContext, type ToastInput, type ToastTone } from "../lib/toasts";
|
||||||
|
|
||||||
|
const TOAST_DURATION_MS = 4500;
|
||||||
|
const MAX_TOASTS = 4;
|
||||||
|
// Identical tone+message repeats inside this window are swallowed so
|
||||||
|
// StrictMode double effects and rapid state re-sets never double-fire.
|
||||||
|
const DEDUPE_WINDOW_MS = 1200;
|
||||||
|
|
||||||
|
type Toast = ToastInput & { id: number };
|
||||||
|
|
||||||
|
const TONE_ICONS: Record<ToastTone, typeof Info> = {
|
||||||
|
success: CheckCircle2,
|
||||||
|
error: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
const nextIdRef = useRef(0);
|
||||||
|
const lastToastRef = useRef<(ToastInput & { at: number }) | null>(null);
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id: number) => {
|
||||||
|
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pushToast = useCallback(
|
||||||
|
({ tone, message }: ToastInput) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = lastToastRef.current;
|
||||||
|
if (last && last.tone === tone && last.message === message && now - last.at < DEDUPE_WINDOW_MS) return;
|
||||||
|
lastToastRef.current = { tone, message, at: now };
|
||||||
|
const id = ++nextIdRef.current;
|
||||||
|
setToasts((current) => [...current, { id, tone, message }].slice(-MAX_TOASTS));
|
||||||
|
window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS);
|
||||||
|
},
|
||||||
|
[dismissToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ pushToast }), [pushToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-stack" role="status" aria-live="polite">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{toasts.map((toast) => {
|
||||||
|
const ToneIcon = TONE_ICONS[toast.tone];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={toast.id}
|
||||||
|
className={`toast toast--${toast.tone}`}
|
||||||
|
initial={{ opacity: 0, y: -12, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||||
|
transition={TOAST_TRANSITION}
|
||||||
|
>
|
||||||
|
<ToneIcon className="toast-icon" size={18} aria-hidden="true" />
|
||||||
|
<p className="toast-message">{toast.message}</p>
|
||||||
|
<button
|
||||||
|
className="toast-dismiss"
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismissToast(toast.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Camera, Plus } from "lucide-react";
|
||||||
|
import { MATERIAL_ACCENTS } from "../data/accents";
|
||||||
|
import { currency } from "../lib/metrics";
|
||||||
|
import { limitStatusMessage } from "../lib/userLimits";
|
||||||
|
import { MiniMetric } from "./MetricTiles";
|
||||||
|
import type { Dashboard } from "../lib/dashboard";
|
||||||
|
import type { LimitCheckResult, RedBullEntry, UserLimits } from "../types";
|
||||||
|
|
||||||
|
type TodayPanelProps = {
|
||||||
|
summary: Dashboard;
|
||||||
|
entries: RedBullEntry[];
|
||||||
|
userLimits: UserLimits;
|
||||||
|
limitCheck: LimitCheckResult;
|
||||||
|
onAdd: () => void;
|
||||||
|
onScan: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TodayPanel({ summary, entries, userLimits, limitCheck, onAdd, onScan }: TodayPanelProps) {
|
||||||
|
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 (
|
||||||
|
<section className="can-panel today-panel relative overflow-hidden p-5 sm:p-7">
|
||||||
|
<p className="section-kicker">Today</p>
|
||||||
|
<div className="mt-3 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="today-stat-value">{summary.todayCans}</p>
|
||||||
|
<p className="today-stat-label mt-2">cans logged</p>
|
||||||
|
{limitSummary ? <p className="today-limit-summary mt-2">{limitSummary}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="today-panel-metrics lg:min-w-[420px]">
|
||||||
|
<MiniMetric label="Caffeine" value={summary.todayCaffeine} accent={MATERIAL_ACCENTS.primary} />
|
||||||
|
<MiniMetric label="Sugar" value={summary.todaySugar} accent={MATERIAL_ACCENTS.secondary} />
|
||||||
|
<MiniMetric label="Streak" value={summary.currentStreak} accent={MATERIAL_ACCENTS.tertiary} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="today-action-row mt-6 flex flex-wrap items-center gap-2">
|
||||||
|
<button className="primary-button" type="button" onClick={onAdd}>
|
||||||
|
<Plus size={18} aria-hidden="true" />
|
||||||
|
Add intake
|
||||||
|
</button>
|
||||||
|
<button className="secondary-button" type="button" onClick={onScan}>
|
||||||
|
<Camera size={18} aria-hidden="true" />
|
||||||
|
Scan barcode
|
||||||
|
</button>
|
||||||
|
<span className="entry-chip px-3 py-2 text-sm">
|
||||||
|
{entries.length ? `${summary.allTimeCans} all-time cans` : "Ready for your first entry"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Camera, Plus } from "lucide-react";
|
||||||
|
import { NAV_ITEMS } from "./navItems";
|
||||||
|
import type { AppView } from "../types";
|
||||||
|
|
||||||
|
type TopBarProps = {
|
||||||
|
activeView: AppView;
|
||||||
|
busyAction: string | null;
|
||||||
|
onAdd: () => void;
|
||||||
|
onScan: () => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopBar({ activeView, busyAction, onAdd, onScan, className = "" }: TopBarProps) {
|
||||||
|
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 (
|
||||||
|
<header className={`top-app-bar ${className}`.trim()} data-view={activeView}>
|
||||||
|
<div className="top-app-bar-main">
|
||||||
|
<div className="top-title-cluster">
|
||||||
|
<span className="top-app-icon">
|
||||||
|
<ActiveIcon size={24} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="top-kicker">{subtitle}</p>
|
||||||
|
<h1 className="top-title">{title}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="top-action-row">
|
||||||
|
<button
|
||||||
|
className="secondary-button top-action-button justify-center"
|
||||||
|
type="button"
|
||||||
|
onClick={onScan}
|
||||||
|
disabled={Boolean(busyAction)}
|
||||||
|
aria-label="Scan barcode"
|
||||||
|
>
|
||||||
|
<Camera size={18} aria-hidden="true" />
|
||||||
|
<span className="top-action-label">Scan</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-button top-action-button justify-center"
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={Boolean(busyAction)}
|
||||||
|
aria-label="Add intake"
|
||||||
|
>
|
||||||
|
<Plus size={18} aria-hidden="true" />
|
||||||
|
<span className="top-action-label">Add intake</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { CalendarDays, Home, LineChart, Settings2 } from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { AppView } from "../types";
|
||||||
|
|
||||||
|
export 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 },
|
||||||
|
];
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type BadgeTone = "neutral" | "accent" | "success" | "warning" | "danger";
|
||||||
|
|
||||||
|
export type BadgeProps = {
|
||||||
|
tone?: BadgeTone;
|
||||||
|
children: ReactNode;
|
||||||
|
} & HTMLAttributes<HTMLSpanElement>;
|
||||||
|
|
||||||
|
export function Badge({ tone = "neutral", className, children, ...rest }: BadgeProps) {
|
||||||
|
const classes = ["badge", tone !== "neutral" ? `badge-${tone}` : "", className].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<span className={classes} {...rest}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type ButtonVariant = "primary" | "secondary" | "danger";
|
||||||
|
|
||||||
|
export type ButtonProps = {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
loading?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
|
const BUTTON_VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||||
|
primary: "primary-button",
|
||||||
|
secondary: "secondary-button",
|
||||||
|
danger: "danger-button",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "primary",
|
||||||
|
loading = false,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
type = "button",
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const classes = [BUTTON_VARIANT_CLASSES[variant], className].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<button className={classes} type={type} disabled={disabled || loading} aria-busy={loading || undefined} {...rest}>
|
||||||
|
{loading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type CardProps = {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Card({ title, subtitle, actions, className, children }: CardProps) {
|
||||||
|
const classes = ["app-card p-4 sm:p-5", className].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<section className={classes}>
|
||||||
|
{(title || subtitle || actions) && (
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{title && <h2 className="app-card-title text-xl">{title}</h2>}
|
||||||
|
{subtitle && <p className="app-card-subtitle mt-1">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Zap } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type EmptyStateProps = {
|
||||||
|
icon?: ReactNode;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyState({ icon, title, body, action, className }: EmptyStateProps) {
|
||||||
|
const classes = ["empty-state", className].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
<div className="empty-state-icon">{icon ?? <Zap size={22} aria-hidden="true" />}</div>
|
||||||
|
<h3 className="empty-state-title">{title}</h3>
|
||||||
|
<p className="empty-state-copy mt-2 max-w-sm">{body}</p>
|
||||||
|
{action && <div className="mt-4">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type FieldControlProps = {
|
||||||
|
id: string;
|
||||||
|
"aria-invalid": true | undefined;
|
||||||
|
"aria-describedby": string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FieldProps = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
help?: string;
|
||||||
|
className?: string;
|
||||||
|
children: (controlProps: FieldControlProps) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Field({ id, label, error, help, className, children }: FieldProps) {
|
||||||
|
const message = error ?? help;
|
||||||
|
const messageId = message ? `${id}-message` : undefined;
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className="field-label" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
{children({
|
||||||
|
id,
|
||||||
|
"aria-invalid": error ? true : undefined,
|
||||||
|
"aria-describedby": messageId,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{message && (
|
||||||
|
<p id={messageId} className={error ? "field-error-text" : "field-help-text"}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { Badge } from "./Badge";
|
||||||
|
export type { BadgeProps, BadgeTone } from "./Badge";
|
||||||
|
export { Button } from "./Button";
|
||||||
|
export type { ButtonProps, ButtonVariant } from "./Button";
|
||||||
|
export { Card } from "./Card";
|
||||||
|
export type { CardProps } from "./Card";
|
||||||
|
export { EmptyState } from "./EmptyState";
|
||||||
|
export type { EmptyStateProps } from "./EmptyState";
|
||||||
|
export { Field } from "./Field";
|
||||||
|
export type { FieldControlProps, FieldProps } from "./Field";
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Eye, EyeOff, 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("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
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" autoComplete="name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Ned" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="field-label">
|
||||||
|
Email
|
||||||
|
<input className="field-control" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="you@example.com" required />
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
Password
|
||||||
|
<span className="relative block">
|
||||||
|
<input
|
||||||
|
className="field-control pr-12"
|
||||||
|
minLength={8}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete={mode === "signup" ? "new-password" : "current-password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="8+ characters"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="password-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
onClick={() => setShowPassword((current) => !current)}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{authError && (
|
||||||
|
<div
|
||||||
|
className="rounded-md px-3 py-2 text-sm"
|
||||||
|
style={{
|
||||||
|
border: "1px solid color-mix(in srgb, var(--error) 35%, transparent)",
|
||||||
|
background: "var(--error-container)",
|
||||||
|
color: "var(--on-error-container)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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,55 @@
|
|||||||
|
import { EntryLedger } from "../EntryLedger";
|
||||||
|
import { FiltersPanel } from "../FiltersPanel";
|
||||||
|
import { LegalFootnote } from "../LegalFootnote";
|
||||||
|
import type { EntryDerived, Filters, Flavour, RedBullEntry } from "../../types";
|
||||||
|
|
||||||
|
type LogbookViewProps = {
|
||||||
|
entries: RedBullEntry[];
|
||||||
|
totalEntries: number;
|
||||||
|
entryDerived: Map<string, EntryDerived>;
|
||||||
|
filters: Filters;
|
||||||
|
flavours: Flavour[];
|
||||||
|
loading: boolean;
|
||||||
|
deletingId: string | null;
|
||||||
|
onFilterChange: (filters: Filters) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (entry: RedBullEntry) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onLogAgain?: (entry: RedBullEntry) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LogbookView({
|
||||||
|
entries,
|
||||||
|
totalEntries,
|
||||||
|
entryDerived,
|
||||||
|
filters,
|
||||||
|
flavours,
|
||||||
|
loading,
|
||||||
|
deletingId,
|
||||||
|
onFilterChange,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onLogAgain,
|
||||||
|
}: 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}
|
||||||
|
entryDerived={entryDerived}
|
||||||
|
filters={filters}
|
||||||
|
loading={loading}
|
||||||
|
deletingId={deletingId}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onLogAgain={onLogAgain}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LegalFootnote />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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 { Skeleton } from "../Skeleton";
|
||||||
|
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;
|
||||||
|
loading: boolean;
|
||||||
|
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,
|
||||||
|
loading,
|
||||||
|
onQuickAdd,
|
||||||
|
onAdd,
|
||||||
|
onScan,
|
||||||
|
onOpenLogbook,
|
||||||
|
onOpenSettings,
|
||||||
|
}: OverviewViewProps) {
|
||||||
|
const todaySpendRaw = limitCheck.todaySpend;
|
||||||
|
const showSkeleton = loading && entries.length === 0;
|
||||||
|
const spendLimitDetail =
|
||||||
|
userLimits.dailySpendLimit != null
|
||||||
|
? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today`
|
||||||
|
: `${summary.monthSpend} this month`;
|
||||||
|
const chartTotalSpend = chartData.reduce((total, point) => total + point.spend, 0);
|
||||||
|
const chartTotalCans = chartData.reduce((total, point) => total + point.cans, 0);
|
||||||
|
const spendChartLabel = `Spend over the last ${chartData.length} logged days, total ${currency.format(chartTotalSpend)} across ${chartTotalCans} cans`;
|
||||||
|
const flavourChartLabel = flavourData.length
|
||||||
|
? `Cans by flavour across ${flavourData.length} flavours. Most logged: ${flavourData[0].name}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
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: "var(--warning)" }} />
|
||||||
|
<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">
|
||||||
|
{showSkeleton ? (
|
||||||
|
Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="h-28" />)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
{showSkeleton ? (
|
||||||
|
<Skeleton className="chart-shell chart-shell--area" />
|
||||||
|
) : chartData.length ? (
|
||||||
|
<div className="chart-shell chart-shell--area" role="img" aria-label={spendChartLabel}>
|
||||||
|
<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`}>
|
||||||
|
{showSkeleton ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{Array.from({ length: 5 }, (_, index) => (
|
||||||
|
<Skeleton key={index} className="h-12" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : 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" role="img" aria-label={flavourChartLabel}>
|
||||||
|
<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,176 @@
|
|||||||
|
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[];
|
||||||
|
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,
|
||||||
|
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 content-start gap-4">
|
||||||
|
<AppCard title="Limits" subtitle="Personal caps for cans, spend, and stop time (local time)">
|
||||||
|
<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={`Pick a theme and colour mode · ${activeTheme.label} active`}>
|
||||||
|
<ThemePicker themeId={themeId} themeMode={themeMode} onChange={onThemeChange} onThemeModeChange={onThemeModeChange} />
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<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" />}
|
||||||
|
{setupStatus.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button mt-4 justify-center" type="button" onClick={onLogout}>
|
||||||
|
<LogOut size={17} aria-hidden="true" />
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid content-start gap-4">
|
||||||
|
<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-2">
|
||||||
|
<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>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<section className="app-card danger-zone p-4 sm:p-5" aria-labelledby="danger-zone-title">
|
||||||
|
<h2 id="danger-zone-title" className="app-card-title danger-zone-title text-xl">
|
||||||
|
Danger zone
|
||||||
|
</h2>
|
||||||
|
<p className="app-card-subtitle mt-1">
|
||||||
|
Permanently delete every entry on this account. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<button className="danger-button mt-4 justify-center" type="button" onClick={onReset} disabled={!entries.length || Boolean(busyAction)}>
|
||||||
|
<RotateCcw size={17} aria-hidden="true" />
|
||||||
|
Delete all entries
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</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: "var(--primary)" }}>{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Line,
|
||||||
|
LineChart as RechartsLineChart,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { currency, oneDecimal, wholeNumber } from "../../lib/metrics";
|
||||||
|
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 { Skeleton } from "../Skeleton";
|
||||||
|
import { SpendForecastCard } from "../SpendForecastCard";
|
||||||
|
import type { Insight } from "../../lib/dashboard";
|
||||||
|
import type { Filters, Flavour, RedBullEntry, UserLimits } from "../../types";
|
||||||
|
|
||||||
|
const CHART_HEIGHT = 300;
|
||||||
|
const AXIS_PROPS = {
|
||||||
|
stroke: "var(--subtle)",
|
||||||
|
tickLine: false,
|
||||||
|
axisLine: false,
|
||||||
|
interval: "preserveStartEnd",
|
||||||
|
minTickGap: 24,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
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;
|
||||||
|
loading: boolean;
|
||||||
|
onFilterChange: (filters: Filters) => void;
|
||||||
|
onSaveLimits: (limits: UserLimits) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TrendsView({
|
||||||
|
chartData,
|
||||||
|
weekData,
|
||||||
|
flavourData,
|
||||||
|
insights,
|
||||||
|
entries,
|
||||||
|
filters,
|
||||||
|
flavours,
|
||||||
|
userLimits,
|
||||||
|
loading,
|
||||||
|
onFilterChange,
|
||||||
|
onSaveLimits,
|
||||||
|
}: TrendsViewProps) {
|
||||||
|
const showSkeleton = loading && entries.length === 0;
|
||||||
|
const totalSpend = chartData.reduce((total, point) => total + point.spend, 0);
|
||||||
|
const totalCans = chartData.reduce((total, point) => total + point.cans, 0);
|
||||||
|
const totalCaffeine = chartData.reduce((total, point) => total + point.caffeine, 0);
|
||||||
|
const weekSpend = weekData.reduce((total, point) => total + point.spend, 0);
|
||||||
|
const weekCans = weekData.reduce((total, point) => total + point.cans, 0);
|
||||||
|
|
||||||
|
const cansSpendLabel = `Spend and cans over ${chartData.length} logged days, total ${currency.format(totalSpend)} and ${oneDecimal.format(totalCans)} cans`;
|
||||||
|
const caffeineLabel = `Estimated caffeine per day over ${chartData.length} logged days, total ${wholeNumber.format(totalCaffeine)} milligrams`;
|
||||||
|
const weekLabel = `Weekly spend and cans across ${weekData.length} weeks, total ${currency.format(weekSpend)} and ${oneDecimal.format(weekCans)} cans`;
|
||||||
|
const flavourLabel = flavourData.length
|
||||||
|
? `Cans by flavour across ${flavourData.length} flavours. Most logged: ${flavourData[0].name}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
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`}>
|
||||||
|
{showSkeleton ? (
|
||||||
|
<Skeleton className="h-[300px]" />
|
||||||
|
) : chartData.length ? (
|
||||||
|
<div role="img" aria-label={cansSpendLabel}>
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
|
<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="var(--chart-grid)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||||
|
<YAxis stroke="var(--subtle)" 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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
{showSkeleton ? (
|
||||||
|
<Skeleton className="h-[300px]" />
|
||||||
|
) : chartData.length ? (
|
||||||
|
<div role="img" aria-label={caffeineLabel}>
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||||
|
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Bar dataKey="caffeine" name="Caffeine" fill="#39d5ff" radius={[8, 8, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No caffeine data" copy="Add entries to estimate caffeine over time." />
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard title="Weekly comparison" subtitle="Spend and cans">
|
||||||
|
{showSkeleton ? (
|
||||||
|
<Skeleton className="h-[300px]" />
|
||||||
|
) : weekData.length ? (
|
||||||
|
<div role="img" aria-label={weekLabel}>
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
|
<RechartsLineChart data={weekData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||||
|
<YAxis stroke="var(--subtle)" 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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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 ? (
|
||||||
|
<div role="img" aria-label={flavourLabel}>
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const MATERIAL_ACCENTS = {
|
||||||
|
primary: "var(--chart-primary)",
|
||||||
|
secondary: "var(--chart-secondary)",
|
||||||
|
tertiary: "var(--chart-tertiary)",
|
||||||
|
error: "var(--chart-error)",
|
||||||
|
custom: "#b85d84",
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type QuickAddItem = (typeof QUICK_ADDS)[number];
|
||||||
+29
-1
@@ -4,12 +4,16 @@ export type AppTheme = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
swatch: string;
|
swatch: string;
|
||||||
|
seed: ThemeSeed;
|
||||||
tokens: ThemeTokens;
|
tokens: ThemeTokens;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v2";
|
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v2";
|
||||||
export const OLD_THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
export const OLD_THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
||||||
export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1";
|
export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1";
|
||||||
|
export const THEME_MODE_STORAGE_KEY = "red-bull-intake-tracker.theme-mode.v1";
|
||||||
export const DEFAULT_THEME_ID = "mist";
|
export const DEFAULT_THEME_ID = "mist";
|
||||||
|
|
||||||
const OLD_THEME_MAP: Record<string, string> = {
|
const OLD_THEME_MAP: Record<string, string> = {
|
||||||
@@ -50,7 +54,7 @@ const OLD_THEME_MAP: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function theme(id: string, label: string, swatch: string, seed: ThemeSeed): AppTheme {
|
function theme(id: string, label: string, swatch: string, seed: ThemeSeed): AppTheme {
|
||||||
return { id, label, swatch, tokens: buildThemeTokens(seed) };
|
return { id, label, swatch, seed, tokens: buildThemeTokens(seed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_THEMES: AppTheme[] = [
|
export const APP_THEMES: AppTheme[] = [
|
||||||
@@ -97,6 +101,30 @@ export function getThemeById(id: string): AppTheme {
|
|||||||
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
|
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getThemeTokens(id: string, mode: "light" | "dark"): ThemeTokens {
|
||||||
|
const entry = getThemeById(id);
|
||||||
|
if (mode === "light") return entry.tokens;
|
||||||
|
|
||||||
|
// Dark mode drops the light-only explicit overrides; chart colours that
|
||||||
|
// still read well on dark surfaces are carried over.
|
||||||
|
const overrides = entry.seed.tokens;
|
||||||
|
const chartOverrides: Partial<ThemeTokens> = {};
|
||||||
|
if (overrides?.chartPrimary) chartOverrides.chartPrimary = overrides.chartPrimary;
|
||||||
|
if (overrides?.chartSecondary) chartOverrides.chartSecondary = overrides.chartSecondary;
|
||||||
|
if (overrides?.chartTertiary) chartOverrides.chartTertiary = overrides.chartTertiary;
|
||||||
|
return buildThemeTokens({ ...entry.seed, dark: true, tokens: chartOverrides });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normaliseThemeMode(value: string | null | undefined): ThemeMode {
|
||||||
|
if (value === "light" || value === "dark" || value === "system") return value;
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredThemeMode(): ThemeMode {
|
||||||
|
if (typeof window === "undefined") return "system";
|
||||||
|
return normaliseThemeMode(localStorage.getItem(THEME_MODE_STORAGE_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
export function normaliseThemeId(id: string | null | undefined): string {
|
export function normaliseThemeId(id: string | null | undefined): string {
|
||||||
if (!id) return DEFAULT_THEME_ID;
|
if (!id) return DEFAULT_THEME_ID;
|
||||||
if (APP_THEMES.some((entry) => entry.id === id)) return id;
|
if (APP_THEMES.some((entry) => entry.id === id)) return id;
|
||||||
|
|||||||
+699
-141
File diff suppressed because it is too large
Load Diff
@@ -59,10 +59,15 @@ export async function createEntry(userId: string, draft: EntryDraft) {
|
|||||||
return fromRow(row);
|
return fromRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createEntries(userId: string, drafts: EntryDraft[]) {
|
export async function createEntries(
|
||||||
|
userId: string,
|
||||||
|
drafts: EntryDraft[],
|
||||||
|
onProgress?: (done: number, total: number) => void,
|
||||||
|
) {
|
||||||
const saved: RedBullEntry[] = [];
|
const saved: RedBullEntry[] = [];
|
||||||
for (const draft of drafts) {
|
for (const [index, draft] of drafts.entries()) {
|
||||||
saved.push(await createEntry(userId, draft));
|
saved.push(await createEntry(userId, draft));
|
||||||
|
onProgress?.(index + 1, drafts.length);
|
||||||
}
|
}
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
caffeineFor,
|
||||||
|
currency,
|
||||||
|
currentStreak,
|
||||||
|
daysSinceLast,
|
||||||
|
entriesInRange,
|
||||||
|
highestAveragePrice,
|
||||||
|
oneDecimal,
|
||||||
|
spendFor,
|
||||||
|
startOfDay,
|
||||||
|
startOfMonth,
|
||||||
|
startOfWeek,
|
||||||
|
sugarFor,
|
||||||
|
sum,
|
||||||
|
topByCans,
|
||||||
|
trackedWeeks,
|
||||||
|
wholeNumber,
|
||||||
|
} from "./metrics";
|
||||||
|
import type { RedBullEntry } from "../types";
|
||||||
|
|
||||||
|
export type Dashboard = ReturnType<typeof buildDashboard>;
|
||||||
|
export type Insight = ReturnType<typeof buildInsights>[number];
|
||||||
|
|
||||||
|
export function buildDashboard(entries: RedBullEntry[]) {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = startOfDay(now);
|
||||||
|
const tomorrow = new Date(todayStart.getTime() + 86_400_000 - 1);
|
||||||
|
const weekStart = startOfWeek(now);
|
||||||
|
const monthStart = startOfMonth(now);
|
||||||
|
const todayEntries = entriesInRange(entries, todayStart, tomorrow);
|
||||||
|
const weekEntries = entriesInRange(entries, weekStart, now);
|
||||||
|
const monthEntries = entriesInRange(entries, monthStart, now);
|
||||||
|
const totalSpend = sum(entries, spendFor);
|
||||||
|
const priceyFlavour = highestAveragePrice(entries, "flavour");
|
||||||
|
const priceyStore = highestAveragePrice(entries, "store");
|
||||||
|
|
||||||
|
return {
|
||||||
|
todayCans: oneDecimal.format(sum(todayEntries, (entry) => entry.cans)),
|
||||||
|
weekCans: `${oneDecimal.format(sum(weekEntries, (entry) => entry.cans))} cans`,
|
||||||
|
monthCans: oneDecimal.format(sum(monthEntries, (entry) => entry.cans)),
|
||||||
|
allTimeCans: oneDecimal.format(sum(entries, (entry) => entry.cans)),
|
||||||
|
totalSpend: currency.format(totalSpend),
|
||||||
|
monthSpend: currency.format(sum(monthEntries, spendFor)),
|
||||||
|
avgWeeklySpend: `${currency.format(totalSpend / trackedWeeks(entries))}`,
|
||||||
|
todayCaffeine: `${wholeNumber.format(sum(todayEntries, caffeineFor))}mg`,
|
||||||
|
monthCaffeine: `${wholeNumber.format(sum(monthEntries, caffeineFor))}mg`,
|
||||||
|
todaySugar: `${oneDecimal.format(sum(todayEntries, sugarFor))}g`,
|
||||||
|
monthSugar: `${oneDecimal.format(sum(monthEntries, sugarFor))}g`,
|
||||||
|
favouriteFlavour: topByCans(entries),
|
||||||
|
priciestFlavour: priceyFlavour ? `${priceyFlavour.label} ${currency.format(priceyFlavour.average)}` : "None yet",
|
||||||
|
priciestStore: priceyStore ? `${priceyStore.label} ${currency.format(priceyStore.average)}` : "No store yet",
|
||||||
|
currentStreak: `${currentStreak(entries)}`,
|
||||||
|
daysWithoutRedBull: `${daysSinceLast(entries)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInsights(entries: RedBullEntry[]) {
|
||||||
|
const now = new Date();
|
||||||
|
const weekStart = startOfWeek(now);
|
||||||
|
const previousWeekStart = new Date(weekStart.getTime() - 7 * 86_400_000);
|
||||||
|
const previousWeekEnd = new Date(weekStart.getTime() - 1);
|
||||||
|
const monthStart = startOfMonth(now);
|
||||||
|
const previousMonthStart = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1);
|
||||||
|
const previousMonthEnd = new Date(monthStart.getTime() - 1);
|
||||||
|
|
||||||
|
const thisMonthSpend = sum(entriesInRange(entries, monthStart, now), spendFor);
|
||||||
|
const lastMonthSpend = sum(entriesInRange(entries, previousMonthStart, previousMonthEnd), spendFor);
|
||||||
|
const thisWeekCans = sum(entriesInRange(entries, weekStart, now), (entry) => entry.cans);
|
||||||
|
const lastWeekCans = sum(entriesInRange(entries, previousWeekStart, previousWeekEnd), (entry) => entry.cans);
|
||||||
|
const sugarFreeCans = sum(entries.filter((entry) => entry.sugarFree), (entry) => entry.cans);
|
||||||
|
const allCans = sum(entries, (entry) => entry.cans);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Month spend",
|
||||||
|
value: `You spent ${currency.format(thisMonthSpend)} this month`,
|
||||||
|
detail:
|
||||||
|
lastMonthSpend > 0
|
||||||
|
? `${comparisonCopy(thisMonthSpend, lastMonthSpend, "vs last month")}`
|
||||||
|
: "No previous-month baseline yet.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Weekly pace",
|
||||||
|
value: `${oneDecimal.format(thisWeekCans)} cans this week`,
|
||||||
|
detail:
|
||||||
|
lastWeekCans > 0
|
||||||
|
? `${comparisonCopy(thisWeekCans, lastWeekCans, "vs last week")}`
|
||||||
|
: "The weekly comparator wakes up after another week of data.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Zero sugar mix",
|
||||||
|
value: allCans ? `${oneDecimal.format((sugarFreeCans / allCans) * 100)}% sugar-free` : "No mix yet",
|
||||||
|
detail: allCans ? `${oneDecimal.format(sugarFreeCans)} of ${oneDecimal.format(allCans)} cans flagged sugar-free.` : "Log a sugar-free entry to track the split.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparisonCopy(current: number, previous: number, suffix: string) {
|
||||||
|
const difference = current - previous;
|
||||||
|
const percent = previous === 0 ? 0 : (difference / previous) * 100;
|
||||||
|
const direction = difference >= 0 ? "up" : "down";
|
||||||
|
return `${direction} ${oneDecimal.format(Math.abs(percent))}% ${suffix}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { RedBullEntry } from "../types";
|
||||||
|
|
||||||
|
const ENTRY_CACHE_KEY_PREFIX = "red-bull-intake-tracker.entries.v1.";
|
||||||
|
|
||||||
|
function entryCacheKey(userId: string): string {
|
||||||
|
return `${ENTRY_CACHE_KEY_PREFIX}${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort read-through cache: entries persist per user so the app can
|
||||||
|
// render the last synced state while a network refresh is still in flight.
|
||||||
|
|
||||||
|
export function readCachedEntries(userId: string): RedBullEntry[] | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(entryCacheKey(userId));
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
const entries = parsed.filter(isEntryShape);
|
||||||
|
return entries.length ? entries : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCachedEntries(userId: string, entries: RedBullEntry[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(entryCacheKey(userId), JSON.stringify(entries));
|
||||||
|
} catch {
|
||||||
|
// Quota and serialization failures are non-fatal; the cache is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCachedEntries(userId: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(entryCacheKey(userId));
|
||||||
|
} catch {
|
||||||
|
// Storage failures are non-fatal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEntryShape(value: unknown): value is RedBullEntry {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const entry = value as Partial<RedBullEntry>;
|
||||||
|
return (
|
||||||
|
typeof entry.id === "string" &&
|
||||||
|
typeof entry.dateTime === "string" &&
|
||||||
|
typeof entry.flavour === "string" &&
|
||||||
|
typeof entry.cans === "number" &&
|
||||||
|
typeof entry.sizeMl === "number" &&
|
||||||
|
typeof entry.pricePerCan === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
import ExcelJS from "exceljs";
|
import type ExcelJS from "exceljs";
|
||||||
import { flavourMeta } from "../data/flavours";
|
import { flavourMeta } from "../data/flavours";
|
||||||
import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types";
|
import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,7 @@ const ENTRY_COLUMNS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export async function createExcelExport(entries: RedBullEntry[]) {
|
export async function createExcelExport(entries: RedBullEntry[]) {
|
||||||
|
const { default: ExcelJS } = await import("exceljs");
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
workbook.creator = "Red Bull Intake Tracker";
|
workbook.creator = "Red Bull Intake Tracker";
|
||||||
workbook.created = new Date();
|
workbook.created = new Date();
|
||||||
@@ -53,6 +54,7 @@ export async function createExcelExport(entries: RedBullEntry[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function parseExcelImport(file: File, existingEntries: RedBullEntry[]): Promise<ImportPreview> {
|
export async function parseExcelImport(file: File, existingEntries: RedBullEntry[]): Promise<ImportPreview> {
|
||||||
|
const { default: ExcelJS } = await import("exceljs");
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(await file.arrayBuffer());
|
await workbook.xlsx.load(await file.arrayBuffer());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { startOfDay, startOfMonth, startOfWeek } from "./metrics";
|
||||||
|
import type { Filters, RedBullEntry } from "../types";
|
||||||
|
|
||||||
|
export const DEFAULT_FILTERS: Filters = {
|
||||||
|
flavour: "all",
|
||||||
|
dateRange: "all",
|
||||||
|
store: "",
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyFilters(entries: RedBullEntry[], filters: Filters) {
|
||||||
|
const now = new Date();
|
||||||
|
let start: Date | null = null;
|
||||||
|
let end: Date | null = null;
|
||||||
|
|
||||||
|
if (filters.dateRange === "today") {
|
||||||
|
start = startOfDay(now);
|
||||||
|
end = new Date(start.getTime() + 86_400_000 - 1);
|
||||||
|
}
|
||||||
|
if (filters.dateRange === "week") {
|
||||||
|
start = startOfWeek(now);
|
||||||
|
end = now;
|
||||||
|
}
|
||||||
|
if (filters.dateRange === "month") {
|
||||||
|
start = startOfMonth(now);
|
||||||
|
end = now;
|
||||||
|
}
|
||||||
|
if (filters.dateRange === "custom") {
|
||||||
|
start = filters.from ? new Date(`${filters.from}T00:00:00`) : null;
|
||||||
|
end = filters.to ? new Date(`${filters.to}T23:59:59`) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.filter((entry) => {
|
||||||
|
const date = new Date(entry.dateTime);
|
||||||
|
const flavourMatch = filters.flavour === "all" || entry.flavour === filters.flavour;
|
||||||
|
const storeMatch = !filters.store || entry.store?.toLowerCase().includes(filters.store.toLowerCase());
|
||||||
|
const startMatch = !start || date >= start;
|
||||||
|
const endMatch = !end || date <= end;
|
||||||
|
return flavourMatch && storeMatch && startMatch && endMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
+1
-6
@@ -20,12 +20,7 @@ type GreetingResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getBstHour(date = new Date()) {
|
export function getBstHour(date = new Date()) {
|
||||||
const hour = new Intl.DateTimeFormat("en-GB", {
|
return date.getHours();
|
||||||
timeZone: "Europe/London",
|
|
||||||
hour: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
}).format(date);
|
|
||||||
return Number.parseInt(hour, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
||||||
|
|||||||
+13
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { BuiltInSize, RedBullEntry } from "../types";
|
import type { BuiltInSize, EntryDerived, RedBullEntry } from "../types";
|
||||||
|
|
||||||
export const CAFFEINE_PER_250ML = 80;
|
export const CAFFEINE_PER_250ML = 80;
|
||||||
export const SUGAR_PER_250ML = 27;
|
export const SUGAR_PER_250ML = 27;
|
||||||
@@ -53,6 +53,18 @@ export function sugarFor(entry: RedBullEntry) {
|
|||||||
return entry.cans * (entry.sizeMl / 250) * SUGAR_PER_250ML;
|
return entry.cans * (entry.sizeMl / 250) * SUGAR_PER_250ML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildEntryDerived(entries: RedBullEntry[]) {
|
||||||
|
const derived = new Map<string, EntryDerived>();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
derived.set(entry.id, {
|
||||||
|
spend: spendFor(entry),
|
||||||
|
caffeine: caffeineFor(entry),
|
||||||
|
sugar: sugarFor(entry),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return derived;
|
||||||
|
}
|
||||||
|
|
||||||
export function startOfDay(date: Date) {
|
export function startOfDay(date: Date) {
|
||||||
const next = new Date(date);
|
const next = new Date(date);
|
||||||
next.setHours(0, 0, 0, 0);
|
next.setHours(0, 0, 0, 0);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Transition } from "framer-motion";
|
||||||
|
|
||||||
|
// Standard ease-out curve shared by every animated surface (A6 motion language).
|
||||||
|
export const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
|
||||||
|
|
||||||
|
export const VIEW_TRANSITION: Transition = { duration: 0.2, ease: EASE_OUT };
|
||||||
|
export const MODAL_TRANSITION: Transition = { duration: 0.22, ease: EASE_OUT };
|
||||||
|
export const TOAST_TRANSITION: Transition = { duration: 0.18, ease: EASE_OUT };
|
||||||
|
export const TILE_TRANSITION: Transition = { duration: 0.22, ease: EASE_OUT };
|
||||||
+28
-1
@@ -1,5 +1,6 @@
|
|||||||
import { flavourMeta } from "../data/flavours";
|
import { flavourMeta } from "../data/flavours";
|
||||||
import type { EntryDraft, RedBullEntry } from "../types";
|
import { makeImportKey } from "./metrics";
|
||||||
|
import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types";
|
||||||
|
|
||||||
export function exportPayload(entries: RedBullEntry[]) {
|
export function exportPayload(entries: RedBullEntry[]) {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
@@ -28,6 +29,32 @@ export function parseImport(raw: string): EntryDraft[] {
|
|||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildJsonImportPreview(
|
||||||
|
drafts: EntryDraft[],
|
||||||
|
existingEntries: RedBullEntry[],
|
||||||
|
fileName: string,
|
||||||
|
): ImportPreview {
|
||||||
|
const seen = new Set(existingEntries.map((entry) => entry.importKey || makeImportKey(entry)));
|
||||||
|
const rows: ImportPreviewRow[] = drafts.map((draft, index) => {
|
||||||
|
const key = makeImportKey({
|
||||||
|
...draft,
|
||||||
|
dateTime: new Date(draft.dateTime).toISOString(),
|
||||||
|
notes: draft.notes ?? "",
|
||||||
|
store: draft.store ?? "",
|
||||||
|
});
|
||||||
|
const duplicate = seen.has(key);
|
||||||
|
if (!duplicate) seen.add(key);
|
||||||
|
return {
|
||||||
|
rowNumber: index + 1,
|
||||||
|
entry: draft,
|
||||||
|
errors: [],
|
||||||
|
duplicate,
|
||||||
|
duplicateReason: duplicate ? "Matches an existing or earlier imported row." : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { fileName, rows };
|
||||||
|
}
|
||||||
|
|
||||||
function coerceEntryDraft(value: unknown): EntryDraft | null {
|
function coerceEntryDraft(value: unknown): EntryDraft | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const entry = value as Partial<RedBullEntry>;
|
const entry = value as Partial<RedBullEntry>;
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export type ThemeTokens = {
|
|||||||
onError: string;
|
onError: string;
|
||||||
errorContainer: string;
|
errorContainer: string;
|
||||||
onErrorContainer: string;
|
onErrorContainer: string;
|
||||||
|
warning: string;
|
||||||
|
onWarning: string;
|
||||||
|
warningContainer: string;
|
||||||
|
onWarningContainer: string;
|
||||||
|
success: string;
|
||||||
|
onSuccess: string;
|
||||||
|
successContainer: string;
|
||||||
|
onSuccessContainer: string;
|
||||||
bg: string;
|
bg: string;
|
||||||
surface: string;
|
surface: string;
|
||||||
surfaceContainerLowest: string;
|
surfaceContainerLowest: string;
|
||||||
@@ -160,6 +168,10 @@ export function buildThemeTokens(seed: ThemeSeed): ThemeTokens {
|
|||||||
const tertiaryContainer = containerColor(tertiary);
|
const tertiaryContainer = containerColor(tertiary);
|
||||||
const error = "#ba1a1a";
|
const error = "#ba1a1a";
|
||||||
const errorContainer = "#ffdad6";
|
const errorContainer = "#ffdad6";
|
||||||
|
const warning = dark ? "#fbbc04" : "#8d5700";
|
||||||
|
const warningContainer = dark ? "#594400" : "#ffddb0";
|
||||||
|
const success = dark ? "#81c995" : "#0d652d";
|
||||||
|
const successContainer = dark ? "#0f5223" : "#ceead6";
|
||||||
|
|
||||||
const tokens: ThemeTokens = {
|
const tokens: ThemeTokens = {
|
||||||
primary,
|
primary,
|
||||||
@@ -178,6 +190,14 @@ export function buildThemeTokens(seed: ThemeSeed): ThemeTokens {
|
|||||||
onError: "#ffffff",
|
onError: "#ffffff",
|
||||||
errorContainer,
|
errorContainer,
|
||||||
onErrorContainer: "#410002",
|
onErrorContainer: "#410002",
|
||||||
|
warning,
|
||||||
|
onWarning: dark ? "#3a2a00" : "#ffffff",
|
||||||
|
warningContainer,
|
||||||
|
onWarningContainer: dark ? "#ffe1a6" : "#291800",
|
||||||
|
success,
|
||||||
|
onSuccess: dark ? "#07391c" : "#ffffff",
|
||||||
|
successContainer,
|
||||||
|
onSuccessContainer: dark ? "#ceead6" : "#03210f",
|
||||||
bg: surfaces.bg,
|
bg: surfaces.bg,
|
||||||
surface: surfaces.surface,
|
surface: surfaces.surface,
|
||||||
surfaceContainerLowest: surfaces.surfaceContainerLowest,
|
surfaceContainerLowest: surfaces.surfaceContainerLowest,
|
||||||
@@ -220,6 +240,14 @@ export function themeTokensToStyle(tokens: ThemeTokens): CSSProperties {
|
|||||||
"--on-error": tokens.onError,
|
"--on-error": tokens.onError,
|
||||||
"--error-container": tokens.errorContainer,
|
"--error-container": tokens.errorContainer,
|
||||||
"--on-error-container": tokens.onErrorContainer,
|
"--on-error-container": tokens.onErrorContainer,
|
||||||
|
"--warning": tokens.warning,
|
||||||
|
"--on-warning": tokens.onWarning,
|
||||||
|
"--warning-container": tokens.warningContainer,
|
||||||
|
"--on-warning-container": tokens.onWarningContainer,
|
||||||
|
"--success": tokens.success,
|
||||||
|
"--on-success": tokens.onSuccess,
|
||||||
|
"--success-container": tokens.successContainer,
|
||||||
|
"--on-success-container": tokens.onSuccessContainer,
|
||||||
"--bg": tokens.bg,
|
"--bg": tokens.bg,
|
||||||
"--surface": tokens.surface,
|
"--surface": tokens.surface,
|
||||||
"--surface-container-lowest": tokens.surfaceContainerLowest,
|
"--surface-container-lowest": tokens.surfaceContainerLowest,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export type ToastTone = "success" | "error" | "info";
|
||||||
|
|
||||||
|
export type ToastInput = {
|
||||||
|
tone: ToastTone;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToastContextValue = {
|
||||||
|
pushToast: (toast: ToastInput) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useToasts(): ToastContextValue {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) throw new Error("useToasts must be used within a ToastProvider.");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { AuthUser } from "../types";
|
||||||
|
|
||||||
|
export function userInitial(user: AuthUser | null) {
|
||||||
|
const value = user?.name || user?.email || "r";
|
||||||
|
return value.trim().charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
+7
-21
@@ -1,6 +1,6 @@
|
|||||||
import type { EntryDraft, LimitCheckResult, LimitViolation, RedBullEntry, UserLimits } from "../types";
|
import type { EntryDraft, LimitCheckResult, LimitViolation, RedBullEntry, UserLimits } from "../types";
|
||||||
import { getBstHour } from "./greeting";
|
import { getBstHour } from "./greeting";
|
||||||
import { currency, spendFor, sum } from "./metrics";
|
import { currency, formatDateKey, spendFor, sum } from "./metrics";
|
||||||
|
|
||||||
export const DEFAULT_LIMITS: UserLimits = {};
|
export const DEFAULT_LIMITS: UserLimits = {};
|
||||||
|
|
||||||
@@ -59,25 +59,11 @@ export function mergePrefsWithLimits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatBstDateKey(date = new Date()) {
|
export function formatBstDateKey(date = new Date()) {
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
return formatDateKey(date);
|
||||||
timeZone: "Europe/London",
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
}).format(date);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBstMinutes(date = new Date()) {
|
export function getBstMinutes(date = new Date()) {
|
||||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
return date.getHours() * 60 + date.getMinutes();
|
||||||
timeZone: "Europe/London",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
}).formatToParts(date);
|
|
||||||
|
|
||||||
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
|
||||||
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
|
||||||
return hour * 60 + minute;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseStopTimeMinutes(stopTime: string) {
|
export function parseStopTimeMinutes(stopTime: string) {
|
||||||
@@ -101,7 +87,7 @@ export function formatStopTimeLabel(stopTime: string) {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function entriesTodayBst(entries: RedBullEntry[], ref = new Date()) {
|
function entriesToday(entries: RedBullEntry[], ref = new Date()) {
|
||||||
const key = formatBstDateKey(ref);
|
const key = formatBstDateKey(ref);
|
||||||
return entries.filter((entry) => formatBstDateKey(new Date(entry.dateTime)) === key);
|
return entries.filter((entry) => formatBstDateKey(new Date(entry.dateTime)) === key);
|
||||||
}
|
}
|
||||||
@@ -111,7 +97,7 @@ function spendForDraft(draft: EntryDraft) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function todayTotals(entries: RedBullEntry[], excludeEntryId?: string, ref = new Date()) {
|
function todayTotals(entries: RedBullEntry[], excludeEntryId?: string, ref = new Date()) {
|
||||||
const todayEntries = entriesTodayBst(entries, ref).filter((entry) => entry.id !== excludeEntryId);
|
const todayEntries = entriesToday(entries, ref).filter((entry) => entry.id !== excludeEntryId);
|
||||||
return {
|
return {
|
||||||
todayCans: sum(todayEntries, (entry) => entry.cans),
|
todayCans: sum(todayEntries, (entry) => entry.cans),
|
||||||
todaySpend: sum(todayEntries, spendFor),
|
todaySpend: sum(todayEntries, spendFor),
|
||||||
@@ -171,7 +157,7 @@ export function limitStatusMessage(
|
|||||||
|
|
||||||
if (violations.includes("cans") && limits.dailyCanLimit != null) {
|
if (violations.includes("cans") && limits.dailyCanLimit != null) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`This would bring you to ${check.projectedCans.toFixed(1)}/${limits.dailyCanLimit} cans today (BST).`,
|
`This would bring you to ${check.projectedCans.toFixed(1)}/${limits.dailyCanLimit} cans today.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +168,7 @@ export function limitStatusMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (violations.includes("stopTime") && limits.stopTime) {
|
if (violations.includes("stopTime") && limits.stopTime) {
|
||||||
lines.push(`You're past your stop time (${formatStopTimeLabel(limits.stopTime)} BST).`);
|
lines.push(`You're past your stop time (${formatStopTimeLabel(limits.stopTime)}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join(" ");
|
return lines.join(" ");
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import { MotionConfig } from "framer-motion";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ToastProvider } from "./components/Toasts";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<MotionConfig reducedMotion="user">
|
||||||
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
|
</MotionConfig>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
import type { Models } from "appwrite";
|
||||||
|
|
||||||
export type BuiltInSize = 250 | 355 | 473;
|
export type BuiltInSize = 250 | 355 | 473;
|
||||||
|
|
||||||
|
export type AppView = "overview" | "logbook" | "trends" | "settings";
|
||||||
|
|
||||||
|
export type AuthUser = Models.User<Models.Preferences>;
|
||||||
|
|
||||||
|
export type SetupStatus = { state: "checking" | "ok" | "error"; message: string };
|
||||||
|
|
||||||
export type RedBullEntry = {
|
export type RedBullEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -106,6 +114,17 @@ export type ImportPreview = {
|
|||||||
rows: ImportPreviewRow[];
|
rows: ImportPreviewRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ImportProgress = {
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntryDerived = {
|
||||||
|
spend: number;
|
||||||
|
caffeine: number;
|
||||||
|
sugar: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserLimits = {
|
export type UserLimits = {
|
||||||
dailyCanLimit?: number;
|
dailyCanLimit?: number;
|
||||||
dailySpendLimit?: number;
|
dailySpendLimit?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user