diff --git a/.env.example b/.env.example index 4a14ee9..7a09b2f 100644 --- a/.env.example +++ b/.env.example @@ -2,16 +2,7 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 VITE_APPWRITE_PROJECT_ID=your-project-id VITE_APPWRITE_DATABASE_ID=redbull_tracker VITE_APPWRITE_COLLECTION_ID=intake_entries -VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats - - -# Server-only. Do not prefix with VITE_ or it will be exposed to the browser. -OLLAMA_API_KEY= -OLLAMA_MODEL=deepseek-v4-pro:cloud -VITE_OLLAMA_PROXY_URL=/api/ollama-chat +VITE_APPWRITE_BARCODE_COLLECTION_ID=barcode_products # Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite. -APPWRITE_API_KEY= - -# Appwrite chat table columns: userId, title, messages, updatedAt. -# Enable row security and Users -> Create at table level. +APPWRITE_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index baf4031..8fa56cd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +.deploy/ +public/*.html diff --git a/APPWRITE_SETUP.md b/APPWRITE_SETUP.md index ec5e506..0fe41f1 100644 --- a/APPWRITE_SETUP.md +++ b/APPWRITE_SETUP.md @@ -1,221 +1,76 @@ -# Red Bull Intake Tracker Setup +# Red Bull tracker setup -## Commands +This app uses Appwrite for auth and intake entries. -```bash -npm install -npm run dev -npm run build -npm run lint +## env + +Copy `.env.example` to `.env.local`, then fill in: + +```sh +VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 +VITE_APPWRITE_PROJECT_ID=your_project_id +VITE_APPWRITE_DATABASE_ID=redbull_tracker +VITE_APPWRITE_COLLECTION_ID=intake_entries +APPWRITE_API_KEY=server_key_for_setup_only ``` -The Vite dev app runs at `http://localhost:5173` unless that port is already taken. +Leave the OAuth URLs empty in local dev unless you need fixed callback URLs. -## Environment +## setup -Copy `.env.example` to `.env.local` and adjust IDs if you choose different Appwrite resource IDs: +Run: -```bash -cp .env.example .env.local -``` - -This app uses only the Appwrite browser SDK. Do not add an API key to the frontend. - -To create/update the database tables from this repo, set a server/admin key as `APPWRITE_API_KEY` in `.env.local` and run: - -```bash +```sh npm run setup:appwrite ``` -The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code. +The script creates or updates: -Configured defaults: +- database: `redbull_tracker` +- table: `intake_entries` +- table permission: `Users -> Create` +- row security: enabled -- Endpoint: `https://fra.cloud.appwrite.io/v1` -- Project ID: `6a0752ee001fb2ef7138` -- Project name: `Red Bull Tracker App` -- Database ID: `redbull_tracker` -- Collection ID: `intake_entries` -- Chat collection ID: `coach_chats` -- Barcode collection ID: `barcode_products` +Rows use per-user read, update, and delete permissions. -`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`. +## intake columns -## Auth +| key | type | required | +| --- | --- | --- | +| `userId` | String, 64 | Yes | +| `cans` | Float | Yes | +| `flavour` | String, 128 | Yes | +| `flavourAccent` | String, 32 | Yes | +| `sizeMl` | Integer | Yes | +| `pricePerCan` | Float | Yes | +| `dateTime` | DateTime | Yes | +| `notes` | String, 2000 | No | +| `store` | String, 256 | No | +| `sugarFree` | Boolean | Yes | +| `caffeineMgPerCan` | Float | No | +| `importKey` | String, 512 | Yes | +| `source` | String, 32 | Yes | -Enable these auth methods in Appwrite Console: +## indexes -- Email/password -- GitHub OAuth -- Google OAuth +- `user_date_desc`: `userId`, `dateTime` +- `user_import_key`: `userId`, `importKey` -Add a Web platform in Appwrite Console for local development: +## run -- Hostname: `localhost` -- Hostname: `127.0.0.1` - -If `client.ping()` shows `Failed to fetch`, this is usually the first thing to check. - -For local OAuth callback URLs, add: - -- Success URL: `http://localhost:5173` -- Failure URL: `http://localhost:5173` -- If Vite starts on another port, add that origin too, for example `http://127.0.0.1:5174` - -For production, add your deployed origin as both success and failure URL, then update the `VITE_APPWRITE_OAUTH_*` variables. - -In local dev, you can leave `VITE_APPWRITE_OAUTH_SUCCESS_URL` and `VITE_APPWRITE_OAUTH_FAILURE_URL` blank. The app will use the current browser origin automatically, which avoids getting redirected to a stale Vite port. - -If OAuth returns to the app but you are still logged out: - -- Confirm the current browser origin is listed under Appwrite project platforms, for example `localhost` and `127.0.0.1`. -- Confirm the same origin is allowed in the OAuth provider success/failure URLs. -- Clear old sessions/cookies for the local app and try again. -- Restart Vite after editing `.env.local`. - -## Database - -Appwrite currently uses newer Console wording in many places: - -| In this app / older SDK wording | Current Appwrite Console wording | -| --- | --- | -| Collection | Table | -| Attribute | Column | -| Document | Row | - -So if the Console asks you to create a **table**, that is the same resource as the `VITE_APPWRITE_COLLECTION_ID` this app currently points at. If the setup below says **attributes**, add them as **columns** inside that table. - -The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID. - -The barcode scanner uses a separate `barcode_products` table by default. Verified Red Bull barcode rows are seeded by `scripts/setup-appwrite.mjs` using `APPWRITE_API_KEY`; browser code can only read verified rows and create/update the current user's own mappings with row-level permissions. - -Create a database with ID: - -```text -redbull_tracker +```sh +npm install +npm run dev ``` -Create a collection with ID: +## deployment-only files -```text -intake_entries -``` +The repo ignores `.deploy/` and local public HTML pages. -Enable document-level permissions on the collection. +For your own deployment, create: -Recommended collection-level permissions: +- `.deploy/head.html` for analytics or other head-only snippets +- `.deploy/body-end.html` for footer links or deploy-only markup +- any local public HTML pages your host needs -- Create: `users` -- Read: none -- Update: none -- Delete: none - -The app writes per-document permissions for the current user: - -- `read("user:{userId}")` -- `update("user:{userId}")` -- `delete("user:{userId}")` - -## Permission Troubleshooting - -If the app shows: - -```text -No permissions provided for action 'create' -``` - -the table is reachable, but the signed-in user is not allowed to create rows yet. - -Fix it in Appwrite Console: - -1. Open **Databases**. -2. Open database `redbull_tracker`. -3. Open table `intake_entries`. -4. Go to **Settings**. -5. Enable **Row Security**. -6. Under **Permissions**, add role **Users**. -7. Check **Create** only. -8. Leave table-level **Read**, **Update**, and **Delete** unchecked. -9. Click **Update** / **Save**. - -Why: table-level **Create** lets authenticated users add their own rows. The app then writes row-level read/update/delete permissions for that exact user, so users do not see each other's entries. - -## Attributes - -Create these attributes: - -| Key | Type | Required | Notes | -| --- | --- | --- | --- | -| `userId` | String, 64 | Yes | Current Appwrite user ID | -| `cans` | Float | Yes | Allows partial cans | -| `flavour` | String, 128 | Yes | Red Bull flavour | -| `flavourAccent` | String, 32 | Yes | UI colour | -| `sizeMl` | Integer | Yes | Can size in ml | -| `pricePerCan` | Float | Yes | GBP price per can | -| `dateTime` | DateTime | Yes | Intake timestamp | -| `notes` | String, 2000 | No | Optional notes | -| `store` | String, 256 | No | Store/location | -| `sugarFree` | Boolean | Yes | Sugar-free flag | -| `caffeineMgPerCan` | Float | No | Custom-size override | -| `importKey` | String, 512 | Yes | Duplicate detection signature | -| `source` | String, 32 | Yes | `manual`, `quick-add`, `excel`, or `json` | - -Recommended indexes: - -- `user_date_desc`: key index on `userId`, `dateTime` -- `user_import_key`: key index on `userId`, `importKey` -- Optional unique index on `userId`, `importKey` if your Appwrite plan/schema supports it - -## Encrypted Coach Chats - -Create a second table with ID: - -```text -coach_chats -``` - -Enable row security on `coach_chats`. - -Recommended table-level permissions: - -- Create: `users` -- Read: none -- Update: none -- Delete: none - -The app stores coach chat titles and messages as plain JSON in Appwrite with row-level user permissions. - -Create these chat columns: - -| Key | Type | Required | Notes | -| --- | --- | --- | --- | -| `userId` | String, 64 | Yes | Current Appwrite user ID | -| `title` | String, 512 | Yes | Chat title | -| `messages` | Longtext | Yes | JSON array of coach messages | -| `updatedAt` | DateTime | Yes | Sort key | - -Recommended chat index: - -- `user_chat_updated`: key index on `userId`, `updatedAt` - -## Component Structure - -- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/coach/data views, modals, and action state. -- `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper. -- `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures. -- `src/lib/coachChats.ts`: Appwrite-backed coach chat storage. -- `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview. -- `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks. -- `src/lib/storage.ts`: JSON backup export/import parser. -- `src/data/flavours.ts`: Built-in flavours and accent metadata. - -## Nutrition Defaults - -- 250ml: `£1.75`, `80mg` caffeine -- 355ml: `£2.20`, `114mg` caffeine -- 473ml: `£2.85`, `151mg` caffeine -- Custom sizes: caffeine is proportional from 250ml unless a custom override is entered - -The UI shows this disclaimer: - -> Caffeine and sugar values are estimates. Check the can label for exact nutritional information. +Vite injects the optional `.deploy` snippets into `index.html` at build time. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3d0c56e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ned Halksworth + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/api/ollama-chat.js b/api/ollama-chat.js deleted file mode 100644 index 5ed710b..0000000 --- a/api/ollama-chat.js +++ /dev/null @@ -1,77 +0,0 @@ -/* global Buffer, fetch, process */ - -const DEFAULT_MODEL = "deepseek-v4-pro:cloud"; - -export default async function handler(req, res) { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); - - if (req.method === "OPTIONS") { - res.statusCode = 204; - res.end(); - return; - } - - if (req.method !== "POST") { - res.statusCode = 405; - res.end("Method not allowed"); - return; - } - - const apiKey = process.env.OLLAMA_API_KEY; - if (!apiKey) { - res.statusCode = 500; - res.end("OLLAMA_API_KEY is not configured on the server."); - return; - } - - try { - const payload = await readJson(req); - const upstream = await fetch("https://ollama.com/api/chat", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...payload, - model: payload.model || process.env.OLLAMA_MODEL || DEFAULT_MODEL, - stream: payload.stream !== false, - }), - }); - - res.statusCode = upstream.status; - res.setHeader("Content-Type", upstream.headers.get("content-type") || "application/x-ndjson"); - - if (!upstream.ok) { - res.end(await upstream.text()); - return; - } - - if (!upstream.body) { - res.end(); - return; - } - - const reader = upstream.body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - res.write(Buffer.from(value)); - } - res.end(); - } catch (error) { - res.statusCode = 500; - res.end(error instanceof Error ? error.message : "Ollama proxy failed."); - } -} - -async function readJson(req) { - if (req.body && typeof req.body === "object") return req.body; - if (typeof req.body === "string") return JSON.parse(req.body || "{}"); - - let raw = ""; - for await (const chunk of req) raw += chunk; - return raw ? JSON.parse(raw) : {}; -} diff --git a/index.html b/index.html index 418e042..3c91a47 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + ; type SetupStatus = { state: "checking" | "ok" | "error"; message: string }; +type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } }; +const OLLAMA_MODEL = "deepseek-v4-pro:cloud"; +const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat"; -type PendingLimitAction = { - kind: "save" | "quick"; - draft: EntryDraft; - editingId?: string; - quickLabel?: string; +type ForecastPoint = { + label: string; + current: number; + lower: number; + limit?: number; }; const DEFAULT_FILTERS: Filters = { @@ -165,18 +173,9 @@ const NAV_ITEMS: Array<{ id: AppView; label: string; icon: LucideIcon }> = [ { id: "overview", label: "Overview", icon: Home }, { id: "logbook", label: "Logbook", icon: CalendarDays }, { id: "trends", label: "Trends", icon: LineChart }, - { id: "coach", label: "Coach", icon: MessageCircle }, { id: "settings", label: "Settings", icon: Settings2 }, ]; -const MATERIAL_ACCENTS = { - primary: "var(--chart-primary)", - secondary: "var(--chart-secondary)", - tertiary: "var(--chart-tertiary)", - error: "var(--chart-error)", - custom: "#b85d84", -}; - function App() { const [themeId, setThemeId] = useState(() => readStoredThemeId()); const activeTheme = useMemo(() => getThemeById(themeId), [themeId]); @@ -198,31 +197,31 @@ function App() { const [isResetOpen, setIsResetOpen] = useState(false); const [notice, setNotice] = useState("Appwrite session pending."); const [dataLoading, setDataLoading] = useState(false); - const [actionLoading, setActionLoading] = useState(null); - const [dataError, setDataError] = useState(""); + const [busyAction, setBusyAction] = useState(null); + const [syncError, setSyncError] = useState(""); const [importPreview, setImportPreview] = useState(null); const [userLimits, setUserLimits] = useState({}); const [limitConfirmOpen, setLimitConfirmOpen] = useState(false); const [limitConfirmMessage, setLimitConfirmMessage] = useState(""); const [pendingLimitAction, setPendingLimitAction] = useState(null); - const [showOnboarding, setShowOnboarding] = useState(false); + const [setupOpen, setSetupOpen] = useState(false); const excelFileInputRef = useRef(null); const jsonFileInputRef = useRef(null); useEffect(() => { - localStorage.setItem(THEME_STORAGE_KEY, themeId); + localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId)); }, [themeId]); const refreshEntries = useCallback(async (userId: string, showLoader = true) => { if (showLoader) setDataLoading(true); - setDataError(""); + setSyncError(""); try { const remoteEntries = await listEntries(userId); setEntries(sortEntries(remoteEntries)); setNotice(`Synced ${remoteEntries.length} Appwrite entr${remoteEntries.length === 1 ? "y" : "ies"}.`); } catch (error) { const message = appwriteErrorMessage(error); - setDataError(message); + setSyncError(message); setNotice("Appwrite sync failed."); } finally { if (showLoader) setDataLoading(false); @@ -253,11 +252,11 @@ function App() { setUser(currentUser); setUserLimits(parseUserLimits(currentUser.prefs)); if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) { - setThemeId(currentUser.prefs.themeId); + setThemeId(normaliseThemeId(currentUser.prefs.themeId)); } setNotice(`Signed in as ${currentUser.email || currentUser.name || "Appwrite user"}.`); if (!currentUser.prefs.onboarded) { - setShowOnboarding(true); + setSetupOpen(true); } } catch { if (!mounted) return; @@ -301,27 +300,20 @@ function App() { () => mergedFlavours(entries.map((entry) => entry.flavour)), [entries], ); - const filteredEntries = useMemo( + const entriesInView = useMemo( () => sortEntries(applyFilters(entries, filters)), [entries, filters], ); - const dashboard = useMemo(() => buildDashboard(entries), [entries]); + const summary = useMemo(() => buildDashboard(entries), [entries]); const limitCheck = useMemo(() => evaluateLimits(userLimits, entries), [userLimits, entries]); - const chartData = useMemo(() => groupByDay(filteredEntries), [filteredEntries]); - const weekData = useMemo(() => groupByWeek(filteredEntries), [filteredEntries]); - const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]); + const chartData = useMemo(() => groupByDay(entriesInView), [entriesInView]); + const weekData = useMemo(() => groupByWeek(entriesInView), [entriesInView]); + const flavourData = useMemo(() => groupByFlavour(entriesInView), [entriesInView]); const insights = useMemo(() => buildInsights(entries), [entries]); const recentEntries = useMemo(() => entries.slice(0, 5), [entries]); - const coachSession = useCoachSession( - user ?? ({ $id: "", email: "", name: "" } as AuthUser), - dashboard, - entries, - userLimits, - limitCheck, - ); async function login(email: string, password: string) { - setActionLoading("auth"); + setBusyAction("auth"); setAuthError(""); try { await account.createEmailPasswordSession({ email, password }); @@ -329,21 +321,21 @@ function App() { setUser(currentUser); setUserLimits(parseUserLimits(currentUser.prefs)); if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) { - setThemeId(currentUser.prefs.themeId); + setThemeId(normaliseThemeId(currentUser.prefs.themeId)); } setNotice(`Signed in as ${currentUser.email}.`); if (!currentUser.prefs.onboarded) { - setShowOnboarding(true); + setSetupOpen(true); } } catch (error) { setAuthError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } async function signup(name: string, email: string, password: string) { - setActionLoading("auth"); + setBusyAction("auth"); setAuthError(""); try { await account.create({ @@ -355,28 +347,27 @@ function App() { await account.createEmailPasswordSession({ email, password }); const currentUser = await account.get(); setUser(currentUser); - setUserLimits(parseUserLimits(currentUser.prefs)); setNotice(`Welcome, ${currentUser.name || currentUser.email}.`); - setShowOnboarding(true); + setSetupOpen(true); } catch (error) { setAuthError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } async function logout() { - setDataError(""); + setBusyAction("logout"); + setSyncError(""); try { await account.deleteSession({ sessionId: "current" }); setUser(null); setEntries([]); - setUserLimits({}); setNotice("Logged out."); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } @@ -390,10 +381,22 @@ function App() { setIsBarcodeScannerOpen(true); } - async function saveUserLimits(next: UserLimits) { + function addBarcodeDraft(draft: EntryDraft) { + setIsBarcodeScannerOpen(false); + saveDraftWithLimitCheck(draft); + } + + function editBarcodeDraft(draft: EntryDraft) { + setIsBarcodeScannerOpen(false); + setEditingEntry(null); + setEntryInitialDraft(draft); + setIsEntryModalOpen(true); + } + + async function saveEntry(draft: EntryDraft) { if (!user) return; - setActionLoading("save-limits"); - setDataError(""); + setBusyAction("save-limits"); + setSyncError(""); try { const prefs = mergePrefsWithLimits(user.prefs, next); await account.updatePrefs(prefs); @@ -402,16 +405,16 @@ function App() { setUserLimits(parseUserLimits(currentUser.prefs)); setNotice("Daily limits saved to your account."); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } async function saveOnboarding(limits: UserLimits, onboardingThemeId: string) { if (!user) return; - setActionLoading("save-onboarding"); - setDataError(""); + setBusyAction("save-onboarding"); + setSyncError(""); try { const limitsPrefs = mergePrefsWithLimits(user.prefs, limits); const nextPrefs = { @@ -424,43 +427,43 @@ function App() { setUser(currentUser); setUserLimits(parseUserLimits(currentUser.prefs)); setThemeId(onboardingThemeId); - setShowOnboarding(false); - setNotice("Onboarding limits and theme saved successfully."); + setSetupOpen(false); + setNotice("Setup saved."); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } - async function persistEntry(action: PendingLimitAction) { + async function saveDraft(action: PendingLimitAction) { if (!user) return; const loadingKey = action.kind === "quick" ? `quick-${action.quickLabel ?? "add"}` : "save-entry"; - setActionLoading(loadingKey); - setDataError(""); + setBusyAction(loadingKey); + setSyncError(""); try { const editing = action.editingId ? entries.find((entry) => entry.id === action.editingId) : null; const saved = editing ? await updateEntry(user.$id, editing.id, { ...action.draft, source: editing.source }) : await createEntry(user.$id, { ...action.draft, source: action.draft.source ?? "manual" }); setEntries((current) => - sortEntries(editing ? current.map((entry) => (entry.id === saved.id ? saved : entry)) : [saved, ...current]), + sortEntries(editingEntry ? current.map((entry) => (entry.id === saved.id ? saved : entry)) : [saved, ...current]), ); - setNotice(editing ? "Entry updated in Appwrite." : "Entry saved to Appwrite."); + setNotice(editingEntry ? "Entry updated in Appwrite." : "Entry saved to Appwrite."); setEditingEntry(null); setEntryInitialDraft(null); setIsEntryModalOpen(false); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); setLimitConfirmOpen(false); setPendingLimitAction(null); setLimitConfirmMessage(""); } } - function requestEntrySave(draft: EntryDraft, editingId?: string) { + function saveDraftWithLimitCheck(draft: EntryDraft, editingId?: string) { const check = evaluateLimits(userLimits, entries, { draft, excludeEntryId: editingId }); if (check.violations.length) { setPendingLimitAction({ kind: "save", draft, editingId }); @@ -468,24 +471,12 @@ function App() { setLimitConfirmOpen(true); return; } - void persistEntry({ kind: "save", draft, editingId }); + void saveDraft({ kind: "save", draft, editingId }); } - async function saveEntry(draft: EntryDraft) { + async function saveEntryDraft(draft: EntryDraft) { if (!user) return; - requestEntrySave(draft, editingEntry?.id); - } - - function addBarcodeDraft(draft: EntryDraft) { - setIsBarcodeScannerOpen(false); - requestEntrySave(draft); - } - - function editBarcodeDraft(draft: EntryDraft) { - setIsBarcodeScannerOpen(false); - setEditingEntry(null); - setEntryInitialDraft(draft); - setIsEntryModalOpen(true); + saveDraftWithLimitCheck(draft, editingEntry?.id); } async function quickAdd(item: (typeof QUICK_ADDS)[number]) { @@ -504,39 +495,43 @@ function App() { source: "quick-add", }; - const check = evaluateLimits(userLimits, entries, { draft }); - if (check.violations.length) { - setPendingLimitAction({ kind: "quick", draft, quickLabel: item.label }); - setLimitConfirmMessage(limitStatusMessage(check.violations, check, userLimits)); - setLimitConfirmOpen(true); - return; - } - - void persistEntry({ kind: "quick", draft, quickLabel: item.label }); - } - - function confirmLimitOverride() { - if (!pendingLimitAction) return; - void persistEntry(pendingLimitAction); - } - - async function deleteEntry(id: string) { - setActionLoading(`delete-${id}`); + setActionLoading(`quick-${item.label}`); setDataError(""); try { - await deleteEntryDocument(id); - setEntries((current) => current.filter((entry) => entry.id !== id)); - setNotice("Entry deleted from Appwrite."); + const saved = await createEntry(user.$id, draft); + setEntries((current) => sortEntries([saved, ...current])); + setNotice(`${item.label} saved to Appwrite.`); } catch (error) { setDataError(appwriteErrorMessage(error)); } finally { setActionLoading(null); } + + void saveDraft({ kind: "quick", draft, quickLabel: item.label }); + } + + function confirmLimitOverride() { + if (!pendingLimitAction) return; + void saveDraft(pendingLimitAction); + } + + async function deleteEntry(id: string) { + setBusyAction(`delete-${id}`); + setSyncError(""); + try { + await deleteEntryDocument(id); + setEntries((current) => current.filter((entry) => entry.id !== id)); + setNotice("Entry deleted from Appwrite."); + } catch (error) { + setSyncError(appwriteErrorMessage(error)); + } finally { + setBusyAction(null); + } } async function resetAll() { - setActionLoading("reset"); - setDataError(""); + setBusyAction("reset"); + setSyncError(""); try { await Promise.all(entries.map((entry) => deleteEntryDocument(entry.id))); setEntries([]); @@ -544,39 +539,39 @@ function App() { setIsResetOpen(false); setNotice("All Appwrite entries deleted."); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } async function exportExcel() { - setActionLoading("excel-export"); - setDataError(""); + setBusyAction("excel-export"); + setSyncError(""); try { const blob = await createExcelExport(entries); downloadBlob(blob, `red-bull-intake-${new Date().toISOString().slice(0, 10)}.xlsx`); setNotice("Excel workbook exported."); } catch (error) { - setDataError(error instanceof Error ? error.message : "Excel export failed."); + setSyncError(error instanceof Error ? error.message : "Excel export failed."); } finally { - setActionLoading(null); + setBusyAction(null); } } async function importExcel(file: File | undefined) { if (!file) return; - setActionLoading("excel-import"); - setDataError(""); + setBusyAction("excel-import"); + setSyncError(""); try { const preview = await parseExcelImport(file, entries); setImportPreview(preview); setNotice(`${preview.rows.length} Excel row${preview.rows.length === 1 ? "" : "s"} parsed for review.`); } catch (error) { - setDataError(error instanceof Error ? error.message : "Excel import failed."); + setSyncError(error instanceof Error ? error.message : "Excel import failed."); } finally { if (excelFileInputRef.current) excelFileInputRef.current.value = ""; - setActionLoading(null); + setBusyAction(null); } } @@ -591,17 +586,17 @@ function App() { return; } - setActionLoading("confirm-excel-import"); - setDataError(""); + setBusyAction("confirm-excel-import"); + setSyncError(""); try { const saved = await createEntries(user.$id, drafts); setEntries((current) => sortEntries([...saved, ...current])); setImportPreview(null); setNotice(`${saved.length} Excel row${saved.length === 1 ? "" : "s"} saved to Appwrite.`); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } @@ -613,8 +608,8 @@ function App() { async function importJson(file: File | undefined) { if (!file || !user) return; - setActionLoading("json-import"); - setDataError(""); + setBusyAction("json-import"); + setSyncError(""); try { const drafts = parseImport(await file.text()); const uniqueDrafts = drafts.filter((draft) => !isDuplicateDraft(entries, draft)); @@ -626,10 +621,10 @@ function App() { setEntries((current) => sortEntries([...saved, ...current])); setNotice(`${saved.length} JSON entr${saved.length === 1 ? "y" : "ies"} saved to Appwrite.`); } catch (error) { - setDataError(error instanceof Error ? error.message : "JSON import failed."); + setSyncError(error instanceof Error ? error.message : "JSON import failed."); } finally { if (jsonFileInputRef.current) jsonFileInputRef.current.value = ""; - setActionLoading(null); + setBusyAction(null); } } @@ -641,12 +636,11 @@ function App() { return ( ); @@ -654,17 +648,17 @@ function App() { return (
- {showOnboarding && user && ( + {setupOpen && user && ( setShowOnboarding(false)} + onClose={() => setSetupOpen(false)} /> )} -
+
setActiveView("settings")} /> -
+
- + - {activeView === "overview" && user && ( + {activeView === "overview" && ( void quickAdd(item)} onAdd={openNewEntry} onScan={openBarcodeScanner} - onOpenCoach={(prompt) => { - if (prompt) coachSession.queuePrompt(prompt); - setActiveView("coach"); - }} onOpenLogbook={() => setActiveView("logbook")} - onOpenSettings={() => setActiveView("settings")} /> )} {activeView === "logbook" && ( void saveUserLimits(next)} - /> - )} - - {activeView === "coach" && user && ( - )} {activeView === "settings" && ( void exportExcel()} onImportExcel={() => excelFileInputRef.current?.click()} onExportJson={exportJson} @@ -804,7 +783,7 @@ function App() { onReset={() => setIsResetOpen(true)} onThemeChange={setThemeId} onSaveLimits={(next) => void saveUserLimits(next)} - onRerunOnboarding={() => setShowOnboarding(true)} + onRerunOnboarding={() => setSetupOpen(true)} /> )} @@ -817,7 +796,7 @@ function App() { initialDraft={entryInitialDraft} flavours={allFlavours} open={isEntryModalOpen} - saving={actionLoading === "save-entry"} + saving={busyAction === "save-entry"} userLimits={userLimits} entries={entries} onClose={() => { @@ -825,11 +804,11 @@ function App() { setEditingEntry(null); setEntryInitialDraft(null); }} - onSave={(draft) => void saveEntry(draft)} + onSave={(draft) => void saveEntryDraft(draft)} /> setImportPreview(null)} onConfirm={() => void confirmExcelImport()} /> +
-
+
-

Red Bull tracker

-

{setupStatus.message}

+

Red Bull tracker

+

{setupStatus.message}

@@ -914,7 +893,6 @@ function AuthView({ shellStyle, themeId, onLogin, - onSignup, }: { authError: string; @@ -923,7 +901,6 @@ function AuthView({ shellStyle: CSSProperties; themeId: string; onLogin: (email: string, password: string) => Promise; - onSignup: (name: string, email: string, password: string) => Promise; }) { const [mode, setMode] = useState("login"); @@ -941,57 +918,49 @@ function AuthView({ } return ( -
+
-
+
-

Red Bull Tracker

-

Track intake, sync across devices.

+

Red Bull tracker

+

Track intake, sync across devices.

-
+
{setupStatus.state !== "ok" && ( -
+
{setupStatus.message}
)} -
- -
{mode === "signup" && ( -
+ +
+ + +
+
); } - function ThemePicker({ themeId, onChange, @@ -1018,37 +996,20 @@ function ThemePicker({ themeId: string; onChange: (id: string) => void; }) { - const [category, setCategory] = useState("vocaloid"); const activeTheme = getThemeById(themeId); - const visibleThemes = APP_THEMES.filter((theme) => theme.category === category); return (
-
- {THEME_CATEGORIES.map((entry) => ( - - ))} -
-
-
Primary
-
Surface
+
Button
+
Panel
Chart
- {visibleThemes.map((theme) => ( + {APP_THEMES.map((theme) => (
); @@ -1092,14 +1053,14 @@ function Sidebar({ onOpenSettings: () => void; }) { return ( -