Compare commits
5 Commits
b4a8bbb1df
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ab88d71d0 | |||
| 7a461af9ee | |||
| 7f56274e65 | |||
| 50ee708295 | |||
| 2c07f2c5b1 |
@@ -1,6 +1,6 @@
|
|||||||
# 🐂 Red Bull Intake Tracker
|
# 🐂 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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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 |
|
| Styling | Tailwind CSS, Framer Motion |
|
||||||
| Charts | Recharts |
|
| Charts | Recharts |
|
||||||
| Backend | Appwrite Cloud (auth, database, storage) |
|
| Backend | Appwrite Cloud (auth, database, storage) |
|
||||||
| AI | Ollama (via server proxy) |
|
|
||||||
| Barcode | @zxing/browser |
|
| Barcode | @zxing/browser |
|
||||||
| Import/Export | ExcelJS |
|
| 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_PROJECT_ID` | Yes | Your Appwrite project ID |
|
||||||
| `VITE_APPWRITE_DATABASE_ID` | Yes | Database ID (default: `redbull_tracker`) |
|
| `VITE_APPWRITE_DATABASE_ID` | Yes | Database ID (default: `redbull_tracker`) |
|
||||||
| `VITE_APPWRITE_COLLECTION_ID` | Yes | Intake entries collection ID |
|
| `VITE_APPWRITE_COLLECTION_ID` | Yes | Intake entries collection ID |
|
||||||
| `VITE_APPWRITE_CHAT_COLLECTION_ID` | Yes | Coach chats collection ID |
|
| `VITE_OLLAMA_PROXY_URL` | No | Proxy endpoint |
|
||||||
|
| `OLLAMA_API_KEY` | No | Server-side API key |
|
||||||
| `VITE_OLLAMA_PROXY_URL` | No | AI coach proxy endpoint |
|
| `OLLAMA_MODEL` | No | Model for coach |
|
||||||
| `OLLAMA_API_KEY` | No | Server-side Ollama API key |
|
|
||||||
| `OLLAMA_MODEL` | No | Ollama model for coach (default: `deepseek-v4-pro:cloud`) |
|
|
||||||
| `APPWRITE_API_KEY` | No | Admin key for `setup:appwrite` script only |
|
| `APPWRITE_API_KEY` | No | Admin key for `setup:appwrite` script only |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -92,7 +88,7 @@ src/
|
|||||||
├── components/
|
├── components/
|
||||||
│ ├── BarcodeScannerModal.tsx # Camera barcode scanner
|
│ ├── BarcodeScannerModal.tsx # Camera barcode scanner
|
||||||
│ ├── BarcodeProductPreview.tsx
|
│ ├── BarcodeProductPreview.tsx
|
||||||
│ ├── CoachPanel.tsx # AI coach chat UI
|
│ ├── CoachPanel.tsx # Coach chat UI
|
||||||
│ ├── DailyLimitsCard.tsx # Limit status & warnings
|
│ ├── DailyLimitsCard.tsx # Limit status & warnings
|
||||||
│ ├── LimitsSettingsForm.tsx
|
│ ├── LimitsSettingsForm.tsx
|
||||||
│ └── OnboardingScreen.tsx
|
│ └── OnboardingScreen.tsx
|
||||||
@@ -108,8 +104,8 @@ src/
|
|||||||
│ ├── barcodeLookup.ts # Multi-source barcode resolution
|
│ ├── barcodeLookup.ts # Multi-source barcode resolution
|
||||||
│ ├── barcodeScanner.ts # @zxing scanner wrapper
|
│ ├── barcodeScanner.ts # @zxing scanner wrapper
|
||||||
│ ├── userBarcodeMappings.ts # Per-user barcode overrides
|
│ ├── userBarcodeMappings.ts # Per-user barcode overrides
|
||||||
│ ├── coachChats.ts # Coach chat persistence
|
│ ├── coachChats.ts # Chat persistence
|
||||||
│ ├── useCoachSession.ts # Coach chat hook
|
│ ├── useCoachSession.ts # Chat hook
|
||||||
│ ├── userLimits.ts # Daily limit logic
|
│ ├── userLimits.ts # Daily limit logic
|
||||||
│ ├── metrics.ts # Computed stats & charts
|
│ ├── metrics.ts # Computed stats & charts
|
||||||
│ ├── excel.ts # Excel import/export
|
│ ├── excel.ts # Excel import/export
|
||||||
|
|||||||
+6
-1
@@ -94,6 +94,7 @@ import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
|
|||||||
import {
|
import {
|
||||||
caffeineFor,
|
caffeineFor,
|
||||||
caffeinePerCan,
|
caffeinePerCan,
|
||||||
|
canLimitFromSpend,
|
||||||
currency,
|
currency,
|
||||||
currentStreak,
|
currentStreak,
|
||||||
daysSinceLast,
|
daysSinceLast,
|
||||||
@@ -166,7 +167,7 @@ const DEFAULT_FILTERS: Filters = {
|
|||||||
const QUICK_ADDS = [
|
const QUICK_ADDS = [
|
||||||
{ label: "Original", flavour: "Original", sizeMl: 250, pricePerCan: 1.75 },
|
{ label: "Original", flavour: "Original", sizeMl: 250, pricePerCan: 1.75 },
|
||||||
{ label: "Sugar Free", flavour: "Sugar Free", 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 },
|
{ label: "473ml Original", flavour: "Original", sizeMl: 473, pricePerCan: 2.85 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -656,6 +657,7 @@ function App() {
|
|||||||
onThemeChange={setThemeId}
|
onThemeChange={setThemeId}
|
||||||
onSave={saveOnboarding}
|
onSave={saveOnboarding}
|
||||||
onClose={() => setSetupOpen(false)}
|
onClose={() => setSetupOpen(false)}
|
||||||
|
initialLimits={userLimits}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@@ -1753,9 +1755,12 @@ function SpendForecastCard({
|
|||||||
const saveLowerLimit = () => {
|
const saveLowerLimit = () => {
|
||||||
if (!onSaveLimits) return;
|
if (!onSaveLimits) return;
|
||||||
const lowerDailyLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100;
|
const lowerDailyLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100;
|
||||||
|
const size = userLimits.limitCanSizeMl ?? 250;
|
||||||
onSaveLimits({
|
onSaveLimits({
|
||||||
...userLimits,
|
...userLimits,
|
||||||
|
limitCanSizeMl: size,
|
||||||
dailySpendLimit: lowerDailyLimit,
|
dailySpendLimit: lowerDailyLimit,
|
||||||
|
dailyCanLimit: canLimitFromSpend(lowerDailyLimit, size),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
|
|||||||
<div>
|
<div>
|
||||||
<p className="section-kicker">Daily limits</p>
|
<p className="section-kicker">Daily limits</p>
|
||||||
<p className="section-meta mt-2 max-w-xl leading-6">
|
<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
|
Set your usual can size and daily ceiling. Spend is calculated automatically. Limits are optional and stored
|
||||||
account.
|
on your account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}>
|
<button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Loader2, Target } from "lucide-react";
|
import { Loader2, Target } from "lucide-react";
|
||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import type { LimitCheckResult, UserLimits } from "../types";
|
import {
|
||||||
import { currency } from "../lib/metrics";
|
BUILT_IN_SIZES,
|
||||||
|
canLimitFromSpend,
|
||||||
|
currency,
|
||||||
|
priceForLimitSize,
|
||||||
|
spendLimitFromCans,
|
||||||
|
} from "../lib/metrics";
|
||||||
|
import type { BuiltInSize, LimitCheckResult, UserLimits } from "../types";
|
||||||
|
|
||||||
type LimitsSettingsFormProps = {
|
type LimitsSettingsFormProps = {
|
||||||
limits: UserLimits;
|
limits: UserLimits;
|
||||||
@@ -11,15 +17,57 @@ type LimitsSettingsFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LimitsSettingsForm({ limits, check, saving, onSave }: 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 [canInput, setCanInput] = useState(limits.dailyCanLimit?.toString() ?? "");
|
||||||
const [spendInput, setSpendInput] = useState(limits.dailySpendLimit?.toString() ?? "");
|
const [spendInput, setSpendInput] = useState(limits.dailySpendLimit?.toString() ?? "");
|
||||||
const [stopInput, setStopInput] = useState(limits.stopTime ?? "");
|
const [stopInput, setStopInput] = useState(limits.stopTime ?? "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setCanSizeMl(limits.limitCanSizeMl ?? 250);
|
||||||
setCanInput(limits.dailyCanLimit?.toString() ?? "");
|
setCanInput(limits.dailyCanLimit?.toString() ?? "");
|
||||||
setSpendInput(limits.dailySpendLimit?.toString() ?? "");
|
setSpendInput(limits.dailySpendLimit?.toString() ?? "");
|
||||||
setStopInput(limits.stopTime ?? "");
|
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>) {
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -28,13 +76,11 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
const canTrim = canInput.trim();
|
const canTrim = canInput.trim();
|
||||||
if (canTrim) {
|
if (canTrim) {
|
||||||
const parsed = Math.max(0.25, Number(canTrim) || 0);
|
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()) {
|
if (stopInput.trim()) {
|
||||||
@@ -51,9 +97,39 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
if (limits.dailySpendLimit != null) {
|
if (limits.dailySpendLimit != null) {
|
||||||
previewParts.push(`${currency.format(check.todaySpend)} of ${currency.format(limits.dailySpendLimit)} spent today`);
|
previewParts.push(`${currency.format(check.todaySpend)} of ${currency.format(limits.dailySpendLimit)} spent today`);
|
||||||
}
|
}
|
||||||
|
if (limits.limitCanSizeMl != null) {
|
||||||
|
previewParts.push(`${limits.limitCanSizeMl}ml cans`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="grid gap-4" onSubmit={submit}>
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<label className="grid gap-2 text-sm">
|
<label className="grid gap-2 text-sm">
|
||||||
<span className="font-medium text-slate-700">Cans per day</span>
|
<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}
|
step={0.25}
|
||||||
placeholder="e.g. 3"
|
placeholder="e.g. 3"
|
||||||
value={canInput}
|
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>
|
<span className="text-xs text-slate-500">Leave empty to remove. Counts use BST calendar days.</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -78,9 +154,11 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
step={0.01}
|
step={0.01}
|
||||||
placeholder="e.g. 5.00"
|
placeholder="e.g. 5.00"
|
||||||
value={spendInput}
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ArrowRight, Check, ChevronLeft } from "lucide-react";
|
import { ArrowRight, Check, ChevronLeft } from "lucide-react";
|
||||||
import { APP_THEMES } from "../data/themes";
|
import { APP_THEMES } from "../data/themes";
|
||||||
import { currency } from "../lib/metrics";
|
import {
|
||||||
import type { UserLimits } from "../types";
|
BUILT_IN_SIZES,
|
||||||
|
currency,
|
||||||
|
priceForLimitSize,
|
||||||
|
spendLimitFromCans,
|
||||||
|
} from "../lib/metrics";
|
||||||
|
import type { BuiltInSize, UserLimits } from "../types";
|
||||||
|
|
||||||
type OnboardingScreenProps = {
|
type OnboardingScreenProps = {
|
||||||
onSave: (limits: UserLimits, themeId: string) => Promise<void>;
|
onSave: (limits: UserLimits, themeId: string) => Promise<void>;
|
||||||
@@ -10,9 +15,10 @@ type OnboardingScreenProps = {
|
|||||||
activeThemeId: string;
|
activeThemeId: string;
|
||||||
onThemeChange: (themeId: string) => void;
|
onThemeChange: (themeId: string) => void;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
initialLimits?: UserLimits;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STEP_COUNT = 6;
|
const STEP_COUNT = 5;
|
||||||
|
|
||||||
const curfewOptions: Array<{ id: string; label: string; hint: string }> = [
|
const curfewOptions: Array<{ id: string; label: string; hint: string }> = [
|
||||||
{ id: "16:00", label: "4:00 PM", hint: "Early cut-off" },
|
{ id: "16:00", label: "4:00 PM", hint: "Early cut-off" },
|
||||||
@@ -27,24 +33,36 @@ export function OnboardingScreen({
|
|||||||
activeThemeId,
|
activeThemeId,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
userName,
|
userName,
|
||||||
|
initialLimits,
|
||||||
}: OnboardingScreenProps) {
|
}: OnboardingScreenProps) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [dailyCanLimit, setDailyCanLimit] = useState<number | "none">(2);
|
const [limitCanSizeMl, setLimitCanSizeMl] = useState<BuiltInSize>(
|
||||||
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5);
|
initialLimits?.limitCanSizeMl ?? 250,
|
||||||
const [stopTime, setStopTime] = useState<string | "none">("18:00");
|
);
|
||||||
|
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 [saving, setSaving] = useState(false);
|
||||||
const activeTheme = useMemo(() => {
|
const activeTheme = useMemo(() => {
|
||||||
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
||||||
}, [activeThemeId]);
|
}, [activeThemeId]);
|
||||||
|
|
||||||
|
const derivedSpend =
|
||||||
|
dailyCanLimit !== "none" ? spendLimitFromCans(dailyCanLimit, limitCanSizeMl) : null;
|
||||||
|
const unitPrice = priceForLimitSize(limitCanSizeMl);
|
||||||
|
|
||||||
const progress = `${(step / STEP_COUNT) * 100}%`;
|
const progress = `${(step / STEP_COUNT) * 100}%`;
|
||||||
|
|
||||||
async function handleFinish() {
|
async function handleFinish() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const limits: UserLimits = {};
|
const limits: UserLimits = {};
|
||||||
if (dailyCanLimit !== "none") limits.dailyCanLimit = dailyCanLimit;
|
if (dailyCanLimit !== "none") {
|
||||||
if (dailySpendLimit !== "none") limits.dailySpendLimit = dailySpendLimit;
|
limits.dailyCanLimit = dailyCanLimit;
|
||||||
|
limits.limitCanSizeMl = limitCanSizeMl;
|
||||||
|
limits.dailySpendLimit = spendLimitFromCans(dailyCanLimit, limitCanSizeMl);
|
||||||
|
}
|
||||||
if (stopTime !== "none") limits.stopTime = stopTime;
|
if (stopTime !== "none") limits.stopTime = stopTime;
|
||||||
|
|
||||||
await onSave(limits, activeThemeId);
|
await onSave(limits, activeThemeId);
|
||||||
@@ -73,23 +91,6 @@ export function OnboardingScreen({
|
|||||||
setDailyCanLimit(Number((dailyCanLimit - 0.5).toFixed(1)));
|
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() {
|
function goNext() {
|
||||||
setStep((current) => Math.min(current + 1, STEP_COUNT));
|
setStep((current) => Math.min(current + 1, STEP_COUNT));
|
||||||
}
|
}
|
||||||
@@ -136,7 +137,7 @@ export function OnboardingScreen({
|
|||||||
Hey {userName || "there"}. Set your baseline.
|
Hey {userName || "there"}. Set your baseline.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-xl text-lg font-normal leading-8 text-[var(--muted)]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -200,15 +201,40 @@ export function OnboardingScreen({
|
|||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<section className="grid gap-9">
|
<section className="grid gap-9">
|
||||||
<div className="grid gap-4">
|
<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">
|
<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>
|
</h2>
|
||||||
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flex flex-wrap items-end gap-5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -235,6 +261,22 @@ export function OnboardingScreen({
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -274,82 +316,6 @@ export function OnboardingScreen({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 4 && (
|
{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">
|
<section className="grid gap-8">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<p className="text-sm font-normal text-[var(--primary)]">time limit</p>
|
<p className="text-sm font-normal text-[var(--primary)]">time limit</p>
|
||||||
@@ -397,7 +363,7 @@ export function OnboardingScreen({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 6 && (
|
{step === 5 && (
|
||||||
<section className="grid gap-8">
|
<section className="grid gap-8">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<p className="text-sm font-normal text-[var(--primary)]">done</p>
|
<p className="text-sm font-normal text-[var(--primary)]">done</p>
|
||||||
@@ -414,6 +380,12 @@ export function OnboardingScreen({
|
|||||||
{activeTheme.label}
|
{activeTheme.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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)" }}>
|
<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(--muted)]">Daily cans</span>
|
||||||
<span className="text-sm font-normal text-[var(--text)]">
|
<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)" }}>
|
<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(--muted)]">Daily spend</span>
|
||||||
<span className="text-sm font-normal text-[var(--text)]">
|
<span className="text-sm font-normal text-[var(--text)]">
|
||||||
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)}
|
{derivedSpend == null ? "No cap" : currency.format(derivedSpend)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<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 CAFFEINE_PER_250ML = 80;
|
||||||
export const SUGAR_PER_250ML = 27;
|
export const SUGAR_PER_250ML = 27;
|
||||||
@@ -8,6 +8,21 @@ export const STANDARD_CAN_VALUES = {
|
|||||||
473: { pricePerCan: 2.85, caffeineMg: 151 },
|
473: { pricePerCan: 2.85, caffeineMg: 151 },
|
||||||
} as const;
|
} 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) {
|
export function spendFor(entry: RedBullEntry) {
|
||||||
return entry.cans * entry.pricePerCan;
|
return entry.cans * entry.pricePerCan;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export const DEFAULT_LIMITS: UserLimits = {};
|
|||||||
const PREFS_CAN_KEY = "dailyCanLimit";
|
const PREFS_CAN_KEY = "dailyCanLimit";
|
||||||
const PREFS_SPEND_KEY = "dailySpendLimit";
|
const PREFS_SPEND_KEY = "dailySpendLimit";
|
||||||
const PREFS_STOP_KEY = "stopTime";
|
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 {
|
export function parseUserLimits(prefs: Record<string, unknown> | null | undefined): UserLimits {
|
||||||
if (!prefs) return { ...DEFAULT_LIMITS };
|
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 spendLimit = Number(prefs[PREFS_SPEND_KEY]);
|
||||||
const stopTime = typeof prefs[PREFS_STOP_KEY] === "string" ? prefs[PREFS_STOP_KEY] : undefined;
|
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(canLimit) && canLimit > 0) limits.dailyCanLimit = canLimit;
|
||||||
if (Number.isFinite(spendLimit) && spendLimit >= 0) limits.dailySpendLimit = spendLimit;
|
if (Number.isFinite(spendLimit) && spendLimit >= 0) limits.dailySpendLimit = spendLimit;
|
||||||
if (stopTime && /^\d{2}:\d{2}$/.test(stopTime)) limits.stopTime = stopTime;
|
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;
|
return limits;
|
||||||
}
|
}
|
||||||
@@ -34,6 +40,9 @@ export function serializeUserLimits(limits: UserLimits): Record<string, unknown>
|
|||||||
if (limits.stopTime) {
|
if (limits.stopTime) {
|
||||||
data[PREFS_STOP_KEY] = 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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +54,7 @@ export function mergePrefsWithLimits(
|
|||||||
delete next[PREFS_CAN_KEY];
|
delete next[PREFS_CAN_KEY];
|
||||||
delete next[PREFS_SPEND_KEY];
|
delete next[PREFS_SPEND_KEY];
|
||||||
delete next[PREFS_STOP_KEY];
|
delete next[PREFS_STOP_KEY];
|
||||||
|
delete next[PREFS_SIZE_KEY];
|
||||||
return { ...next, ...serializeUserLimits(limits) };
|
return { ...next, ...serializeUserLimits(limits) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export type UserLimits = {
|
|||||||
dailyCanLimit?: number;
|
dailyCanLimit?: number;
|
||||||
dailySpendLimit?: number;
|
dailySpendLimit?: number;
|
||||||
stopTime?: string;
|
stopTime?: string;
|
||||||
|
limitCanSizeMl?: BuiltInSize;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LimitViolation = "cans" | "spend" | "stopTime";
|
export type LimitViolation = "cans" | "spend" | "stopTime";
|
||||||
|
|||||||
Reference in New Issue
Block a user