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