feat(ui): view-by-view UX redesign and accessibility pass

- Overview: limits hero with progress states, spend pill, primary quick-add, mobile FAB
- Logbook: day-grouped entries with subtotals, unified filter bar, Log again,
  human source labels
- Trends: unified chart cards, tick thinning, chart aria summaries, radiogroup
  forecast control
- Settings: sectioned layout with separated danger zone, ThemePicker aria fix
- EntryModal: fieldset grouping, Field primitive, safe date validation
- AuthView: autoComplete, password visibility toggle
- StatHint popover replacing title-only tooltips; mobile stacked import preview
- Remaining inline hex styles converted to theme tokens
This commit is contained in:
nh9961
2026-07-19 20:34:10 +00:00
parent 733c6cbae8
commit 3706021326
19 changed files with 945 additions and 299 deletions
+26
View File
@@ -1,4 +1,5 @@
import { AnimatePresence, motion } from "framer-motion";
import { Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flavourMeta, mergedFlavours } from "./data/flavours";
import { QUICK_ADDS, type QuickAddItem } from "./data/quickAdds";
@@ -323,6 +324,24 @@ function App() {
setIsEntryModalOpen(true);
}
function logEntryAgain(entry: RedBullEntry) {
setEditingEntry(null);
setEntryInitialDraft({
cans: entry.cans,
flavour: entry.flavour,
flavourAccent: entry.flavourAccent,
sizeMl: entry.sizeMl,
pricePerCan: entry.pricePerCan,
dateTime: new Date().toISOString(),
notes: entry.notes ?? "",
store: entry.store ?? "",
sugarFree: entry.sugarFree,
caffeineMgPerCan: entry.caffeineMgPerCan,
source: entry.source,
});
setIsEntryModalOpen(true);
}
async function saveUserLimits(next: UserLimits) {
if (!user) return;
setBusyAction("save-limits");
@@ -677,6 +696,7 @@ function App() {
setIsEntryModalOpen(true);
}}
onDelete={(id) => setPendingDeleteId(id)}
onLogAgain={logEntryAgain}
/>
)}
@@ -727,6 +747,12 @@ function App() {
<MobileNav activeView={activeView} onChange={setActiveView} />
{activeView === "overview" && (
<button className="fab-add" type="button" onClick={openNewEntry} aria-label="Add intake">
<Plus size={26} aria-hidden="true" />
</button>
)}
<EntryModal
entry={editingEntry}
initialDraft={entryInitialDraft}
+2 -2
View File
@@ -10,9 +10,9 @@ 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: "#202124" }}>{label}</p>
<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: "#5f6670" }}>
<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>
))}
+8 -1
View File
@@ -96,7 +96,14 @@ function LimitRow({
<span>{label}</span>
<strong>{value}</strong>
</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>
</div>
+119 -21
View File
@@ -1,6 +1,14 @@
import { Edit3, Loader2, Trash2 } from "lucide-react";
import type { CSSProperties } from "react";
import { caffeineFor, currency, humanDateTime, oneDecimal, spendFor, sugarFor, wholeNumber } from "../lib/metrics";
import { Edit3, Loader2, RotateCcw, Trash2 } from "lucide-react";
import { useMemo, type CSSProperties } from "react";
import {
caffeineFor,
currency,
formatDateKey,
oneDecimal,
spendFor,
sugarFor,
wholeNumber,
} from "../lib/metrics";
import { AppCard } from "./AppCard";
import { EmptyState } from "./EmptyState";
import type { RedBullEntry } from "../types";
@@ -12,15 +20,46 @@ type EntryLedgerProps = {
onAdd: () => void;
onEdit: (entry: RedBullEntry) => void;
onDelete: (id: string) => void;
onLogAgain?: (entry: RedBullEntry) => void;
};
export function EntryLedger({ entries, totalEntries, deletingId, onAdd, onEdit, onDelete }: EntryLedgerProps) {
type DayGroup = {
key: string;
label: string;
cans: number;
caffeine: number;
spend: number;
entries: RedBullEntry[];
};
export function EntryLedger({ entries, totalEntries, deletingId, onAdd, onEdit, onDelete, onLogAgain }: EntryLedgerProps) {
const dayGroups = useMemo(() => groupEntriesByDay(entries), [entries]);
return (
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
{entries.length ? (
<div className="grid gap-2">
{entries.map((entry) => (
<EntryRow key={entry.id} entry={entry} deleting={deletingId === entry.id} onEdit={onEdit} onDelete={onDelete} />
<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}
deleting={deletingId === entry.id}
onEdit={onEdit}
onDelete={onDelete}
onLogAgain={onLogAgain}
/>
))}
</div>
</section>
))}
</div>
) : (
@@ -35,45 +74,48 @@ function EntryRow({
deleting,
onEdit,
onDelete,
onLogAgain,
}: {
entry: RedBullEntry;
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)]" />
<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">
{entry.source}
</span>
<span className="source-badge">{sourceLabel(entry.source)}</span>
</div>
<p className="entry-meta">{humanDateTime(entry.dateTime)}</p>
<p className="entry-meta">
{timeLabel(entry.dateTime)}
{entry.store ? ` · ${entry.store}` : ""}
</p>
<p className="entry-summary mt-2">
{currency.format(spendFor(entry))} · {wholeNumber.format(caffeineFor(entry))}mg caffeine · {oneDecimal.format(sugarFor(entry))}g sugar
</p>
{(entry.store || entry.notes) && (
<p className="entry-meta mt-2 leading-6">
{entry.store ? `${entry.store}` : ""}
{entry.store && entry.notes ? " · " : ""}
{entry.notes}
</p>
)}
{entry.notes ? <p className="entry-meta mt-2 leading-6">{entry.notes}</p> : null}
</div>
<div className="flex items-center gap-2">
<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: "#9f1c16" }}
style={{ color: "var(--error)" }}
disabled={deleting}
onClick={() => onDelete(entry.id)}
aria-label={`Delete ${entry.flavour} entry`}
@@ -84,3 +126,59 @@ function EntryRow({
</article>
);
}
function groupEntriesByDay(entries: RedBullEntry[]): 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);
}
group.entries.push(entry);
group.cans += entry.cans;
group.caffeine += caffeineFor(entry);
group.spend += spendFor(entry);
});
return groups;
}
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";
}
}
+135 -97
View File
@@ -5,6 +5,7 @@ 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 } from "./Modal";
import { Field } from "./ui";
import type { EntryDraft, Flavour, RedBullEntry } from "../types";
type EntryModalProps = {
@@ -68,6 +69,9 @@ export function EntryModal({
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);
@@ -85,7 +89,7 @@ export function EntryModal({
flavourAccent: isOther ? customAccent || accentForCustomFlavour(finalFlavour) : meta.accent,
sizeMl: numericSize,
pricePerCan: numericPrice,
dateTime: new Date(dateTime).toISOString(),
dateTime: dateValid ? new Date(dateTime).toISOString() : "",
notes: notes.trim(),
store: store.trim(),
sugarFree: sugarFree || Boolean(meta.sugarFree),
@@ -106,13 +110,14 @@ export function EntryModal({
sugarFree,
sizePreset,
caffeineOverride,
dateValid,
entry?.source,
initialDraft?.source,
]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!draftPreview) return;
if (!draftPreview || !dateValid) return;
onSave(draftPreview);
}
@@ -138,110 +143,143 @@ export function EntryModal({
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="field-label">
Number of cans
<input ref={firstFieldRef} className="field-control" min="0.25" step="0.25" type="number" value={cans} onChange={(event) => setCans(event.target.value)} required />
</label>
<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>
<label className="field-label">
Price per can
<input className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
</label>
<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>
<label className="field-label">
Flavour
<select
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>
</label>
{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>
</>
)}
<label className="field-label">
Can size
<select
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>
</label>
<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>
{isOther && (
<>
<label className="field-label">
Custom flavour
<input className="field-control" type="text" value={customFlavour} onChange={(event) => setCustomFlavour(event.target.value)} placeholder="Fig Apple, Sea Blue..." />
{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>
<label className="field-label">
Accent colour
<input className="field-control h-12 p-1" type="color" value={customAccent} onChange={(event) => setCustomAccent(event.target.value)} aria-label="Custom flavour accent colour" />
</label>
</>
)}
</div>
</fieldset>
{sizePreset === "custom" && (
<>
<label className="field-label">
Custom size in ml
<input className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
</label>
<label className="field-label">
Caffeine override mg/can
<input className="field-control" min="0" step="1" type="number" value={caffeineOverride} onChange={(event) => setCaffeineOverride(event.target.value)} placeholder={wholeNumber.format(caffeinePerCan(numericSize))} />
</label>
</>
)}
<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>
<label className="field-label">
Date and time
<input className="field-control" type="datetime-local" value={dateTime} onChange={(event) => setDateTime(event.target.value)} required />
</label>
<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>
<label className="field-label">
Location or store
<input className="field-control" type="text" value={store} onChange={(event) => setStore(event.target.value)} placeholder="BP, Tesco, airport..." />
</label>
<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>
<label className="field-label sm:col-span-2">
Notes
<textarea className="field-control min-h-24 resize-y" value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Late drive, gym stop, exam fuel..." />
</label>
<div className="rounded-lg px-3 py-3 text-sm sm:col-span-2" style={{ border: "1px solid #d8e1ee", background: "#f7faff", color: "#3c4043" }}>
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: "#d8e1ee", background: "#ffffff" }}>
<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>
<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">
+55 -42
View File
@@ -1,6 +1,6 @@
import { Search, X } from "lucide-react";
import { Search, SlidersHorizontal, X } from "lucide-react";
import { useState } from "react";
import { DEFAULT_FILTERS } from "../lib/filters";
import { AppCard } from "./AppCard";
import type { DateFilter, Filters, Flavour } from "../types";
type FiltersPanelProps = {
@@ -11,50 +11,27 @@ type FiltersPanelProps = {
};
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 (
<AppCard title="Filters" subtitle={compact ? "Scope the charts" : "Search the logbook"}>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<label className="field-label">
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>
<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>
<label className="field-label">
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">
From
<input className="field-control" type="date" value={filters.from} onChange={(event) => set("from", event.target.value)} />
</label>
<label className="field-label">
To
<input className="field-control" type="date" value={filters.to} onChange={(event) => set("to", event.target.value)} />
</label>
</>
)}
<label className="field-label sm:col-span-2 xl:col-span-1">
<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" />
@@ -68,11 +45,47 @@ export function FiltersPanel({ filters, flavours, compact = false, onChange }: F
</span>
</label>
<button className="secondary-button sm:col-span-2 xl:col-span-1" type="button" onClick={() => onChange(DEFAULT_FILTERS)}>
<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>
</AppCard>
</section>
);
}
+2
View File
@@ -1,5 +1,6 @@
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";
@@ -58,6 +59,7 @@ export function GreetingPanel({ summary, user, userLimits, limitCheck, onAdd, on
<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>
+34 -6
View File
@@ -3,7 +3,7 @@ import { FileSpreadsheet, Loader2, X } from "lucide-react";
import { humanDateTime } from "../lib/metrics";
import { MiniMetric } from "./MetricTiles";
import { Modal } from "./Modal";
import type { ImportPreview } from "../types";
import type { ImportPreview, ImportPreviewRow } from "../types";
type ImportPreviewModalProps = {
busy: boolean;
@@ -50,7 +50,7 @@ export function ImportPreviewModal({
<MiniMetric label="Invalid" value={`${invalidRows.length}`} accent="#ff3448" />
</div>
<div className="max-h-[48vh] overflow-auto rounded-lg border border-white/10">
<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>
@@ -68,21 +68,37 @@ export function ImportPreviewModal({
<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">
<span className={`rounded px-2 py-1 text-xs font-semibold ${row.errors.length ? "bg-red-500/15 text-red-100" : row.duplicate ? "bg-amber-300/15 text-amber-100" : "bg-cyan-300/15 text-cyan-100"}`}>
{row.errors.length ? "Invalid" : row.duplicate ? "Duplicate" : "Ready"}
</span>
<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">{row.errors.join(" ") || row.duplicateReason || "Looks good."}</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:justify-end">
<button className="secondary-button justify-center" type="button" onClick={onClose}>
Cancel
@@ -97,3 +113,15 @@ export function ImportPreviewModal({
</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.";
}
+11 -4
View File
@@ -115,9 +115,9 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
onClick={() => handleCanSizeChange(size)}
className="rounded-full border px-4 py-2 text-sm transition"
style={{
borderColor: isActive ? "var(--primary, #2563eb)" : "#cbd5e1",
background: isActive ? "#eff6ff" : "white",
color: isActive ? "#1d4ed8" : "#475569",
borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
background: isActive ? "var(--primary-container)" : "var(--surface-container-lowest)",
color: isActive ? "var(--on-primary-container)" : "var(--muted)",
}}
>
{size}ml ({currency.format(priceForLimitSize(size))})
@@ -174,7 +174,14 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
</label>
{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(" · ")}
</p>
) : null}
+25 -16
View File
@@ -1,5 +1,7 @@
import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react";
import { StatHint } from "./StatHint";
import { Card } from "./ui";
type MetricTileProps = {
icon: LucideIcon;
@@ -11,30 +13,34 @@ type MetricTileProps = {
export function MetricTile({ icon: Icon, label, value, detail, accent }: MetricTileProps) {
return (
<motion.article
className="glass-panel metric-tile"
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22 }}
>
<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>
<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>
<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>
</motion.article>
<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" title={statHint(label)}>
<p className="mini-metric-label">{label}</p>
<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>
@@ -44,8 +50,11 @@ export function MiniMetric({ label, value, accent }: { label: string; value: str
export function WellnessPill({ label, value }: { label: string; value: string }) {
return (
<div className="wellness-pill" title={statHint(label)}>
<span>{label}</span>
<div className="wellness-pill">
<span>
{label}
<StatHint label={label} hint={statHint(label)} />
</span>
<strong>{value}</strong>
</div>
);
+35 -13
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo, useState, type KeyboardEvent } from "react";
import {
Area,
AreaChart,
@@ -22,6 +22,8 @@ type ForecastPoint = {
limit?: number;
};
const FORECAST_WINDOWS = [7, 30, 90, 365] as const;
type SpendForecastCardProps = {
entries: RedBullEntry[];
userLimits: UserLimits;
@@ -99,21 +101,41 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
});
};
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: "#d8e1ee" }}>
<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="tablist">
{([7, 30, 90, 365] as const).map((days) => (
<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="tab"
aria-selected={projectionDays === days}
role="radio"
aria-checked={projectionDays === days}
tabIndex={projectionDays === days ? 0 : -1}
onClick={() => setProjectionDays(days)}
className={projectionDays === days ? "segmented-control-active" : ""}
>
@@ -150,7 +172,7 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
type="button"
onClick={saveLowerLimit}
className="forecast-stat-note mt-1 block text-left underline"
style={{ color: "#0d652d" }}
style={{ color: "var(--success)" }}
>
Lock daily limit to {currency.format(stats.avgDailySpend * 0.8)}/day
</button>
@@ -158,7 +180,7 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
</div>
</div>
<div className="forecast-chart-wrap relative">
<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>
@@ -167,12 +189,12 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
<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="#10b981" stopOpacity={0.15} />
<stop offset="100%" stopColor="#10b981" stopOpacity={0.0} />
<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} />
<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
@@ -187,7 +209,7 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
type="monotone"
dataKey="lower"
name="20 percent lower"
stroke="#10b981"
stroke="var(--success)"
fill="url(#optimalProj)"
strokeWidth={3}
strokeDasharray="4 4"
@@ -197,7 +219,7 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
type="monotone"
dataKey="limit"
name="daily limit"
stroke="#f59e0b"
stroke="var(--warning)"
strokeWidth={2}
dot={false}
strokeDasharray="6 6"
+53
View File
@@ -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>
);
}
+3 -4
View File
@@ -40,18 +40,17 @@ export function ThemePicker({ themeId, themeMode, onChange, onThemeModeChange }:
<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: "#fff" }}>
<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="listbox" aria-label="App themes">
<div className="theme-picker-grid" role="group" aria-label="App themes">
{APP_THEMES.map((theme) => (
<button
key={theme.id}
type="button"
role="option"
aria-selected={themeId === theme.id}
aria-pressed={themeId === theme.id}
className={`theme-tile ${themeId === theme.id ? "theme-tile-active" : ""}`}
onClick={() => onChange(theme.id)}
>
+33 -5
View File
@@ -1,4 +1,4 @@
import { Loader2, LogIn } from "lucide-react";
import { Eye, EyeOff, Loader2, LogIn } from "lucide-react";
import { useState, type CSSProperties, type FormEvent } from "react";
import { LegalFootnote } from "../LegalFootnote";
import { ShellBackdrop } from "../ShellBackdrop";
@@ -31,6 +31,7 @@ export function AuthView({
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -71,20 +72,47 @@ export function AuthView({
{mode === "signup" && (
<label className="field-label">
Name
<input className="field-control" type="text" value={name} onChange={(event) => setName(event.target.value)} placeholder="Ned" />
<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" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="you@example.com" required />
<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
<input className="field-control" minLength={8} type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="8+ characters" required />
<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 #ffc9c2", background: "#fff3f1", color: "#9f1c16" }}>
<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>
)}
+11 -1
View File
@@ -13,6 +13,7 @@ type LogbookViewProps = {
onAdd: () => void;
onEdit: (entry: RedBullEntry) => void;
onDelete: (id: string) => void;
onLogAgain?: (entry: RedBullEntry) => void;
};
export function LogbookView({
@@ -25,12 +26,21 @@ export function LogbookView({
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} deletingId={deletingId} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />
<EntryLedger
entries={entries}
totalEntries={totalEntries}
deletingId={deletingId}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
onLogAgain={onLogAgain}
/>
</div>
<LegalFootnote />
</section>
+9 -3
View File
@@ -68,6 +68,12 @@ export function OverviewView({
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">
@@ -84,7 +90,7 @@ export function OverviewView({
{limitCheck.violations.length ? (
<section className="limit-alert">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 shrink-0" size={20} aria-hidden="true" style={{ color: "#b06000" }} />
<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">
@@ -111,7 +117,7 @@ export function OverviewView({
<section className="overview-charts-grid grid gap-4">
<AppCard title="Spend overview" subtitle="Last 30 logged days">
{chartData.length ? (
<div className="chart-shell chart-shell--area">
<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>
@@ -159,7 +165,7 @@ export function OverviewView({
<section className="grid gap-4">
<AppCard title="Flavour mix" subtitle="Cans by flavour">
{flavourData.length ? (
<div className="chart-shell chart-shell--pie">
<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}>
+37 -29
View File
@@ -60,8 +60,8 @@ export function SettingsView({
}: SettingsViewProps) {
return (
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
<div className="grid gap-4">
<AppCard title="Daily limits" subtitle="Personal caps for cans, spend, and stop time (BST)">
<div className="grid content-start gap-4">
<AppCard title="Limits" subtitle="Personal caps for cans, spend, and stop time (BST)">
<LimitsSettingsForm
limits={userLimits}
check={limitCheck}
@@ -80,10 +80,32 @@ export function SettingsView({
</div>
</AppCard>
<AppCard title="Appearance" subtitle={`${activeTheme.label} theme active`}>
<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} />
@@ -91,7 +113,7 @@ export function SettingsView({
<MiniMetric label="Favourite" value={summary.favouriteFlavour} accent={MATERIAL_ACCENTS.secondary} />
</div>
<div className="mt-5 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<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
@@ -123,34 +145,20 @@ export function SettingsView({
<DataPair label="Collection" value={appwriteConfig.collectionId} />
</dl>
</div>
</AppCard>
<button className="danger-button mt-5 justify-center" type="button" onClick={onReset} disabled={!entries.length || Boolean(busyAction)}>
<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>
</AppCard>
</div>
<div className="grid gap-4">
<AppCard title="Account" subtitle="Signed in with Appwrite">
<div className="account-card">
<div className="account-avatar">{userInitial(user)}</div>
<div className="min-w-0">
<p className="truncate text-lg font-medium text-slate-950">{user?.name || "Appwrite user"}</p>
<p className="truncate text-sm text-slate-500">{user?.email}</p>
</div>
</div>
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.05] p-4">
<div className="flex items-center gap-2 text-sm text-slate-700">
{dataLoading ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Cloud size={16} aria-hidden="true" />}
{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>
</section>
</div>
<LegalFootnote className="mt-2" />
@@ -162,7 +170,7 @@ function DataPair({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1 sm:grid-cols-[110px_1fr]">
<dt className="text-slate-500">{label}</dt>
<dd className="truncate font-mono text-xs" style={{ color: "#174ea6" }}>{value}</dd>
<dd className="truncate font-mono text-xs" style={{ color: "var(--primary)" }}>{value}</dd>
</div>
);
}
+80 -49
View File
@@ -14,6 +14,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { currency, oneDecimal, wholeNumber } from "../../lib/metrics";
import { AppCard } from "../AppCard";
import { ChartTooltip } from "../ChartTooltip";
import { EmptyState } from "../EmptyState";
@@ -24,6 +25,15 @@ 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 }>;
@@ -49,32 +59,47 @@ export function TrendsView({
onFilterChange,
onSaveLimits,
}: TrendsViewProps) {
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`}>
{chartData.length ? (
<ResponsiveContainer width="100%" height={340}>
<AreaChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
<defs>
<linearGradient id="trendSpend" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#39d5ff" stopOpacity={0.28} />
<stop offset="100%" stopColor="#39d5ff" stopOpacity={0.02} />
</linearGradient>
<linearGradient id="trendCans" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#ff3448" stopOpacity={0.2} />
<stop offset="100%" stopColor="#ff3448" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="rgba(203,213,225,0.12)" vertical={false} />
<XAxis dataKey="label" stroke="#94a3b8" tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" tickLine={false} axisLine={false} />
<Tooltip content={<ChartTooltip />} />
<Area type="monotone" dataKey="spend" name="Spend" stroke="#39d5ff" fill="url(#trendSpend)" strokeWidth={3} />
<Area type="monotone" dataKey="cans" name="Cans" stroke="#ff3448" fill="url(#trendCans)" strokeWidth={3} />
</AreaChart>
</ResponsiveContainer>
<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." />
)}
@@ -84,15 +109,17 @@ export function TrendsView({
<section className="grid gap-4 xl:grid-cols-2">
<AppCard title="Caffeine by day" subtitle="Estimated mg">
{chartData.length ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
<CartesianGrid stroke="rgba(203,213,225,0.12)" vertical={false} />
<XAxis dataKey="label" stroke="#94a3b8" tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" tickLine={false} axisLine={false} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="caffeine" name="Caffeine" fill="#39d5ff" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<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." />
)}
@@ -100,16 +127,18 @@ export function TrendsView({
<AppCard title="Weekly comparison" subtitle="Spend and cans">
{weekData.length ? (
<ResponsiveContainer width="100%" height={300}>
<RechartsLineChart data={weekData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
<CartesianGrid stroke="rgba(203,213,225,0.12)" vertical={false} />
<XAxis dataKey="label" stroke="#94a3b8" tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" tickLine={false} axisLine={false} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey="spend" name="Spend" stroke="#ffd84d" strokeWidth={3} dot={{ r: 3 }} />
<Line type="monotone" dataKey="cans" name="Cans" stroke="#ffb7d9" strokeWidth={3} dot={{ r: 3 }} />
</RechartsLineChart>
</ResponsiveContainer>
<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." />
)}
@@ -119,16 +148,18 @@ export function TrendsView({
<section className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
<AppCard title="Flavour split" subtitle="Cans by flavour">
{flavourData.length ? (
<ResponsiveContainer width="100%" height={320}>
<PieChart>
<Pie data={flavourData} dataKey="value" nameKey="name" innerRadius={76} outerRadius={118} paddingAngle={4} stroke="#080d1f" strokeWidth={4}>
{flavourData.map((entry) => (
<Cell key={entry.name} fill={entry.accent} />
))}
</Pie>
<Tooltip content={<ChartTooltip />} />
</PieChart>
</ResponsiveContainer>
<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." />
)}
+267 -6
View File
@@ -1397,7 +1397,7 @@ textarea:focus-visible {
@media (min-width: 640px) {
.hero-stat-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
@@ -1476,11 +1476,6 @@ textarea:focus-visible {
.today-stat-value {
font-size: clamp(3rem, 6vw, 5rem);
}
.logbook-layout {
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
align-items: start;
}
}
@media (min-width: 1024px) {
@@ -1714,3 +1709,269 @@ textarea:focus-visible {
padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
}
}
/* --- View redesign additions (A5 / B2.2 / B2.3 / B3.4) --- */
/* Floating add button: mobile only, sits above the bottom nav, safe-area aware. */
.fab-add {
position: fixed;
right: max(16px, env(safe-area-inset-right, 0px));
bottom: calc(96px + env(safe-area-inset-bottom, 0px));
z-index: 45;
display: grid;
width: 56px;
height: 56px;
place-items: center;
color: var(--on-primary, #ffffff);
background: var(--primary, #2563c7);
border: 0;
border-radius: 999px;
box-shadow: var(--elevation-2, 0 12px 34px rgba(60, 64, 67, 0.16));
transition: background 0.16s ease, transform 0.16s ease;
}
.fab-add:hover {
background: color-mix(in srgb, var(--primary, #2563c7) 90%, #000000);
}
.fab-add:active {
transform: scale(0.94);
}
@media (min-width: 1024px) {
.fab-add {
display: none;
}
}
/* Promoted quick-add row: thumb-friendly targets with a primary icon chip. */
.quick-add-button {
min-height: 60px;
}
.quick-add-icon {
color: var(--on-primary, #ffffff);
background: var(--primary, #2563c7);
}
/* Limit hero rows: state-tinted borders on top of the progress fill colours. */
.limit-row--warn {
border-color: color-mix(in srgb, var(--warning, #8d5700) 42%, transparent);
}
.limit-row--over {
border-color: color-mix(in srgb, var(--error, #ba1a1a) 42%, transparent);
}
.limit-progress-fill {
transition: width 0.3s ease;
}
/* Compact filter bar: collapses behind a toggle on mobile, single row on desktop. */
.filter-bar {
display: grid;
gap: 12px;
padding: 12px 14px;
background: var(--surface-container-lowest, #ffffff);
border: 1px solid var(--outline-variant, #d8e1ee);
border-radius: 22px;
box-shadow: var(--elevation-1, 0 1px 2px rgba(60, 64, 67, 0.08));
}
.filter-bar-toggle {
display: inline-flex;
min-height: 40px;
align-items: center;
justify-self: start;
gap: 8px;
padding: 0 14px;
color: var(--text, #202124);
background: var(--surface-container-low, #edf2fa);
border: 1px solid var(--outline-variant, #d8e1ee);
border-radius: 999px;
font-size: 13px;
font-weight: 500;
}
.filter-bar-controls {
display: grid;
gap: 10px;
}
.filter-bar-controls[data-open="false"] {
display: none;
}
@media (min-width: 768px) {
.filter-bar-toggle {
display: none;
}
.filter-bar-controls,
.filter-bar-controls[data-open="false"] {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
}
.filter-bar-field {
flex: 1 1 150px;
min-width: 140px;
}
.filter-bar-field--search {
flex: 2 1 220px;
}
.filter-bar-clear {
flex: 0 0 auto;
}
}
/* Logbook day groups. */
.day-group {
display: grid;
gap: 8px;
}
.day-group-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 4px 12px;
padding: 0 2px;
}
.day-group-title {
color: var(--text, #202124);
font-size: 14px;
font-weight: 600;
}
.day-group-subtotals {
color: var(--muted, #5f6670);
font-size: 12px;
}
/* Entry row layout: content left, actions right on wider screens. */
.entry-row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.entry-log-again {
min-height: 36px;
padding: 0 14px;
font-size: 12px;
}
@media (min-width: 640px) {
.entry-row {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.entry-row-actions {
justify-content: flex-end;
}
}
/* Stat hint popover (replaces title-attribute tooltips). */
.stat-hint {
position: relative;
display: inline-flex;
margin-left: 4px;
vertical-align: middle;
}
.stat-hint-trigger {
display: inline-flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
padding: 0;
color: var(--subtle, #80868b);
background: transparent;
border: 0;
border-radius: 999px;
}
.stat-hint-trigger:hover {
color: var(--primary, #2563c7);
background: color-mix(in srgb, var(--primary-container, #dbe9ff) 80%, transparent);
}
.stat-hint-popover {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 60;
width: max-content;
max-width: 220px;
padding: 8px 10px;
color: var(--text, #202124);
background: var(--surface-container-lowest, #ffffff);
border: 1px solid var(--outline-variant, #d8e1ee);
border-radius: 12px;
box-shadow: var(--elevation-2, 0 12px 28px rgba(60, 64, 67, 0.14));
font-size: 12px;
font-weight: 400;
line-height: 1.4;
text-align: left;
}
/* Entry modal field groups. */
.entry-fieldset {
display: grid;
gap: 12px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.entry-fieldset-legend {
padding: 0;
color: var(--primary, #2563c7);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* Settings danger zone. */
.danger-zone {
border-color: color-mix(in srgb, var(--error, #ba1a1a) 38%, transparent);
background: color-mix(in srgb, var(--error-container, #ffdad6) 22%, var(--surface-container-lowest, #ffffff));
}
.danger-zone-title {
color: var(--error, #ba1a1a);
}
/* Password visibility toggle. */
.password-toggle {
position: absolute;
top: 50%;
right: 6px;
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
padding: 0;
color: var(--muted, #5f6670);
background: transparent;
border: 0;
border-radius: 999px;
transform: translateY(-50%);
}
.password-toggle:hover {
color: var(--text, #202124);
background: color-mix(in srgb, var(--text, #202124) 6%, transparent);
}