Compare commits
5 Commits
b4a8bbb1df
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ab88d71d0 | |||
| 7a461af9ee | |||
| 7f56274e65 | |||
| 50ee708295 | |||
| 2c07f2c5b1 |
@@ -1,6 +1,6 @@
|
||||
# 🐂 Red Bull Intake Tracker
|
||||
|
||||
Track your Red Bull consumption with per-can logging, barcode scanning, spending insights, and an AI-powered coach. Built with React, Appwrite, and Material You theming.
|
||||
Track your Red Bull consumption with per-can logging, barcode scanning, spending insights, and a coach. Built with React, Appwrite, and Material You theming.
|
||||
|
||||
   
|
||||
|
||||
@@ -9,7 +9,6 @@ Track your Red Bull consumption with per-can logging, barcode scanning, spending
|
||||
- **Quick logging** — tap a flavour, pick a size, done. Cans are tracked with timestamp, price, and store
|
||||
- **Barcode scanning** — scan any Red Bull can (EAN-13/EAN-8/UPC-A) and it auto-fills flavour, size, and caffeine. 475+ verified barcodes built in, with user overrides
|
||||
- **20 built-in flavours** — Original, Zero, Ruby, Tropical, Dragon Fruit, and more, each with its own accent colour
|
||||
- **AI coach** — ChatGPT-style chat interface powered by Ollama, keeps per-session context and gives caffeine/spending advice
|
||||
- **Daily limits** — set max cans/day, max spend/day, and a cut-off time. Get warned when you're about to breach
|
||||
- **Charts & analytics** — intake over time, flavour breakdown (pie chart), spending trends, caffeine metrics
|
||||
- **Import** — bulk import from Excel (.xlsx) or JSON, with duplicate detection and row-level error preview
|
||||
@@ -26,7 +25,6 @@ Track your Red Bull consumption with per-can logging, barcode scanning, spending
|
||||
| Styling | Tailwind CSS, Framer Motion |
|
||||
| Charts | Recharts |
|
||||
| Backend | Appwrite Cloud (auth, database, storage) |
|
||||
| AI | Ollama (via server proxy) |
|
||||
| Barcode | @zxing/browser |
|
||||
| Import/Export | ExcelJS |
|
||||
|
||||
@@ -77,11 +75,9 @@ The app runs at `http://localhost:5173`.
|
||||
| `VITE_APPWRITE_PROJECT_ID` | Yes | Your Appwrite project ID |
|
||||
| `VITE_APPWRITE_DATABASE_ID` | Yes | Database ID (default: `redbull_tracker`) |
|
||||
| `VITE_APPWRITE_COLLECTION_ID` | Yes | Intake entries collection ID |
|
||||
| `VITE_APPWRITE_CHAT_COLLECTION_ID` | Yes | Coach chats collection ID |
|
||||
|
||||
| `VITE_OLLAMA_PROXY_URL` | No | AI coach proxy endpoint |
|
||||
| `OLLAMA_API_KEY` | No | Server-side Ollama API key |
|
||||
| `OLLAMA_MODEL` | No | Ollama model for coach (default: `deepseek-v4-pro:cloud`) |
|
||||
| `VITE_OLLAMA_PROXY_URL` | No | Proxy endpoint |
|
||||
| `OLLAMA_API_KEY` | No | Server-side API key |
|
||||
| `OLLAMA_MODEL` | No | Model for coach |
|
||||
| `APPWRITE_API_KEY` | No | Admin key for `setup:appwrite` script only |
|
||||
|
||||
## Project Structure
|
||||
@@ -92,7 +88,7 @@ src/
|
||||
├── components/
|
||||
│ ├── BarcodeScannerModal.tsx # Camera barcode scanner
|
||||
│ ├── BarcodeProductPreview.tsx
|
||||
│ ├── CoachPanel.tsx # AI coach chat UI
|
||||
│ ├── CoachPanel.tsx # Coach chat UI
|
||||
│ ├── DailyLimitsCard.tsx # Limit status & warnings
|
||||
│ ├── LimitsSettingsForm.tsx
|
||||
│ └── OnboardingScreen.tsx
|
||||
@@ -108,8 +104,8 @@ src/
|
||||
│ ├── barcodeLookup.ts # Multi-source barcode resolution
|
||||
│ ├── barcodeScanner.ts # @zxing scanner wrapper
|
||||
│ ├── userBarcodeMappings.ts # Per-user barcode overrides
|
||||
│ ├── coachChats.ts # Coach chat persistence
|
||||
│ ├── useCoachSession.ts # Coach chat hook
|
||||
│ ├── coachChats.ts # Chat persistence
|
||||
│ ├── useCoachSession.ts # Chat hook
|
||||
│ ├── userLimits.ts # Daily limit logic
|
||||
│ ├── metrics.ts # Computed stats & charts
|
||||
│ ├── excel.ts # Excel import/export
|
||||
|
||||
+6
-1
@@ -94,6 +94,7 @@ import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
|
||||
import {
|
||||
caffeineFor,
|
||||
caffeinePerCan,
|
||||
canLimitFromSpend,
|
||||
currency,
|
||||
currentStreak,
|
||||
daysSinceLast,
|
||||
@@ -166,7 +167,7 @@ const DEFAULT_FILTERS: Filters = {
|
||||
const QUICK_ADDS = [
|
||||
{ label: "Original", flavour: "Original", sizeMl: 250, pricePerCan: 1.75 },
|
||||
{ label: "Sugar Free", flavour: "Sugar Free", sizeMl: 250, pricePerCan: 1.75 },
|
||||
{ label: "Tropical", flavour: "Tropical", sizeMl: 250, pricePerCan: 1.75 },
|
||||
{ label: "Iced Vanilla", flavour: "Iced Vanilla", sizeMl: 250, pricePerCan: 1.75 },
|
||||
{ label: "473ml Original", flavour: "Original", sizeMl: 473, pricePerCan: 2.85 },
|
||||
];
|
||||
|
||||
@@ -656,6 +657,7 @@ function App() {
|
||||
onThemeChange={setThemeId}
|
||||
onSave={saveOnboarding}
|
||||
onClose={() => setSetupOpen(false)}
|
||||
initialLimits={userLimits}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
@@ -1753,9 +1755,12 @@ function SpendForecastCard({
|
||||
const saveLowerLimit = () => {
|
||||
if (!onSaveLimits) return;
|
||||
const lowerDailyLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100;
|
||||
const size = userLimits.limitCanSizeMl ?? 250;
|
||||
onSaveLimits({
|
||||
...userLimits,
|
||||
limitCanSizeMl: size,
|
||||
dailySpendLimit: lowerDailyLimit,
|
||||
dailyCanLimit: canLimitFromSpend(lowerDailyLimit, size),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
|
||||
<div>
|
||||
<p className="section-kicker">Daily limits</p>
|
||||
<p className="section-meta mt-2 max-w-xl leading-6">
|
||||
Set how many cans you want per day, when to stop, and a spend cap. Limits are optional and stored on your
|
||||
account.
|
||||
Set your usual can size and daily ceiling. Spend is calculated automatically. Limits are optional and stored
|
||||
on your account.
|
||||
</p>
|
||||
</div>
|
||||
<button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Loader2, Target } from "lucide-react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import type { LimitCheckResult, UserLimits } from "../types";
|
||||
import { currency } from "../lib/metrics";
|
||||
import {
|
||||
BUILT_IN_SIZES,
|
||||
canLimitFromSpend,
|
||||
currency,
|
||||
priceForLimitSize,
|
||||
spendLimitFromCans,
|
||||
} from "../lib/metrics";
|
||||
import type { BuiltInSize, LimitCheckResult, UserLimits } from "../types";
|
||||
|
||||
type LimitsSettingsFormProps = {
|
||||
limits: UserLimits;
|
||||
@@ -11,15 +17,57 @@ type LimitsSettingsFormProps = {
|
||||
};
|
||||
|
||||
export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSettingsFormProps) {
|
||||
const [canSizeMl, setCanSizeMl] = useState<BuiltInSize>(limits.limitCanSizeMl ?? 250);
|
||||
const [canInput, setCanInput] = useState(limits.dailyCanLimit?.toString() ?? "");
|
||||
const [spendInput, setSpendInput] = useState(limits.dailySpendLimit?.toString() ?? "");
|
||||
const [stopInput, setStopInput] = useState(limits.stopTime ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setCanSizeMl(limits.limitCanSizeMl ?? 250);
|
||||
setCanInput(limits.dailyCanLimit?.toString() ?? "");
|
||||
setSpendInput(limits.dailySpendLimit?.toString() ?? "");
|
||||
setStopInput(limits.stopTime ?? "");
|
||||
}, [limits.dailyCanLimit, limits.dailySpendLimit, limits.stopTime]);
|
||||
}, [limits.dailyCanLimit, limits.dailySpendLimit, limits.limitCanSizeMl, limits.stopTime]);
|
||||
|
||||
function syncFromCans(cans: number, size: BuiltInSize) {
|
||||
setCanInput(cans.toString());
|
||||
setSpendInput(spendLimitFromCans(cans, size).toFixed(2));
|
||||
}
|
||||
|
||||
function handleCanSizeChange(size: BuiltInSize) {
|
||||
setCanSizeMl(size);
|
||||
const canTrim = canInput.trim();
|
||||
if (canTrim) {
|
||||
const cans = Math.max(0.25, Number(canTrim) || 0);
|
||||
syncFromCans(cans, size);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCanInputChange(value: string) {
|
||||
setCanInput(value);
|
||||
const canTrim = value.trim();
|
||||
if (!canTrim) {
|
||||
setSpendInput("");
|
||||
return;
|
||||
}
|
||||
const cans = Math.max(0.25, Number(canTrim) || 0);
|
||||
if (cans > 0) {
|
||||
setSpendInput(spendLimitFromCans(cans, canSizeMl).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSpendInputChange(value: string) {
|
||||
setSpendInput(value);
|
||||
const spendTrim = value.trim();
|
||||
if (!spendTrim) {
|
||||
setCanInput("");
|
||||
return;
|
||||
}
|
||||
const spend = Math.max(0, Number(spendTrim) || 0);
|
||||
if (spend >= 0) {
|
||||
setCanInput(canLimitFromSpend(spend, canSizeMl).toString());
|
||||
}
|
||||
}
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -28,13 +76,11 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
const canTrim = canInput.trim();
|
||||
if (canTrim) {
|
||||
const parsed = Math.max(0.25, Number(canTrim) || 0);
|
||||
if (parsed > 0) next.dailyCanLimit = parsed;
|
||||
if (parsed > 0) {
|
||||
next.dailyCanLimit = parsed;
|
||||
next.limitCanSizeMl = canSizeMl;
|
||||
next.dailySpendLimit = spendLimitFromCans(parsed, canSizeMl);
|
||||
}
|
||||
|
||||
const spendTrim = spendInput.trim();
|
||||
if (spendTrim) {
|
||||
const parsed = Math.max(0, Number(spendTrim) || 0);
|
||||
next.dailySpendLimit = parsed;
|
||||
}
|
||||
|
||||
if (stopInput.trim()) {
|
||||
@@ -51,9 +97,39 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
if (limits.dailySpendLimit != null) {
|
||||
previewParts.push(`${currency.format(check.todaySpend)} of ${currency.format(limits.dailySpendLimit)} spent today`);
|
||||
}
|
||||
if (limits.limitCanSizeMl != null) {
|
||||
previewParts.push(`${limits.limitCanSizeMl}ml cans`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="grid gap-4" onSubmit={submit}>
|
||||
<div className="grid gap-2">
|
||||
<span className="text-sm font-medium text-slate-700">Usual can size</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{BUILT_IN_SIZES.map((size) => {
|
||||
const isActive = canSizeMl === size;
|
||||
return (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{size}ml ({currency.format(priceForLimitSize(size))})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
Spend is based on your usual can size. Changing cans or spend updates the other.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm">
|
||||
<span className="font-medium text-slate-700">Cans per day</span>
|
||||
@@ -64,7 +140,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
step={0.25}
|
||||
placeholder="e.g. 3"
|
||||
value={canInput}
|
||||
onChange={(event) => setCanInput(event.target.value)}
|
||||
onChange={(event) => handleCanInputChange(event.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-slate-500">Leave empty to remove. Counts use BST calendar days.</span>
|
||||
</label>
|
||||
@@ -78,9 +154,11 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
step={0.01}
|
||||
placeholder="e.g. 5.00"
|
||||
value={spendInput}
|
||||
onChange={(event) => setSpendInput(event.target.value)}
|
||||
onChange={(event) => handleSpendInputChange(event.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-slate-500">Based on price per can in your log.</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
Linked to {canSizeMl}ml at {currency.format(priceForLimitSize(canSizeMl))}/can.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowRight, Check, ChevronLeft } from "lucide-react";
|
||||
import { APP_THEMES } from "../data/themes";
|
||||
import { currency } from "../lib/metrics";
|
||||
import type { UserLimits } from "../types";
|
||||
import {
|
||||
BUILT_IN_SIZES,
|
||||
currency,
|
||||
priceForLimitSize,
|
||||
spendLimitFromCans,
|
||||
} from "../lib/metrics";
|
||||
import type { BuiltInSize, UserLimits } from "../types";
|
||||
|
||||
type OnboardingScreenProps = {
|
||||
onSave: (limits: UserLimits, themeId: string) => Promise<void>;
|
||||
@@ -10,9 +15,10 @@ type OnboardingScreenProps = {
|
||||
activeThemeId: string;
|
||||
onThemeChange: (themeId: string) => void;
|
||||
userName?: string;
|
||||
initialLimits?: UserLimits;
|
||||
};
|
||||
|
||||
const STEP_COUNT = 6;
|
||||
const STEP_COUNT = 5;
|
||||
|
||||
const curfewOptions: Array<{ id: string; label: string; hint: string }> = [
|
||||
{ id: "16:00", label: "4:00 PM", hint: "Early cut-off" },
|
||||
@@ -27,24 +33,36 @@ export function OnboardingScreen({
|
||||
activeThemeId,
|
||||
onThemeChange,
|
||||
userName,
|
||||
initialLimits,
|
||||
}: OnboardingScreenProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [dailyCanLimit, setDailyCanLimit] = useState<number | "none">(2);
|
||||
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5);
|
||||
const [stopTime, setStopTime] = useState<string | "none">("18:00");
|
||||
const [limitCanSizeMl, setLimitCanSizeMl] = useState<BuiltInSize>(
|
||||
initialLimits?.limitCanSizeMl ?? 250,
|
||||
);
|
||||
const [dailyCanLimit, setDailyCanLimit] = useState<number | "none">(
|
||||
initialLimits?.dailyCanLimit ?? 2,
|
||||
);
|
||||
const [stopTime, setStopTime] = useState<string | "none">(initialLimits?.stopTime ?? "18:00");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const activeTheme = useMemo(() => {
|
||||
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
||||
}, [activeThemeId]);
|
||||
|
||||
const derivedSpend =
|
||||
dailyCanLimit !== "none" ? spendLimitFromCans(dailyCanLimit, limitCanSizeMl) : null;
|
||||
const unitPrice = priceForLimitSize(limitCanSizeMl);
|
||||
|
||||
const progress = `${(step / STEP_COUNT) * 100}%`;
|
||||
|
||||
async function handleFinish() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const limits: UserLimits = {};
|
||||
if (dailyCanLimit !== "none") limits.dailyCanLimit = dailyCanLimit;
|
||||
if (dailySpendLimit !== "none") limits.dailySpendLimit = dailySpendLimit;
|
||||
if (dailyCanLimit !== "none") {
|
||||
limits.dailyCanLimit = dailyCanLimit;
|
||||
limits.limitCanSizeMl = limitCanSizeMl;
|
||||
limits.dailySpendLimit = spendLimitFromCans(dailyCanLimit, limitCanSizeMl);
|
||||
}
|
||||
if (stopTime !== "none") limits.stopTime = stopTime;
|
||||
|
||||
await onSave(limits, activeThemeId);
|
||||
@@ -73,23 +91,6 @@ export function OnboardingScreen({
|
||||
setDailyCanLimit(Number((dailyCanLimit - 0.5).toFixed(1)));
|
||||
}
|
||||
|
||||
function incrementSpend() {
|
||||
if (dailySpendLimit === "none") {
|
||||
setDailySpendLimit(1);
|
||||
return;
|
||||
}
|
||||
if (dailySpendLimit < 30) setDailySpendLimit(Number((dailySpendLimit + 0.5).toFixed(2)));
|
||||
}
|
||||
|
||||
function decrementSpend() {
|
||||
if (dailySpendLimit === "none") return;
|
||||
if (dailySpendLimit <= 0.5) {
|
||||
setDailySpendLimit("none");
|
||||
return;
|
||||
}
|
||||
setDailySpendLimit(Number((dailySpendLimit - 0.5).toFixed(2)));
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
setStep((current) => Math.min(current + 1, STEP_COUNT));
|
||||
}
|
||||
@@ -136,7 +137,7 @@ export function OnboardingScreen({
|
||||
Hey {userName || "there"}. Set your baseline.
|
||||
</h1>
|
||||
<p className="max-w-xl text-lg font-normal leading-8 text-[var(--muted)]">
|
||||
Pick a theme, then set optional limits for cans, spend, and time.
|
||||
Pick a theme, choose your usual can size, set a daily ceiling, and optionally a curfew.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -200,15 +201,40 @@ export function OnboardingScreen({
|
||||
{step === 3 && (
|
||||
<section className="grid gap-9">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--primary)]">daily cans</p>
|
||||
<p className="text-sm font-normal text-[var(--primary)]">daily limit</p>
|
||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||
What is your daily can ceiling?
|
||||
What size can do you usually have?
|
||||
</h2>
|
||||
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
||||
The app warns before saving an entry over this number. You can change it later.
|
||||
Your spend cap is calculated from your can size and daily ceiling. You can change this later in settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{BUILT_IN_SIZES.map((size) => {
|
||||
const isSelected = limitCanSizeMl === size;
|
||||
return (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setLimitCanSizeMl(size)}
|
||||
className="flex min-h-20 flex-col items-start justify-center rounded-2xl border px-4 text-left transition"
|
||||
style={{
|
||||
background: isSelected ? "var(--surface-container-low)" : "var(--surface-container-lowest)",
|
||||
borderColor: isSelected ? "var(--primary)" : "var(--outline-variant)",
|
||||
}}
|
||||
>
|
||||
<span className="text-lg font-normal text-[var(--text)]">{size}ml</span>
|
||||
<span className="mt-1 text-sm font-normal text-[var(--muted)]">
|
||||
{currency.format(priceForLimitSize(size))} per can
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--muted)]">Daily can ceiling</p>
|
||||
<div className="flex flex-wrap items-end gap-5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -235,6 +261,22 @@ export function OnboardingScreen({
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{derivedSpend != null ? (
|
||||
<p
|
||||
className="rounded-2xl border px-4 py-3 text-sm font-normal"
|
||||
style={{
|
||||
background: "var(--surface-container-lowest)",
|
||||
borderColor: "var(--outline-variant)",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
Daily budget: {currency.format(derivedSpend)} ({dailyCanLimit} × {currency.format(unitPrice)})
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-normal text-[var(--muted)]">No daily spend cap when cans are unlimited.</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -274,82 +316,6 @@ export function OnboardingScreen({
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<section className="grid gap-9">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--primary)]">daily spend</p>
|
||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||
Set a daily spend line.
|
||||
</h2>
|
||||
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
||||
Useful if you want a spending line for the day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={decrementSpend}
|
||||
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
|
||||
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="min-w-52">
|
||||
<p className="text-7xl font-normal leading-none tracking-[-0.06em] sm:text-8xl" style={{ color: "var(--primary)" }}>
|
||||
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)}
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-normal text-[var(--muted)]">
|
||||
{dailySpendLimit === "none" ? "No daily budget" : "maximum per day"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={incrementSpend}
|
||||
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
|
||||
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDailySpendLimit("none")}
|
||||
className="rounded-full border px-4 py-2 text-sm font-normal transition"
|
||||
style={{
|
||||
background: dailySpendLimit === "none" ? "var(--primary-container)" : "var(--surface-container-lowest)",
|
||||
borderColor: dailySpendLimit === "none" ? "var(--primary)" : "var(--outline-variant)",
|
||||
color: dailySpendLimit === "none" ? "var(--on-primary-container)" : "var(--muted)",
|
||||
}}
|
||||
>
|
||||
No spend cap
|
||||
</button>
|
||||
{dailySpendLimit === "none" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDailySpendLimit(3.5)}
|
||||
className="rounded-full border px-4 py-2 text-sm font-normal transition"
|
||||
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
|
||||
>
|
||||
Use £3.50
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
|
||||
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<section className="grid gap-8">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--primary)]">time limit</p>
|
||||
@@ -397,7 +363,7 @@ export function OnboardingScreen({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
{step === 5 && (
|
||||
<section className="grid gap-8">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--primary)]">done</p>
|
||||
@@ -414,6 +380,12 @@ export function OnboardingScreen({
|
||||
{activeTheme.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
|
||||
<span className="text-sm font-normal text-[var(--muted)]">Usual can size</span>
|
||||
<span className="text-sm font-normal text-[var(--text)]">
|
||||
{dailyCanLimit === "none" ? "—" : `${limitCanSizeMl}ml (${currency.format(unitPrice)}/can)`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
|
||||
<span className="text-sm font-normal text-[var(--muted)]">Daily cans</span>
|
||||
<span className="text-sm font-normal text-[var(--text)]">
|
||||
@@ -423,7 +395,7 @@ export function OnboardingScreen({
|
||||
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
|
||||
<span className="text-sm font-normal text-[var(--muted)]">Daily spend</span>
|
||||
<span className="text-sm font-normal text-[var(--text)]">
|
||||
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)}
|
||||
{derivedSpend == null ? "No cap" : currency.format(derivedSpend)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import type { RedBullEntry } from "../types";
|
||||
import type { BuiltInSize, RedBullEntry } from "../types";
|
||||
|
||||
export const CAFFEINE_PER_250ML = 80;
|
||||
export const SUGAR_PER_250ML = 27;
|
||||
@@ -8,6 +8,21 @@ export const STANDARD_CAN_VALUES = {
|
||||
473: { pricePerCan: 2.85, caffeineMg: 151 },
|
||||
} as const;
|
||||
|
||||
export const BUILT_IN_SIZES: BuiltInSize[] = [250, 355, 473];
|
||||
|
||||
export function priceForLimitSize(size: BuiltInSize): number {
|
||||
return STANDARD_CAN_VALUES[size].pricePerCan;
|
||||
}
|
||||
|
||||
export function spendLimitFromCans(cans: number, size: BuiltInSize): number {
|
||||
return Math.round(cans * priceForLimitSize(size) * 100) / 100;
|
||||
}
|
||||
|
||||
export function canLimitFromSpend(spend: number, size: BuiltInSize): number {
|
||||
const raw = spend / priceForLimitSize(size);
|
||||
return Math.round(raw * 4) / 4;
|
||||
}
|
||||
|
||||
export function spendFor(entry: RedBullEntry) {
|
||||
return entry.cans * entry.pricePerCan;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ export const DEFAULT_LIMITS: UserLimits = {};
|
||||
const PREFS_CAN_KEY = "dailyCanLimit";
|
||||
const PREFS_SPEND_KEY = "dailySpendLimit";
|
||||
const PREFS_STOP_KEY = "stopTime";
|
||||
const PREFS_SIZE_KEY = "limitCanSizeMl";
|
||||
|
||||
const VALID_LIMIT_SIZES = new Set([250, 355, 473]);
|
||||
|
||||
export function parseUserLimits(prefs: Record<string, unknown> | null | undefined): UserLimits {
|
||||
if (!prefs) return { ...DEFAULT_LIMITS };
|
||||
@@ -16,9 +19,12 @@ export function parseUserLimits(prefs: Record<string, unknown> | null | undefine
|
||||
const spendLimit = Number(prefs[PREFS_SPEND_KEY]);
|
||||
const stopTime = typeof prefs[PREFS_STOP_KEY] === "string" ? prefs[PREFS_STOP_KEY] : undefined;
|
||||
|
||||
const sizeLimit = Number(prefs[PREFS_SIZE_KEY]);
|
||||
|
||||
if (Number.isFinite(canLimit) && canLimit > 0) limits.dailyCanLimit = canLimit;
|
||||
if (Number.isFinite(spendLimit) && spendLimit >= 0) limits.dailySpendLimit = spendLimit;
|
||||
if (stopTime && /^\d{2}:\d{2}$/.test(stopTime)) limits.stopTime = stopTime;
|
||||
if (VALID_LIMIT_SIZES.has(sizeLimit)) limits.limitCanSizeMl = sizeLimit as 250 | 355 | 473;
|
||||
|
||||
return limits;
|
||||
}
|
||||
@@ -34,6 +40,9 @@ export function serializeUserLimits(limits: UserLimits): Record<string, unknown>
|
||||
if (limits.stopTime) {
|
||||
data[PREFS_STOP_KEY] = limits.stopTime;
|
||||
}
|
||||
if (limits.limitCanSizeMl != null && VALID_LIMIT_SIZES.has(limits.limitCanSizeMl)) {
|
||||
data[PREFS_SIZE_KEY] = limits.limitCanSizeMl;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -45,6 +54,7 @@ export function mergePrefsWithLimits(
|
||||
delete next[PREFS_CAN_KEY];
|
||||
delete next[PREFS_SPEND_KEY];
|
||||
delete next[PREFS_STOP_KEY];
|
||||
delete next[PREFS_SIZE_KEY];
|
||||
return { ...next, ...serializeUserLimits(limits) };
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export type UserLimits = {
|
||||
dailyCanLimit?: number;
|
||||
dailySpendLimit?: number;
|
||||
stopTime?: string;
|
||||
limitCanSizeMl?: BuiltInSize;
|
||||
};
|
||||
|
||||
export type LimitViolation = "cans" | "spend" | "stopTime";
|
||||
|
||||
Reference in New Issue
Block a user