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 { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { flavourMeta, mergedFlavours } from "./data/flavours";
|
import { flavourMeta, mergedFlavours } from "./data/flavours";
|
||||||
import { QUICK_ADDS, type QuickAddItem } from "./data/quickAdds";
|
import { QUICK_ADDS, type QuickAddItem } from "./data/quickAdds";
|
||||||
@@ -323,6 +324,24 @@ function App() {
|
|||||||
setIsEntryModalOpen(true);
|
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) {
|
async function saveUserLimits(next: UserLimits) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
setBusyAction("save-limits");
|
setBusyAction("save-limits");
|
||||||
@@ -677,6 +696,7 @@ function App() {
|
|||||||
setIsEntryModalOpen(true);
|
setIsEntryModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onDelete={(id) => setPendingDeleteId(id)}
|
onDelete={(id) => setPendingDeleteId(id)}
|
||||||
|
onLogAgain={logEntryAgain}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -727,6 +747,12 @@ function App() {
|
|||||||
|
|
||||||
<MobileNav activeView={activeView} onChange={setActiveView} />
|
<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
|
<EntryModal
|
||||||
entry={editingEntry}
|
entry={editingEntry}
|
||||||
initialDraft={entryInitialDraft}
|
initialDraft={entryInitialDraft}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export function ChartTooltip({ active, payload, label }: ChartTooltipProps) {
|
|||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="chart-tooltip">
|
<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) => (
|
{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)}
|
<span style={{ color: item.color }}>{item.name}</span>: {formatMetricValue(item.name, item.value)}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -96,7 +96,14 @@ function LimitRow({
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<strong>{value}</strong>
|
<strong>{value}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="limit-progress" aria-hidden="true">
|
<div
|
||||||
|
className="limit-progress"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={`${label}: ${value}`}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={progress}
|
||||||
|
>
|
||||||
<div className="limit-progress-fill" style={{ width: `${progress}%` }} />
|
<div className="limit-progress-fill" style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+118
-20
@@ -1,6 +1,14 @@
|
|||||||
import { Edit3, Loader2, Trash2 } from "lucide-react";
|
import { Edit3, Loader2, RotateCcw, Trash2 } from "lucide-react";
|
||||||
import type { CSSProperties } from "react";
|
import { useMemo, type CSSProperties } from "react";
|
||||||
import { caffeineFor, currency, humanDateTime, oneDecimal, spendFor, sugarFor, wholeNumber } from "../lib/metrics";
|
import {
|
||||||
|
caffeineFor,
|
||||||
|
currency,
|
||||||
|
formatDateKey,
|
||||||
|
oneDecimal,
|
||||||
|
spendFor,
|
||||||
|
sugarFor,
|
||||||
|
wholeNumber,
|
||||||
|
} from "../lib/metrics";
|
||||||
import { AppCard } from "./AppCard";
|
import { AppCard } from "./AppCard";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
import type { RedBullEntry } from "../types";
|
import type { RedBullEntry } from "../types";
|
||||||
@@ -12,15 +20,46 @@ type EntryLedgerProps = {
|
|||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (entry: RedBullEntry) => void;
|
onEdit: (entry: RedBullEntry) => void;
|
||||||
onDelete: (id: string) => 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 (
|
return (
|
||||||
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
|
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
|
||||||
{entries.length ? (
|
{entries.length ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{dayGroups.map((group) => (
|
||||||
|
<section className="day-group" key={group.key} aria-label={group.label}>
|
||||||
|
<div className="day-group-header">
|
||||||
|
<h3 className="day-group-title">{group.label}</h3>
|
||||||
|
<p className="day-group-subtotals">
|
||||||
|
{oneDecimal.format(group.cans)} can{group.cans === 1 ? "" : "s"} · {wholeNumber.format(group.caffeine)}mg · {currency.format(group.spend)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{entries.map((entry) => (
|
{group.entries.map((entry) => (
|
||||||
<EntryRow key={entry.id} entry={entry} deleting={deletingId === entry.id} onEdit={onEdit} onDelete={onDelete} />
|
<EntryRow
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
deleting={deletingId === entry.id}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onLogAgain={onLogAgain}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -35,45 +74,48 @@ function EntryRow({
|
|||||||
deleting,
|
deleting,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onLogAgain,
|
||||||
}: {
|
}: {
|
||||||
entry: RedBullEntry;
|
entry: RedBullEntry;
|
||||||
deleting: boolean;
|
deleting: boolean;
|
||||||
onEdit: (entry: RedBullEntry) => void;
|
onEdit: (entry: RedBullEntry) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onLogAgain?: (entry: RedBullEntry) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<article className="entry-row" style={{ "--accent": entry.flavourAccent } as CSSProperties}>
|
<article className="entry-row" style={{ "--accent": entry.flavourAccent } as CSSProperties}>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
<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>
|
<h3 className="entry-title">{entry.flavour}</h3>
|
||||||
<span className="entry-chip">
|
<span className="entry-chip">
|
||||||
{entry.cans} can{entry.cans === 1 ? "" : "s"} · {entry.sizeMl}ml
|
{entry.cans} can{entry.cans === 1 ? "" : "s"} · {entry.sizeMl}ml
|
||||||
</span>
|
</span>
|
||||||
<span className="source-badge">
|
<span className="source-badge">{sourceLabel(entry.source)}</span>
|
||||||
{entry.source}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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">
|
<p className="entry-summary mt-2">
|
||||||
{currency.format(spendFor(entry))} · {wholeNumber.format(caffeineFor(entry))}mg caffeine · {oneDecimal.format(sugarFor(entry))}g sugar
|
{currency.format(spendFor(entry))} · {wholeNumber.format(caffeineFor(entry))}mg caffeine · {oneDecimal.format(sugarFor(entry))}g sugar
|
||||||
</p>
|
</p>
|
||||||
{(entry.store || entry.notes) && (
|
{entry.notes ? <p className="entry-meta mt-2 leading-6">{entry.notes}</p> : null}
|
||||||
<p className="entry-meta mt-2 leading-6">
|
|
||||||
{entry.store ? `${entry.store}` : ""}
|
|
||||||
{entry.store && entry.notes ? " · " : ""}
|
|
||||||
{entry.notes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</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`}>
|
<button className="icon-button" type="button" onClick={() => onEdit(entry)} aria-label={`Edit ${entry.flavour} entry`}>
|
||||||
<Edit3 size={17} aria-hidden="true" />
|
<Edit3 size={17} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
type="button"
|
type="button"
|
||||||
style={{ color: "#9f1c16" }}
|
style={{ color: "var(--error)" }}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
onClick={() => onDelete(entry.id)}
|
onClick={() => onDelete(entry.id)}
|
||||||
aria-label={`Delete ${entry.flavour} entry`}
|
aria-label={`Delete ${entry.flavour} entry`}
|
||||||
@@ -84,3 +126,59 @@ function EntryRow({
|
|||||||
</article>
|
</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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MATERIAL_ACCENTS } from "../data/accents";
|
|||||||
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours";
|
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours";
|
||||||
import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics";
|
import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
|
import { Field } from "./ui";
|
||||||
import type { EntryDraft, Flavour, RedBullEntry } from "../types";
|
import type { EntryDraft, Flavour, RedBullEntry } from "../types";
|
||||||
|
|
||||||
type EntryModalProps = {
|
type EntryModalProps = {
|
||||||
@@ -68,6 +69,9 @@ export function EntryModal({
|
|||||||
numericSize,
|
numericSize,
|
||||||
sizePreset === "custom" && caffeineOverride.trim() ? Number(caffeineOverride) : undefined,
|
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 draftPreview = useMemo(() => {
|
||||||
const numericCans = Math.max(0.25, Number(cans) || 1);
|
const numericCans = Math.max(0.25, Number(cans) || 1);
|
||||||
@@ -85,7 +89,7 @@ export function EntryModal({
|
|||||||
flavourAccent: isOther ? customAccent || accentForCustomFlavour(finalFlavour) : meta.accent,
|
flavourAccent: isOther ? customAccent || accentForCustomFlavour(finalFlavour) : meta.accent,
|
||||||
sizeMl: numericSize,
|
sizeMl: numericSize,
|
||||||
pricePerCan: numericPrice,
|
pricePerCan: numericPrice,
|
||||||
dateTime: new Date(dateTime).toISOString(),
|
dateTime: dateValid ? new Date(dateTime).toISOString() : "",
|
||||||
notes: notes.trim(),
|
notes: notes.trim(),
|
||||||
store: store.trim(),
|
store: store.trim(),
|
||||||
sugarFree: sugarFree || Boolean(meta.sugarFree),
|
sugarFree: sugarFree || Boolean(meta.sugarFree),
|
||||||
@@ -106,13 +110,14 @@ export function EntryModal({
|
|||||||
sugarFree,
|
sugarFree,
|
||||||
sizePreset,
|
sizePreset,
|
||||||
caffeineOverride,
|
caffeineOverride,
|
||||||
|
dateValid,
|
||||||
entry?.source,
|
entry?.source,
|
||||||
initialDraft?.source,
|
initialDraft?.source,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function submit(event: FormEvent<HTMLFormElement>) {
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!draftPreview) return;
|
if (!draftPreview || !dateValid) return;
|
||||||
onSave(draftPreview);
|
onSave(draftPreview);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,20 +143,20 @@ export function EntryModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<fieldset className="entry-fieldset">
|
||||||
|
<legend className="entry-fieldset-legend">What</legend>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<label className="field-label">
|
<Field id="entry-cans" label="Number of cans">
|
||||||
Number of cans
|
{(controlProps) => (
|
||||||
<input ref={firstFieldRef} className="field-control" min="0.25" step="0.25" type="number" value={cans} onChange={(event) => setCans(event.target.value)} required />
|
<input ref={firstFieldRef} {...controlProps} className="field-control" min="0.25" step="0.25" type="number" value={cans} onChange={(event) => setCans(event.target.value)} required />
|
||||||
</label>
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
<label className="field-label">
|
<Field id="entry-flavour" label="Flavour">
|
||||||
Price per can
|
{(controlProps) => (
|
||||||
<input className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field-label">
|
|
||||||
Flavour
|
|
||||||
<select
|
<select
|
||||||
|
{...controlProps}
|
||||||
className="field-control"
|
className="field-control"
|
||||||
value={selectedFlavour}
|
value={selectedFlavour}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -168,11 +173,28 @@ export function EntryModal({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
<label className="field-label">
|
{isOther && (
|
||||||
Can size
|
<>
|
||||||
|
<Field id="entry-custom-flavour" label="Custom flavour">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" type="text" value={customFlavour} onChange={(event) => setCustomFlavour(event.target.value)} placeholder="Fig Apple, Sea Blue..." />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field id="entry-custom-accent" label="Accent colour">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control h-12 p-1" type="color" value={customAccent} onChange={(event) => setCustomAccent(event.target.value)} aria-label="Custom flavour accent colour" />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field id="entry-size" label="Can size">
|
||||||
|
{(controlProps) => (
|
||||||
<select
|
<select
|
||||||
|
{...controlProps}
|
||||||
className="field-control"
|
className="field-control"
|
||||||
value={sizePreset}
|
value={sizePreset}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -191,58 +213,74 @@ export function EntryModal({
|
|||||||
<option value="473">473ml</option>
|
<option value="473">473ml</option>
|
||||||
<option value="custom">Custom</option>
|
<option value="custom">Custom</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
|
||||||
|
|
||||||
{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..." />
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
{sizePreset === "custom" && (
|
{sizePreset === "custom" && (
|
||||||
<>
|
<>
|
||||||
<label className="field-label">
|
<Field id="entry-custom-size" label="Custom size in ml">
|
||||||
Custom size in ml
|
{(controlProps) => (
|
||||||
<input className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
<input {...controlProps} className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
||||||
</label>
|
)}
|
||||||
<label className="field-label">
|
</Field>
|
||||||
Caffeine override mg/can
|
<Field id="entry-caffeine-override" 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))} />
|
{(controlProps) => (
|
||||||
</label>
|
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="field-label">
|
<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)" }}>
|
||||||
Date and time
|
|
||||||
<input className="field-control" type="datetime-local" value={dateTime} onChange={(event) => setDateTime(event.target.value)} required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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
|
Estimated caffeine per can: {wholeNumber.format(caffeinePreview)}mg
|
||||||
</div>
|
</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" }}>
|
<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)} />
|
<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
|
Count this entry as sugar-free / zero sugar
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="entry-fieldset">
|
||||||
|
<legend className="entry-fieldset-legend">When</legend>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field id="entry-datetime" label="Date and time" error={dateError}>
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" type="datetime-local" value={dateTime} onChange={(event) => setDateTime(event.target.value)} required />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="entry-fieldset">
|
||||||
|
<legend className="entry-fieldset-legend">Where</legend>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Field id="entry-store" label="Location or store">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" type="text" value={store} onChange={(event) => setStore(event.target.value)} placeholder="BP, Tesco, airport..." />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field id="entry-notes" label="Notes">
|
||||||
|
{(controlProps) => (
|
||||||
|
<textarea {...controlProps} className="field-control min-h-24 resize-y" value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Late drive, gym stop, exam fuel..." />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="entry-fieldset">
|
||||||
|
<legend className="entry-fieldset-legend">Price</legend>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field id="entry-price" label="Price per can">
|
||||||
|
{(controlProps) => (
|
||||||
|
<input {...controlProps} className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<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}>
|
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
||||||
|
|||||||
@@ -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 { DEFAULT_FILTERS } from "../lib/filters";
|
||||||
import { AppCard } from "./AppCard";
|
|
||||||
import type { DateFilter, Filters, Flavour } from "../types";
|
import type { DateFilter, Filters, Flavour } from "../types";
|
||||||
|
|
||||||
type FiltersPanelProps = {
|
type FiltersPanelProps = {
|
||||||
@@ -11,50 +11,27 @@ type FiltersPanelProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function FiltersPanel({ filters, flavours, compact = false, onChange }: 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]) => {
|
const set = <Key extends keyof Filters>(key: Key, value: Filters[Key]) => {
|
||||||
onChange({ ...filters, [key]: value });
|
onChange({ ...filters, [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard title="Filters" subtitle={compact ? "Scope the charts" : "Search the logbook"}>
|
<section className="filter-bar" aria-label={title}>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
<button
|
||||||
<label className="field-label">
|
className="filter-bar-toggle"
|
||||||
Flavour
|
type="button"
|
||||||
<select className="field-control" value={filters.flavour} onChange={(event) => set("flavour", event.target.value)}>
|
aria-expanded={open}
|
||||||
<option value="all">All flavours</option>
|
onClick={() => setOpen((current) => !current)}
|
||||||
{flavours.map((flavour) => (
|
>
|
||||||
<option key={flavour.name} value={flavour.name}>
|
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||||
{flavour.name}
|
{title}
|
||||||
</option>
|
</button>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field-label">
|
<div className="filter-bar-controls" data-open={open}>
|
||||||
Date range
|
<label className="field-label filter-bar-field filter-bar-field--search">
|
||||||
<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">
|
|
||||||
Store or location
|
Store or location
|
||||||
<span className="relative">
|
<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" />
|
<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>
|
</span>
|
||||||
</label>
|
</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" />
|
<X size={17} aria-hidden="true" />
|
||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Activity, CalendarDays, Camera, Plus, PoundSterling, Zap } from "lucide-react";
|
import { Activity, CalendarDays, Camera, Plus, PoundSterling, Zap } from "lucide-react";
|
||||||
import { buildDynamicGreeting } from "../lib/greeting";
|
import { buildDynamicGreeting } from "../lib/greeting";
|
||||||
|
import { currency } from "../lib/metrics";
|
||||||
import { userInitial } from "../lib/userDisplay";
|
import { userInitial } from "../lib/userDisplay";
|
||||||
import { WellnessPill } from "./MetricTiles";
|
import { WellnessPill } from "./MetricTiles";
|
||||||
import type { Dashboard } from "../lib/dashboard";
|
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="Today" value={`${summary.todayCans} cans`} />
|
||||||
<WellnessPill label="Caffeine" value={summary.todayCaffeine} />
|
<WellnessPill label="Caffeine" value={summary.todayCaffeine} />
|
||||||
<WellnessPill label="Sugar" value={summary.todaySugar} />
|
<WellnessPill label="Sugar" value={summary.todaySugar} />
|
||||||
|
<WellnessPill label="Spend" value={currency.format(limitCheck.todaySpend)} />
|
||||||
<WellnessPill label="Streak" value={`${summary.currentStreak} days`} />
|
<WellnessPill label="Streak" value={`${summary.currentStreak} days`} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FileSpreadsheet, Loader2, X } from "lucide-react";
|
|||||||
import { humanDateTime } from "../lib/metrics";
|
import { humanDateTime } from "../lib/metrics";
|
||||||
import { MiniMetric } from "./MetricTiles";
|
import { MiniMetric } from "./MetricTiles";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import type { ImportPreview } from "../types";
|
import type { ImportPreview, ImportPreviewRow } from "../types";
|
||||||
|
|
||||||
type ImportPreviewModalProps = {
|
type ImportPreviewModalProps = {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
@@ -50,7 +50,7 @@ export function ImportPreviewModal({
|
|||||||
<MiniMetric label="Invalid" value={`${invalidRows.length}`} accent="#ff3448" />
|
<MiniMetric label="Invalid" value={`${invalidRows.length}`} accent="#ff3448" />
|
||||||
</div>
|
</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">
|
<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">
|
<thead className="sticky top-0 bg-[#0d142c] text-xs uppercase tracking-[0.14em] text-slate-400">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -68,21 +68,37 @@ export function ImportPreviewModal({
|
|||||||
<tr key={row.rowNumber} className="border-t border-white/10">
|
<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 text-slate-400">{row.rowNumber}</td>
|
||||||
<td className="px-3 py-3">
|
<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"}`}>
|
<ImportStatusBadge invalid={row.errors.length > 0} duplicate={row.duplicate} />
|
||||||
{row.errors.length ? "Invalid" : row.duplicate ? "Duplicate" : "Ready"}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-slate-300">{row.entry ? humanDateTime(row.entry.dateTime) : "-"}</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-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 ? `${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-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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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">
|
<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}>
|
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -97,3 +113,15 @@ export function ImportPreviewModal({
|
|||||||
</Modal>
|
</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)}
|
onClick={() => handleCanSizeChange(size)}
|
||||||
className="rounded-full border px-4 py-2 text-sm transition"
|
className="rounded-full border px-4 py-2 text-sm transition"
|
||||||
style={{
|
style={{
|
||||||
borderColor: isActive ? "var(--primary, #2563eb)" : "#cbd5e1",
|
borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
|
||||||
background: isActive ? "#eff6ff" : "white",
|
background: isActive ? "var(--primary-container)" : "var(--surface-container-lowest)",
|
||||||
color: isActive ? "#1d4ed8" : "#475569",
|
color: isActive ? "var(--on-primary-container)" : "var(--muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{size}ml ({currency.format(priceForLimitSize(size))})
|
{size}ml ({currency.format(priceForLimitSize(size))})
|
||||||
@@ -174,7 +174,14 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{previewParts.length ? (
|
{previewParts.length ? (
|
||||||
<p className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
|
<p
|
||||||
|
className="rounded-lg border px-3 py-2 text-sm"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--outline-variant)",
|
||||||
|
background: "var(--surface-container-low)",
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Today so far: {previewParts.join(" · ")}
|
Today so far: {previewParts.join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { StatHint } from "./StatHint";
|
||||||
|
import { Card } from "./ui";
|
||||||
|
|
||||||
type MetricTileProps = {
|
type MetricTileProps = {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
@@ -11,12 +13,12 @@ type MetricTileProps = {
|
|||||||
|
|
||||||
export function MetricTile({ icon: Icon, label, value, detail, accent }: MetricTileProps) {
|
export function MetricTile({ icon: Icon, label, value, detail, accent }: MetricTileProps) {
|
||||||
return (
|
return (
|
||||||
<motion.article
|
<motion.div
|
||||||
className="glass-panel metric-tile"
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.22 }}
|
transition={{ duration: 0.22 }}
|
||||||
>
|
>
|
||||||
|
<Card className="metric-tile">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="metric-tile-label">{label}</p>
|
<p className="metric-tile-label">{label}</p>
|
||||||
@@ -27,14 +29,18 @@ export function MetricTile({ icon: Icon, label, value, detail, accent }: MetricT
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="metric-tile-detail mt-4">{detail}</p>
|
<p className="metric-tile-detail mt-4">{detail}</p>
|
||||||
</motion.article>
|
</Card>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MiniMetric({ label, value, accent }: { label: string; value: string; accent: string }) {
|
export function MiniMetric({ label, value, accent }: { label: string; value: string; accent: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="mini-metric-card metric-soft" title={statHint(label)}>
|
<div className="mini-metric-card metric-soft">
|
||||||
<p className="mini-metric-label">{label}</p>
|
<p className="mini-metric-label">
|
||||||
|
{label}
|
||||||
|
<StatHint label={label} hint={statHint(label)} />
|
||||||
|
</p>
|
||||||
<p className="mini-metric-value truncate" style={{ color: accent }}>
|
<p className="mini-metric-value truncate" style={{ color: accent }}>
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
@@ -44,8 +50,11 @@ export function MiniMetric({ label, value, accent }: { label: string; value: str
|
|||||||
|
|
||||||
export function WellnessPill({ label, value }: { label: string; value: string }) {
|
export function WellnessPill({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="wellness-pill" title={statHint(label)}>
|
<div className="wellness-pill">
|
||||||
<span>{label}</span>
|
<span>
|
||||||
|
{label}
|
||||||
|
<StatHint label={label} hint={statHint(label)} />
|
||||||
|
</span>
|
||||||
<strong>{value}</strong>
|
<strong>{value}</strong>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
@@ -22,6 +22,8 @@ type ForecastPoint = {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FORECAST_WINDOWS = [7, 30, 90, 365] as const;
|
||||||
|
|
||||||
type SpendForecastCardProps = {
|
type SpendForecastCardProps = {
|
||||||
entries: RedBullEntry[];
|
entries: RedBullEntry[];
|
||||||
userLimits: UserLimits;
|
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 (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
title="Spend forecast"
|
title="Spend forecast"
|
||||||
subtitle={`${activePeriodDays} day average: ${currency.format(stats.avgDailySpend)} per day`}
|
subtitle={`${activePeriodDays} day average: ${currency.format(stats.avgDailySpend)} per day`}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<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>
|
<p className="app-card-subtitle">Forecast window</p>
|
||||||
<div className="segmented-control max-w-xs self-start" role="tablist">
|
<div
|
||||||
{([7, 30, 90, 365] as const).map((days) => (
|
className="segmented-control max-w-xs self-start"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Forecast window"
|
||||||
|
onKeyDown={onWindowKeyDown}
|
||||||
|
>
|
||||||
|
{FORECAST_WINDOWS.map((days) => (
|
||||||
<button
|
<button
|
||||||
key={days}
|
key={days}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="radio"
|
||||||
aria-selected={projectionDays === days}
|
aria-checked={projectionDays === days}
|
||||||
|
tabIndex={projectionDays === days ? 0 : -1}
|
||||||
onClick={() => setProjectionDays(days)}
|
onClick={() => setProjectionDays(days)}
|
||||||
className={projectionDays === days ? "segmented-control-active" : ""}
|
className={projectionDays === days ? "segmented-control-active" : ""}
|
||||||
>
|
>
|
||||||
@@ -150,7 +172,7 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={saveLowerLimit}
|
onClick={saveLowerLimit}
|
||||||
className="forecast-stat-note mt-1 block text-left underline"
|
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
|
Lock daily limit to {currency.format(stats.avgDailySpend * 0.8)}/day
|
||||||
</button>
|
</button>
|
||||||
@@ -158,7 +180,7 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="forecast-chart-wrap relative">
|
<div className="forecast-chart-wrap relative" role="img" aria-label={forecastChartLabel}>
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<AreaChart data={projectionData} margin={{ top: 12, right: 16, bottom: 0, left: -10 }}>
|
<AreaChart data={projectionData} margin={{ top: 12, right: 16, bottom: 0, left: -10 }}>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -167,12 +189,12 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
|
|||||||
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0.0} />
|
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0.0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="optimalProj" x1="0" x2="0" y1="0" y2="1">
|
<linearGradient id="optimalProj" x1="0" x2="0" y1="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#10b981" stopOpacity={0.15} />
|
<stop offset="0%" stopColor="var(--success)" stopOpacity={0.15} />
|
||||||
<stop offset="100%" stopColor="#10b981" stopOpacity={0.0} />
|
<stop offset="100%" stopColor="var(--success)" stopOpacity={0.0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
<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}`} />
|
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} tickFormatter={(val) => `£${val}`} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
<Area
|
<Area
|
||||||
@@ -187,7 +209,7 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="lower"
|
dataKey="lower"
|
||||||
name="20 percent lower"
|
name="20 percent lower"
|
||||||
stroke="#10b981"
|
stroke="var(--success)"
|
||||||
fill="url(#optimalProj)"
|
fill="url(#optimalProj)"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
strokeDasharray="4 4"
|
strokeDasharray="4 4"
|
||||||
@@ -197,7 +219,7 @@ export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendFo
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="limit"
|
dataKey="limit"
|
||||||
name="daily limit"
|
name="daily limit"
|
||||||
stroke="#f59e0b"
|
stroke="var(--warning)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeDasharray="6 6"
|
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-strip">
|
||||||
<div className="theme-preview-chip primary-button px-4 py-2 text-sm">Button</div>
|
<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 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
|
Chart
|
||||||
</div>
|
</div>
|
||||||
</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) => (
|
{APP_THEMES.map((theme) => (
|
||||||
<button
|
<button
|
||||||
key={theme.id}
|
key={theme.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
aria-pressed={themeId === theme.id}
|
||||||
aria-selected={themeId === theme.id}
|
|
||||||
className={`theme-tile ${themeId === theme.id ? "theme-tile-active" : ""}`}
|
className={`theme-tile ${themeId === theme.id ? "theme-tile-active" : ""}`}
|
||||||
onClick={() => onChange(theme.id)}
|
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 { useState, type CSSProperties, type FormEvent } from "react";
|
||||||
import { LegalFootnote } from "../LegalFootnote";
|
import { LegalFootnote } from "../LegalFootnote";
|
||||||
import { ShellBackdrop } from "../ShellBackdrop";
|
import { ShellBackdrop } from "../ShellBackdrop";
|
||||||
@@ -31,6 +31,7 @@ export function AuthView({
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
function submit(event: FormEvent<HTMLFormElement>) {
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -71,20 +72,47 @@ export function AuthView({
|
|||||||
{mode === "signup" && (
|
{mode === "signup" && (
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
Name
|
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>
|
||||||
)}
|
)}
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
Email
|
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>
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
Password
|
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>
|
</label>
|
||||||
|
|
||||||
{authError && (
|
{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}
|
{authError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type LogbookViewProps = {
|
|||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (entry: RedBullEntry) => void;
|
onEdit: (entry: RedBullEntry) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onLogAgain?: (entry: RedBullEntry) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LogbookView({
|
export function LogbookView({
|
||||||
@@ -25,12 +26,21 @@ export function LogbookView({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onLogAgain,
|
||||||
}: LogbookViewProps) {
|
}: LogbookViewProps) {
|
||||||
return (
|
return (
|
||||||
<section className="grid gap-4">
|
<section className="grid gap-4">
|
||||||
<div className="logbook-layout grid gap-4">
|
<div className="logbook-layout grid gap-4">
|
||||||
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} />
|
<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>
|
</div>
|
||||||
<LegalFootnote />
|
<LegalFootnote />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ export function OverviewView({
|
|||||||
userLimits.dailySpendLimit != null
|
userLimits.dailySpendLimit != null
|
||||||
? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today`
|
? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today`
|
||||||
: `${summary.monthSpend} this month`;
|
: `${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 (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
@@ -84,7 +90,7 @@ export function OverviewView({
|
|||||||
{limitCheck.violations.length ? (
|
{limitCheck.violations.length ? (
|
||||||
<section className="limit-alert">
|
<section className="limit-alert">
|
||||||
<div className="flex items-start gap-3">
|
<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>
|
<div>
|
||||||
<p className="limit-alert-title">Limit alerts</p>
|
<p className="limit-alert-title">Limit alerts</p>
|
||||||
<p className="limit-alert-copy mt-1">
|
<p className="limit-alert-copy mt-1">
|
||||||
@@ -111,7 +117,7 @@ export function OverviewView({
|
|||||||
<section className="overview-charts-grid grid gap-4">
|
<section className="overview-charts-grid grid gap-4">
|
||||||
<AppCard title="Spend overview" subtitle="Last 30 logged days">
|
<AppCard title="Spend overview" subtitle="Last 30 logged days">
|
||||||
{chartData.length ? (
|
{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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={chartData} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
|
<AreaChart data={chartData} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -159,7 +165,7 @@ export function OverviewView({
|
|||||||
<section className="grid gap-4">
|
<section className="grid gap-4">
|
||||||
<AppCard title="Flavour mix" subtitle="Cans by flavour">
|
<AppCard title="Flavour mix" subtitle="Cans by flavour">
|
||||||
{flavourData.length ? (
|
{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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={flavourData} dataKey="value" nameKey="name" innerRadius={70} outerRadius={104} paddingAngle={4} stroke="#080d1f" strokeWidth={4}>
|
<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) {
|
}: SettingsViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
|
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
|
||||||
<div className="grid gap-4">
|
<div className="grid content-start gap-4">
|
||||||
<AppCard title="Daily limits" subtitle="Personal caps for cans, spend, and stop time (BST)">
|
<AppCard title="Limits" subtitle="Personal caps for cans, spend, and stop time (BST)">
|
||||||
<LimitsSettingsForm
|
<LimitsSettingsForm
|
||||||
limits={userLimits}
|
limits={userLimits}
|
||||||
check={limitCheck}
|
check={limitCheck}
|
||||||
@@ -80,10 +80,32 @@ export function SettingsView({
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</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} />
|
<ThemePicker themeId={themeId} themeMode={themeMode} onChange={onThemeChange} onThemeModeChange={onThemeModeChange} />
|
||||||
</AppCard>
|
</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`}>
|
<AppCard title="Data & sync" subtitle={`${entries.length} entries synced for this user`}>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<MiniMetric label="All-time cans" value={summary.allTimeCans} accent={MATERIAL_ACCENTS.primary} />
|
<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} />
|
<MiniMetric label="Favourite" value={summary.favouriteFlavour} accent={MATERIAL_ACCENTS.secondary} />
|
||||||
</div>
|
</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}>
|
<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" />}
|
{dataLoading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <RefreshCcw size={17} aria-hidden="true" />}
|
||||||
Sync now
|
Sync now
|
||||||
@@ -123,34 +145,20 @@ export function SettingsView({
|
|||||||
<DataPair label="Collection" value={appwriteConfig.collectionId} />
|
<DataPair label="Collection" value={appwriteConfig.collectionId} />
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</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" />
|
<RotateCcw size={17} aria-hidden="true" />
|
||||||
Delete all entries
|
Delete all entries
|
||||||
</button>
|
</button>
|
||||||
</AppCard>
|
</section>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LegalFootnote className="mt-2" />
|
<LegalFootnote className="mt-2" />
|
||||||
@@ -162,7 +170,7 @@ function DataPair({ label, value }: { label: string; value: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-1 sm:grid-cols-[110px_1fr]">
|
<div className="grid gap-1 sm:grid-cols-[110px_1fr]">
|
||||||
<dt className="text-slate-500">{label}</dt>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { currency, oneDecimal, wholeNumber } from "../../lib/metrics";
|
||||||
import { AppCard } from "../AppCard";
|
import { AppCard } from "../AppCard";
|
||||||
import { ChartTooltip } from "../ChartTooltip";
|
import { ChartTooltip } from "../ChartTooltip";
|
||||||
import { EmptyState } from "../EmptyState";
|
import { EmptyState } from "../EmptyState";
|
||||||
@@ -24,6 +25,15 @@ import { SpendForecastCard } from "../SpendForecastCard";
|
|||||||
import type { Insight } from "../../lib/dashboard";
|
import type { Insight } from "../../lib/dashboard";
|
||||||
import type { Filters, Flavour, RedBullEntry, UserLimits } from "../../types";
|
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 = {
|
type TrendsViewProps = {
|
||||||
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
||||||
weekData: Array<{ label: string; spend: number; cans: number }>;
|
weekData: Array<{ label: string; spend: number; cans: number }>;
|
||||||
@@ -49,13 +59,27 @@ export function TrendsView({
|
|||||||
onFilterChange,
|
onFilterChange,
|
||||||
onSaveLimits,
|
onSaveLimits,
|
||||||
}: TrendsViewProps) {
|
}: 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 (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<section className="logbook-layout grid gap-4">
|
<section className="logbook-layout grid gap-4">
|
||||||
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} compact />
|
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} compact />
|
||||||
<AppCard title="Cans and spend" subtitle={`${entries.length} entries in view`}>
|
<AppCard title="Cans and spend" subtitle={`${entries.length} entries in view`}>
|
||||||
{chartData.length ? (
|
{chartData.length ? (
|
||||||
<ResponsiveContainer width="100%" height={340}>
|
<div role="img" aria-label={cansSpendLabel}>
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
<AreaChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
<AreaChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="trendSpend" x1="0" x2="0" y1="0" y2="1">
|
<linearGradient id="trendSpend" x1="0" x2="0" y1="0" y2="1">
|
||||||
@@ -67,14 +91,15 @@ export function TrendsView({
|
|||||||
<stop offset="100%" stopColor="#ff3448" stopOpacity={0.02} />
|
<stop offset="100%" stopColor="#ff3448" stopOpacity={0.02} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid stroke="rgba(203,213,225,0.12)" vertical={false} />
|
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||||
<XAxis dataKey="label" stroke="#94a3b8" tickLine={false} axisLine={false} />
|
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||||
<YAxis stroke="#94a3b8" tickLine={false} axisLine={false} />
|
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
<Area type="monotone" dataKey="spend" name="Spend" stroke="#39d5ff" fill="url(#trendSpend)" strokeWidth={3} />
|
<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} />
|
<Area type="monotone" dataKey="cans" name="Cans" stroke="#ff3448" fill="url(#trendCans)" strokeWidth={3} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="No trend data" copy="Filtered chart data appears here." />
|
<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">
|
<section className="grid gap-4 xl:grid-cols-2">
|
||||||
<AppCard title="Caffeine by day" subtitle="Estimated mg">
|
<AppCard title="Caffeine by day" subtitle="Estimated mg">
|
||||||
{chartData.length ? (
|
{chartData.length ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div role="img" aria-label={caffeineLabel}>
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
<BarChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
<BarChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||||
<CartesianGrid stroke="rgba(203,213,225,0.12)" vertical={false} />
|
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||||
<XAxis dataKey="label" stroke="#94a3b8" tickLine={false} axisLine={false} />
|
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||||
<YAxis stroke="#94a3b8" tickLine={false} axisLine={false} />
|
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
<Bar dataKey="caffeine" name="Caffeine" fill="#39d5ff" radius={[8, 8, 0, 0]} />
|
<Bar dataKey="caffeine" name="Caffeine" fill="#39d5ff" radius={[8, 8, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="No caffeine data" copy="Add entries to estimate caffeine over time." />
|
<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">
|
<AppCard title="Weekly comparison" subtitle="Spend and cans">
|
||||||
{weekData.length ? (
|
{weekData.length ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div role="img" aria-label={weekLabel}>
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
<RechartsLineChart data={weekData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
<RechartsLineChart data={weekData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||||
<CartesianGrid stroke="rgba(203,213,225,0.12)" vertical={false} />
|
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||||
<XAxis dataKey="label" stroke="#94a3b8" tickLine={false} axisLine={false} />
|
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||||
<YAxis stroke="#94a3b8" tickLine={false} axisLine={false} />
|
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
<Line type="monotone" dataKey="spend" name="Spend" stroke="#ffd84d" strokeWidth={3} dot={{ r: 3 }} />
|
<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 }} />
|
<Line type="monotone" dataKey="cans" name="Cans" stroke="#ffb7d9" strokeWidth={3} dot={{ r: 3 }} />
|
||||||
</RechartsLineChart>
|
</RechartsLineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="No weekly comparison" copy="Weekly comparisons appear as your history grows." />
|
<EmptyState title="No weekly comparison" copy="Weekly comparisons appear as your history grows." />
|
||||||
)}
|
)}
|
||||||
@@ -119,7 +148,8 @@ export function TrendsView({
|
|||||||
<section className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
|
<section className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
|
||||||
<AppCard title="Flavour split" subtitle="Cans by flavour">
|
<AppCard title="Flavour split" subtitle="Cans by flavour">
|
||||||
{flavourData.length ? (
|
{flavourData.length ? (
|
||||||
<ResponsiveContainer width="100%" height={320}>
|
<div role="img" aria-label={flavourLabel}>
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={flavourData} dataKey="value" nameKey="name" innerRadius={76} outerRadius={118} paddingAngle={4} stroke="#080d1f" strokeWidth={4}>
|
<Pie data={flavourData} dataKey="value" nameKey="name" innerRadius={76} outerRadius={118} paddingAngle={4} stroke="#080d1f" strokeWidth={4}>
|
||||||
{flavourData.map((entry) => (
|
{flavourData.map((entry) => (
|
||||||
@@ -129,6 +159,7 @@ export function TrendsView({
|
|||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="No flavour split" copy="Entries will form a flavour mix here." />
|
<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) {
|
@media (min-width: 640px) {
|
||||||
.hero-stat-row {
|
.hero-stat-row {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1476,11 +1476,6 @@ textarea:focus-visible {
|
|||||||
.today-stat-value {
|
.today-stat-value {
|
||||||
font-size: clamp(3rem, 6vw, 5rem);
|
font-size: clamp(3rem, 6vw, 5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-layout {
|
|
||||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -1714,3 +1709,269 @@ textarea:focus-visible {
|
|||||||
padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
|
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