From bd3e970286908987d0a777d7221b2ed17e7a45d4 Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Fri, 15 May 2026 21:36:13 +0100 Subject: [PATCH 01/11] intial commit --- .env.example | 18 +- APPWRITE_SETUP.md | 47 +- package-lock.json | 46 -- package.json | 4 +- src/App.tsx | 1464 +++++++++++------------------------------- src/data/flavours.ts | 29 +- src/index.css | 1130 ++++---------------------------- src/lib/appwrite.ts | 23 +- src/types.ts | 88 --- src/vite-env.d.ts | 3 - tailwind.config.ts | 25 +- vite.config.ts | 146 +---- 12 files changed, 566 insertions(+), 2457 deletions(-) diff --git a/.env.example b/.env.example index 4a14ee9..d1b772e 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,9 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 -VITE_APPWRITE_PROJECT_ID=your-project-id +VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138 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 - -# 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. +# Optional. Leave blank in local dev so the app uses the current Vite origin, +# including fallback ports like http://127.0.0.1:5174. +VITE_APPWRITE_OAUTH_SUCCESS_URL= +VITE_APPWRITE_OAUTH_FAILURE_URL= diff --git a/APPWRITE_SETUP.md b/APPWRITE_SETUP.md index ec5e506..152ed1c 100644 --- a/APPWRITE_SETUP.md +++ b/APPWRITE_SETUP.md @@ -21,14 +21,6 @@ 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 -npm run setup:appwrite -``` - -The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code. - Configured defaults: - Endpoint: `https://fra.cloud.appwrite.io/v1` @@ -36,8 +28,6 @@ Configured defaults: - Project name: `Red Bull Tracker App` - Database ID: `redbull_tracker` - Collection ID: `intake_entries` -- Chat collection ID: `coach_chats` -- Barcode collection ID: `barcode_products` `client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`. @@ -87,8 +77,6 @@ So if the Console asks you to create a **table**, that is the same resource as t 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 @@ -166,44 +154,11 @@ Recommended indexes: - `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/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/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. diff --git a/package-lock.json b/package-lock.json index 40f071d..4f3b2f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0", "dependencies": { "@vitejs/plugin-react": "^4.3.4", - "@zxing/browser": "^0.2.0", "appwrite": "^25.0.0", "exceljs": "^4.4.0", "framer-motion": "^11.18.2", @@ -1899,41 +1898,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@zxing/browser": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.0.tgz", - "integrity": "sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==", - "license": "MIT", - "optionalDependencies": { - "@zxing/text-encoding": "^0.9.0" - }, - "peerDependencies": { - "@zxing/library": "^0.22.0" - } - }, - "node_modules/@zxing/library": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz", - "integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "ts-custom-error": "^3.3.1" - }, - "engines": { - "node": ">= 24.0.0" - }, - "optionalDependencies": { - "@zxing/text-encoding": "~0.9.0" - } - }, - "node_modules/@zxing/text-encoding": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", - "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", - "license": "(Unlicense OR Apache-2.0)", - "optional": true - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -5115,16 +5079,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-custom-error": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", - "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/package.json b/package.json index d877f09..e492d48 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,10 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "setup:appwrite": "node scripts/setup-appwrite.mjs" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", - "@zxing/browser": "^0.2.0", "appwrite": "^25.0.0", "exceljs": "^4.4.0", "framer-motion": "^11.18.2", diff --git a/src/App.tsx b/src/App.tsx index ea18706..0afdf62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,28 +3,28 @@ import { Activity, AlertTriangle, CalendarDays, - Camera, + CheckCircle2, ChevronRight, Cloud, Command, + Database, Edit3, FileJson, FileSpreadsheet, Gauge, + Github, Home, - Info, LineChart, Loader2, LogIn, LogOut, - MessageCircle, Plus, PoundSterling, RefreshCcw, RotateCcw, Search, Settings2, - Sparkles, + ShieldCheck, TimerReset, Trash2, Upload, @@ -61,17 +61,7 @@ import { YAxis, } from "recharts"; import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta, mergedFlavours } from "./data/flavours"; -import { - APP_THEMES, - THEME_CATEGORIES, - THEME_STORAGE_KEY, - getThemeById, - readStoredThemeId, - type AppTheme, - type ThemeCategory, -} from "./data/themes"; -import { themeTokensToStyle } from "./lib/themeTokens"; -import { account, appwriteConfig, Channel, client, pingAppwrite } from "./lib/appwrite"; +import { account, appwriteConfig, Channel, client, OAuthProvider, pingAppwrite } from "./lib/appwrite"; import { appwriteErrorMessage, createEntries, @@ -81,20 +71,6 @@ import { listEntries, updateEntry, } from "./lib/appwriteEntries"; -import { CoachPanel } from "./components/CoachPanel"; -import { BarcodeScannerModal } from "./components/BarcodeScannerModal"; -import { DailyLimitsCard } from "./components/DailyLimitsCard"; -import { LimitsSettingsForm } from "./components/LimitsSettingsForm"; -import { OnboardingScreen } from "./components/OnboardingScreen"; -import { buildDynamicGreeting } from "./lib/greeting"; -import { - evaluateLimits, - limitStatusMessage, - mergePrefsWithLimits, - parseUserLimits, -} from "./lib/userLimits"; -import type { CoachSession } from "./lib/useCoachSession"; -import { useCoachSession } from "./lib/useCoachSession"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { caffeineFor, @@ -123,28 +99,15 @@ import { wholeNumber, } from "./lib/metrics"; import { exportPayload, parseImport } from "./lib/storage"; -import type { - DateFilter, - EntryDraft, - Filters, - Flavour, - ImportPreview, - LimitCheckResult, - RedBullEntry, - UserLimits, -} from "./types"; +import type { DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; -type AppView = "overview" | "logbook" | "trends" | "coach" | "settings"; +type AppView = "overview" | "logbook" | "trends" | "data"; type AuthMode = "login" | "signup"; +type AccentTheme = "blue" | "pink"; type AuthUser = Models.User; type SetupStatus = { state: "checking" | "ok" | "error"; message: string }; -type PendingLimitAction = { - kind: "save" | "quick"; - draft: EntryDraft; - editingId?: string; - quickLabel?: string; -}; +const ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1"; const DEFAULT_FILTERS: Filters = { flavour: "all", @@ -165,22 +128,16 @@ 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 }, + { id: "data", label: "Data", icon: Settings2 }, ]; -const MATERIAL_ACCENTS = { - primary: "var(--chart-primary)", - secondary: "var(--chart-secondary)", - tertiary: "var(--chart-tertiary)", - error: "var(--chart-error)", - custom: "#b85d84", -}; +const ACCENT_OPTIONS: Array<{ id: AccentTheme; label: string }> = [ + { id: "blue", label: "Baby blue" }, + { id: "pink", label: "Pastel pink" }, +]; function App() { - const [themeId, setThemeId] = useState(() => readStoredThemeId()); - const activeTheme = useMemo(() => getThemeById(themeId), [themeId]); - const shellStyle = useMemo(() => themeTokensToStyle(activeTheme.tokens), [activeTheme]); + const [themeAccent, setThemeAccent] = useState(() => readStoredAccent()); const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [authError, setAuthError] = useState(""); @@ -192,26 +149,19 @@ function App() { const [filters, setFilters] = useState(DEFAULT_FILTERS); const [activeView, setActiveView] = useState("overview"); const [isEntryModalOpen, setIsEntryModalOpen] = useState(false); - const [entryInitialDraft, setEntryInitialDraft] = useState(null); const [editingEntry, setEditingEntry] = useState(null); - const [isBarcodeScannerOpen, setIsBarcodeScannerOpen] = useState(false); 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 [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 excelFileInputRef = useRef(null); const jsonFileInputRef = useRef(null); useEffect(() => { - localStorage.setItem(THEME_STORAGE_KEY, themeId); - }, [themeId]); + localStorage.setItem(ACCENT_STORAGE_KEY, themeAccent); + }, [themeAccent]); const refreshEntries = useCallback(async (userId: string, showLoader = true) => { if (showLoader) setDataLoading(true); @@ -251,14 +201,7 @@ function App() { const currentUser = await account.get(); if (!mounted) return; setUser(currentUser); - setUserLimits(parseUserLimits(currentUser.prefs)); - if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) { - setThemeId(currentUser.prefs.themeId); - } setNotice(`Signed in as ${currentUser.email || currentUser.name || "Appwrite user"}.`); - if (!currentUser.prefs.onboarded) { - setShowOnboarding(true); - } } catch { if (!mounted) return; setUser(null); @@ -306,19 +249,11 @@ function App() { [entries, filters], ); const dashboard = 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 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"); @@ -327,14 +262,7 @@ function App() { await account.createEmailPasswordSession({ email, password }); const currentUser = await account.get(); setUser(currentUser); - setUserLimits(parseUserLimits(currentUser.prefs)); - if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) { - setThemeId(currentUser.prefs.themeId); - } setNotice(`Signed in as ${currentUser.email}.`); - if (!currentUser.prefs.onboarded) { - setShowOnboarding(true); - } } catch (error) { setAuthError(appwriteErrorMessage(error)); } finally { @@ -355,9 +283,7 @@ 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); } catch (error) { setAuthError(appwriteErrorMessage(error)); } finally { @@ -365,13 +291,23 @@ function App() { } } + function startOAuth(provider: "github" | "google") { + const selectedProvider = provider === "github" ? OAuthProvider.Github : OAuthProvider.Google; + setActionLoading("oauth"); + account.createOAuth2Session({ + provider: selectedProvider, + success: appwriteConfig.oauthSuccessUrl, + failure: appwriteConfig.oauthFailureUrl, + }); + } + async function logout() { + setActionLoading("logout"); setDataError(""); try { await account.deleteSession({ sessionId: "current" }); setUser(null); setEntries([]); - setUserLimits({}); setNotice("Logged out."); } catch (error) { setDataError(appwriteErrorMessage(error)); @@ -382,112 +318,30 @@ function App() { function openNewEntry() { setEditingEntry(null); - setEntryInitialDraft(null); setIsEntryModalOpen(true); } - function openBarcodeScanner() { - setIsBarcodeScannerOpen(true); - } - - async function saveUserLimits(next: UserLimits) { + async function saveEntry(draft: EntryDraft) { if (!user) return; - setActionLoading("save-limits"); + setActionLoading("save-entry"); setDataError(""); try { - const prefs = mergePrefsWithLimits(user.prefs, next); - await account.updatePrefs(prefs); - const currentUser = await account.get(); - setUser(currentUser); - setUserLimits(parseUserLimits(currentUser.prefs)); - setNotice("Daily limits saved to your account."); - } catch (error) { - setDataError(appwriteErrorMessage(error)); - } finally { - setActionLoading(null); - } - } - - async function saveOnboarding(limits: UserLimits, onboardingThemeId: string) { - if (!user) return; - setActionLoading("save-onboarding"); - setDataError(""); - try { - const limitsPrefs = mergePrefsWithLimits(user.prefs, limits); - const nextPrefs = { - ...limitsPrefs, - themeId: onboardingThemeId, - onboarded: true, - }; - await account.updatePrefs(nextPrefs); - const currentUser = await account.get(); - setUser(currentUser); - setUserLimits(parseUserLimits(currentUser.prefs)); - setThemeId(onboardingThemeId); - setShowOnboarding(false); - setNotice("Onboarding limits and theme saved successfully."); - } catch (error) { - setDataError(appwriteErrorMessage(error)); - } finally { - setActionLoading(null); - } - } - - async function persistEntry(action: PendingLimitAction) { - if (!user) return; - const loadingKey = action.kind === "quick" ? `quick-${action.quickLabel ?? "add"}` : "save-entry"; - setActionLoading(loadingKey); - setDataError(""); - 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" }); + const saved = editingEntry + ? await updateEntry(user.$id, editingEntry.id, { ...draft, source: editingEntry.source }) + : await createEntry(user.$id, { ...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)); } finally { setActionLoading(null); - setLimitConfirmOpen(false); - setPendingLimitAction(null); - setLimitConfirmMessage(""); } } - function requestEntrySave(draft: EntryDraft, editingId?: string) { - const check = evaluateLimits(userLimits, entries, { draft, excludeEntryId: editingId }); - if (check.violations.length) { - setPendingLimitAction({ kind: "save", draft, editingId }); - setLimitConfirmMessage(limitStatusMessage(check.violations, check, userLimits)); - setLimitConfirmOpen(true); - return; - } - void persistEntry({ kind: "save", draft, editingId }); - } - - async function saveEntry(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); - } - async function quickAdd(item: (typeof QUICK_ADDS)[number]) { if (!user) return; const meta = flavourMeta(item.flavour); @@ -504,20 +358,17 @@ 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; + setActionLoading(`quick-${item.label}`); + setDataError(""); + try { + 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 persistEntry({ kind: "quick", draft, quickLabel: item.label }); - } - - function confirmLimitOverride() { - if (!pendingLimitAction) return; - void persistEntry(pendingLimitAction); } async function deleteEntry(id: string) { @@ -634,39 +485,26 @@ function App() { } if (authLoading) { - return ; + return ; } if (!user) { return ( ); } return ( -
- {showOnboarding && user && ( - setShowOnboarding(false)} - /> - )} +
-
+
setActiveView("settings")} + onLogout={() => void logout()} /> -
+
void exportExcel()} + onImportExcel={() => excelFileInputRef.current?.click()} + onRefresh={() => void refreshEntries(user.$id)} /> @@ -716,9 +561,9 @@ function App() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.2 }} - className="app-main" + className="mt-4" > - {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")} /> )} @@ -769,42 +604,19 @@ function App() { filters={filters} flavours={allFlavours} onFilterChange={setFilters} - userLimits={userLimits} - onSaveLimits={(next) => void saveUserLimits(next)} /> )} - {activeView === "coach" && user && ( - - )} - - {activeView === "settings" && ( - void exportExcel()} onImportExcel={() => excelFileInputRef.current?.click()} onExportJson={exportJson} onImportJson={() => jsonFileInputRef.current?.click()} - onLogout={() => void logout()} onReset={() => setIsResetOpen(true)} - onThemeChange={setThemeId} - onSaveLimits={(next) => void saveUserLimits(next)} - onRerunOnboarding={() => setShowOnboarding(true)} /> )} @@ -814,30 +626,16 @@ function App() { { setIsEntryModalOpen(false); setEditingEntry(null); - setEntryInitialDraft(null); }} onSave={(draft) => void saveEntry(draft)} /> - setIsBarcodeScannerOpen(false)} - onEditBeforeAdding={editBarcodeDraft} - /> - setIsResetOpen(false)} onConfirm={() => void resetAll()} /> - - { - setLimitConfirmOpen(false); - setPendingLimitAction(null); - setLimitConfirmMessage(""); - }} - onConfirm={confirmLimitOverride} - />
); } @@ -882,24 +666,16 @@ function ShellBackdrop() { ); } -function LoadingScreen({ - setupStatus, - shellStyle, - themeId, -}: { - setupStatus: SetupStatus; - shellStyle: CSSProperties; - themeId: string; -}) { +function LoadingScreen({ setupStatus, themeAccent }: { setupStatus: SetupStatus; themeAccent: AccentTheme }) { return ( -
+
-

Red Bull tracker

+

Red Bull command centre

{setupStatus.message}

@@ -908,22 +684,22 @@ function LoadingScreen({ } function AuthView({ + accent, authError, busy, setupStatus, - shellStyle, - themeId, + onAccentChange, onLogin, - + onOAuth, onSignup, }: { + accent: AccentTheme; authError: string; busy: boolean; setupStatus: SetupStatus; - shellStyle: CSSProperties; - themeId: string; + onAccentChange: (accent: AccentTheme) => void; onLogin: (email: string, password: string) => Promise; - + onOAuth: (provider: "github" | "google") => void; onSignup: (name: string, email: string, password: string) => Promise; }) { const [mode, setMode] = useState("login"); @@ -941,179 +717,171 @@ function AuthView({ } return ( -
+
-
-
-
-

Red Bull Tracker

-

Track intake, sync across devices.

+
+
+
+
+

+ Red Bull Tracker App +

+

+ Glossy intake telemetry with Appwrite authentication, device sync, and finance-grade Excel exports. +

+
+ + + +
+ {setupStatus.state !== "ok" && ( +
+ {setupStatus.message} +
+ )} +
+ +
+
+ +
+
+ +
-
- {setupStatus.state !== "ok" && ( -
- {setupStatus.message} +
+ {mode === "signup" && ( + + )} + + + + {authError && ( +
+ {authError}
)} -
- - -
- - - {mode === "signup" && ( - - )} - - - - {authError && ( -
- {authError} -
- )} - - -
- + + +
+ + OAuth +
-
+ +
+ + +
+
); } +function AuthSignal({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) { + return ( +
+
+ ); +} -function ThemePicker({ - themeId, +function AccentPicker({ + accent, onChange, }: { - themeId: string; - onChange: (id: string) => void; + accent: AccentTheme; + onChange: (accent: AccentTheme) => 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
-
- Chart -
-
- -
- {visibleThemes.map((theme) => ( - - ))} -
- -

- Current theme: {activeTheme.label} -

+
+ {ACCENT_OPTIONS.map((option) => ( + + ))}
); } function Sidebar({ + accent, activeView, dataLoading, notice, setupStatus, user, - onAdd, - onScan, + onAccentChange, onChange, - onOpenSettings, + onLogout, }: { + accent: AccentTheme; activeView: AppView; dataLoading: boolean; notice: string; setupStatus: SetupStatus; user: AuthUser; - onAdd: () => void; - onScan: () => void; + onAccentChange: (accent: AccentTheme) => void; onChange: (view: AppView) => void; - onOpenSettings: () => void; + onLogout: () => void; }) { return ( - ); @@ -1148,12 +924,14 @@ function Sidebar({ function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (view: AppView) => void }) { return ( -
); @@ -1254,16 +1051,9 @@ function OverviewView({ recentEntries, chartData, flavourData, - user, - coachSession, - userLimits, - limitCheck, onQuickAdd, onAdd, - onScan, - onOpenCoach, onOpenLogbook, - onOpenSettings, }: { dashboard: Dashboard; entries: RedBullEntry[]; @@ -1272,85 +1062,40 @@ function OverviewView({ recentEntries: RedBullEntry[]; chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>; flavourData: Array<{ name: string; value: number; spend: number; accent: string }>; - user: AuthUser; - userLimits: UserLimits; - limitCheck: LimitCheckResult; - coachSession: CoachSession; onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void; onAdd: () => void; - onScan: () => void; - onOpenCoach: (prompt?: string) => void; onOpenLogbook: () => void; - onOpenSettings: () => void; }) { - const todaySpendRaw = limitCheck.todaySpend; - const spendLimitDetail = - userLimits.dailySpendLimit != null - ? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today` - : `${dashboard.monthSpend} this month`; - return (
- - - - -
- onOpenCoach()} - /> +
+
- - - {limitCheck.violations.length ? ( -
-
-
-
- ) : null} -
- - - - + + + +
- + {chartData.length ? ( - - - + + + - - - + + + } /> - + ) : ( @@ -1386,7 +1131,7 @@ function OverviewView({ {flavourData.length ? ( - + {flavourData.map((entry) => ( ))} @@ -1405,150 +1150,31 @@ function OverviewView({ ); } -function GreetingPanel({ - dashboard, - user, - userLimits, - limitCheck, - onOpenCoach, -}: { - dashboard: Dashboard; - user: AuthUser; - userLimits: UserLimits; - limitCheck: LimitCheckResult; - onOpenCoach: (prompt?: string) => void; -}) { - const todayNumber = Number.parseFloat(dashboard.todayCans) || 0; - const canLimit = userLimits.dailyCanLimit; - const progress = canLimit ? Math.min(100, Math.round((todayNumber / canLimit) * 100)) : 0; - const ringState = limitCheck.violations.includes("cans") - ? "over" - : canLimit && todayNumber >= canLimit * 0.75 - ? "warn" - : "ok"; - const name = firstName(user); - const greeting = buildDynamicGreeting({ - name, - todayCans: todayNumber, - favouriteFlavour: dashboard.favouriteFlavour, - currentStreak: Number.parseInt(dashboard.currentStreak, 10) || 0, - todayCaffeineMg: Number.parseFloat(dashboard.todayCaffeine.replace(/[^\d.]/g, "")) || 0, - allTimeCans: Number.parseFloat(dashboard.allTimeCans) || 0, - dailyCanLimit: canLimit, - limitCheck, - }); - - const coachPrompts = [ - { - label: "Pace today's caffeine", - prompt: "what does my red bull pattern say about today?", - }, - { - label: "Sugar-free swap", - prompt: "give me one lower-sugar swap based on my favourite flavour.", - }, - { - label: "Weekly spend trend", - prompt: "review my weekly spend trend and suggest one saving.", - }, - ]; - - return ( -
-
-
-
- {dashboard.todayCans} - {canLimit ? `of ${canLimit}` : "today"} -
-
- -
-
-
-

{greeting.headline}

-

{greeting.subline}

-
- -
- - - -
-
- -
- {coachPrompts.map((item) => ( - - ))} -
-
- ); -} - -function WellnessPill({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - function TodayPanel({ dashboard, entries, - userLimits, - limitCheck, onAdd, - onScan, }: { dashboard: Dashboard; entries: RedBullEntry[]; - userLimits: UserLimits; - limitCheck: LimitCheckResult; onAdd: () => void; - onScan: () => void; }) { - const limitSummary = [ - userLimits.dailyCanLimit != null ? `${limitCheck.todayCans.toFixed(1)}/${userLimits.dailyCanLimit} cans` : null, - userLimits.dailySpendLimit != null - ? `${currency.format(limitCheck.todaySpend)} of ${currency.format(userLimits.dailySpendLimit)} spend` - : null, - ] - .filter(Boolean) - .join(" · "); - return ( -
+
+

Today

{dashboard.todayCans}

cans logged

- {limitSummary ?

{limitSummary}

: null}
- - - + + +
-
- +
- -
- -
); } - -function SpendingPredictionsCard({ - entries, - userLimits, - onSaveLimits, -}: { - entries: RedBullEntry[]; - userLimits: UserLimits; - onSaveLimits?: (limits: UserLimits) => void; -}) { - const [projectionDays, setProjectionDays] = useState<7 | 30 | 90 | 365>(30); - const now = new Date(); - - // Establish typical daily averages over last 30 calendar days (or all time if tracked less than 30 days) - const firstEntryDate = useMemo(() => { - if (!entries.length) return now; - return new Date( - [...entries].sort( - (a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime() - )[0].dateTime - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entries]); - - const trackingDays = useMemo(() => { - const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime()); - return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24))); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [firstEntryDate]); - - const activePeriodDays = Math.min(30, trackingDays); - - const stats = useMemo(() => { - const cutoff = new Date(now.getTime() - activePeriodDays * 24 * 60 * 60 * 1000); - const recent = entries.filter((e) => new Date(e.dateTime) >= cutoff); - const totalSpend = recent.reduce((sum, e) => sum + e.cans * e.pricePerCan, 0); - const totalCans = recent.reduce((sum, e) => sum + e.cans, 0); - - return { - avgDailySpend: totalSpend / activePeriodDays, - avgDailyCans: totalCans / activePeriodDays, - hasData: entries.length > 0, - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entries, activePeriodDays]); - - const projectionData = useMemo(() => { - return Array.from({ length: projectionDays }).map((_, index) => { - const day = index + 1; - const dataPoint: Record = { - label: `Day ${day}`, - "Current Path": Number((day * stats.avgDailySpend).toFixed(2)), - "Optimal Path (-20%)": Number((day * stats.avgDailySpend * 0.8).toFixed(2)), - }; - if (userLimits.dailySpendLimit != null) { - dataPoint["Daily Limit Path"] = Number((day * userLimits.dailySpendLimit).toFixed(2)); - } - return dataPoint; - }); - }, [projectionDays, stats, userLimits.dailySpendLimit]); - - if (!stats.hasData) { - return ( - - - - ); - } - - const projectedSpend = stats.avgDailySpend * projectionDays; - const projectedCans = stats.avgDailyCans * projectionDays; - const optimalSpend = projectedSpend * 0.8; - const potentialSavings = projectedSpend - optimalSpend; - - const handleApplyOptimalLimit = () => { - if (!onSaveLimits) return; - const optimalDailySpendLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100; - onSaveLimits({ - ...userLimits, - dailySpendLimit: optimalDailySpendLimit, - }); - }; - - return ( - -
- {/* Toggle Range */} -
-

Select projection window:

-
- {([7, 30, 90, 365] as const).map((days) => ( - - ))} -
-
- - {/* Projections Stats Grid */} -
-
- Projected spend -

{currency.format(projectedSpend)}

- - ~{oneDecimal.format(projectedCans)} cans logged - -
- -
- Optimal path (-20%) -

{currency.format(optimalSpend)}

- - ~{oneDecimal.format(projectedCans * 0.8)} cans logged - -
- -
-
- Potential savings -

{currency.format(potentialSavings)}

-
- {onSaveLimits && ( - - )} -
-
- - {/* Projections Recharts AreaChart */} -
- - - - - - - - - - - - - - - `£${val}`} /> - } /> - - - {userLimits.dailySpendLimit != null && ( - - )} - - -
- -
- - - The Optimal Path models a sustainable 20% reduction target, which fits guidelines for a healthy energy drink moderation pace. If a budget is active, the Limit Path displays the projection if you exhaust your daily limit budget completely every day. - -
-
-
- ); -} - - -function SettingsView({ - activeTheme, +function DataView({ dashboard, - dataLoading, entries, - notice, - setupStatus, - themeId, - user, - userLimits, - limitCheck, actionLoading, onExportExcel, onImportExcel, onExportJson, onImportJson, - onLogout, onReset, - onThemeChange, - onSaveLimits, - onRerunOnboarding, }: { - activeTheme: AppTheme; dashboard: Dashboard; - dataLoading: boolean; entries: RedBullEntry[]; - notice: string; - setupStatus: SetupStatus; - themeId: string; - user: AuthUser | null; - userLimits: UserLimits; - limitCheck: LimitCheckResult; actionLoading: string | null; onExportExcel: () => void; onImportExcel: () => void; onExportJson: () => void; onImportJson: () => void; - onLogout: () => void; onReset: () => void; - onThemeChange: (id: string) => void; - onSaveLimits: (limits: UserLimits) => void; - onRerunOnboarding: () => void; }) { return (
-
- - -
- -
-
+ +
+ + + +
- -
-

{user?.name || "Appwrite user"}

-

{user?.email}

-
- {dataLoading ?
-

{setupStatus.message}

- -
-
- - - - - - -
- - - -
- -
- - - - - -
- -
-

Configured Appwrite IDs

-
- - - - - -
-
- - -
-
+ + + +
+ +
+

Configured Appwrite IDs

+
+ + + + +
+
+ + +
- +
- {draftLimitCheck?.violations.length ? ( -

- {limitStatusMessage(draftLimitCheck.violations, draftLimitCheck, userLimits)} You can still save with - confirmation. -

- ) : null} -
- - - + + +
@@ -2993,19 +2293,6 @@ function formatMetricValue(name: string, value: number) { return oneDecimal.format(value); } -function firstName(user: AuthUser) { - const fallback = user.email?.split("@")[0] ?? "there"; - const value = (user.name || fallback).trim(); - return value.split(/\s+/)[0] || "there"; -} - -function userInitials(user: AuthUser) { - if (user.name) { - return user.name.split(" ").map((part) => part[0]).join("").toUpperCase().slice(0, 2); - } - return (user.email?.[0] ?? "U").toUpperCase(); -} - function sizeToPreset(size: number) { if (size === 250 || size === 355 || size === 473) return size.toString(); return "custom"; @@ -3018,4 +2305,9 @@ function actionLabel(value: string) { .replace(/\b\w/g, (letter) => letter.toUpperCase()); } +function readStoredAccent(): AccentTheme { + const value = localStorage.getItem(ACCENT_STORAGE_KEY); + return value === "pink" ? "pink" : "blue"; +} + export default App; diff --git a/src/data/flavours.ts b/src/data/flavours.ts index e220cd0..669b6f6 100644 --- a/src/data/flavours.ts +++ b/src/data/flavours.ts @@ -1,25 +1,20 @@ import type { Flavour } from "../types"; export const BUILT_IN_FLAVOURS: Flavour[] = [ - { name: "Original", accent: "#282874" }, - { name: "Zero", accent: "#B1D0EE", sugarFree: true }, - { name: "Sugar Free", accent: "#009EDF", sugarFree: true }, - { name: "Ruby", accent: "#B50045" }, - { name: "Iced Vanilla", accent: "#53B2C2" }, - { name: "Tropical", accent: "#FFCB04" }, - { name: "Cherry Edition", accent: "#D81B60" }, - { name: "Apricot Edition", accent: "#F3911B" }, - { name: "Lilac Sugarfree", accent: "#7D62CE", sugarFree: true }, - { name: "Pink Sugarfree", accent: "#E77BAB", sugarFree: true }, - { name: "Watermelon", accent: "#E6301F" }, + { name: "Original", accent: "#00A7FF" }, + { name: "Sugar Free", accent: "#E7EEF8", sugarFree: true }, + { name: "Ruby", accent: "#C3093B" }, + { name: "Iced Vanilla", accent: "#49adbe" }, + { name: "Tropical", accent: "#FFC247" }, + { name: "Watermelon", accent: "#FF355E" }, { name: "Blueberry", accent: "#496DFF" }, - { name: "Coconut Berry", accent: "#0070B8" }, - { name: "Peach", accent: "#E24585" }, - { name: "Juneberry", accent: "#0085C8" }, + { name: "Coconut Berry", accent: "#D8F9FF" }, + { name: "Peach", accent: "#FF9B63" }, + { name: "Juneberry", accent: "#9C73FF" }, { name: "Dragon Fruit", accent: "#FF3DBD" }, - { name: "Curuba Elderflower", accent: "#78B941" }, - { name: "Winter Edition", accent: "#BF1431" }, - { name: "Summer Edition", accent: "#F2E853" }, + { name: "Curuba Elderflower", accent: "#B7FF4A" }, + { name: "Winter Edition", accent: "#7CE7FF" }, + { name: "Summer Edition", accent: "#f0e53b" }, { name: "Other", accent: "#AEB9C7" }, ]; diff --git a/src/index.css b/src/index.css index 4d50ac9..40e3692 100644 --- a/src/index.css +++ b/src/index.css @@ -2,22 +2,10 @@ @tailwind components; @tailwind utilities; -@font-face { - font-family: "Google Sans"; - src: local("Google Sans"), local("GoogleSans-Regular"); - font-display: swap; -} - -@font-face { - font-family: "Google Sans Text"; - src: local("Google Sans Text"), local("GoogleSansText-Regular"); - font-display: swap; -} - :root { color-scheme: light; - font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; - background: #f8fbff; + font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif; + background: #f5fbff; } * { @@ -26,19 +14,18 @@ html { min-width: 320px; - background: #f8fbff; + background: #f5fbff; } body { min-width: 320px; min-height: 100vh; margin: 0; - background: #f8fbff; - color: #1f252a; - font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; - font-weight: 400; + background: #f5fbff; + color: #193042; + font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; + text-rendering: geometricPrecision; } button, @@ -52,12 +39,12 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { - outline: 2px solid var(--primary, #9c4168); + outline: 2px solid var(--accent-strong, #74c7ec); outline-offset: 3px; } ::selection { - background: color-mix(in srgb, var(--primary-container, #ffd8e7) 68%, transparent); + background: color-mix(in srgb, var(--accent, #bdeeff) 55%, transparent); } ::-webkit-scrollbar { @@ -66,991 +53,189 @@ textarea:focus-visible { } ::-webkit-scrollbar-track { - background: var(--surface-container-low, #fff0f5); + background: rgba(230, 244, 255, 0.92); } ::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--outline, #85737a) 42%, transparent); - border: 3px solid var(--surface-container-low, #fff0f5); + background: rgba(116, 155, 184, 0.45); border-radius: 999px; } @layer components { - .auth-layout { - @apply mx-auto grid min-h-screen w-full max-w-6xl gap-6 px-4 py-8 lg:grid-cols-[1.05fr_0.95fr]; - align-items: center; - } - - .auth-hero { - @apply min-w-0; - } - - .auth-signal-grid { - @apply mt-6 grid gap-3 sm:grid-cols-3; - } - - .auth-panel { - @apply border p-5 shadow-fridge sm:p-6; - background: color-mix(in srgb, var(--surface-container) 88%, white); - border-color: var(--outline-variant); - border-radius: 28px; - } - - .state-chip { - @apply inline-flex min-h-10 items-center gap-2 px-3 text-sm font-medium; - background: var(--primary-container); - border-radius: 999px; - color: var(--on-primary-container); - } - - .segmented-control { - @apply grid grid-cols-2 gap-1 border p-1; - background: var(--surface-container-high); - border-color: var(--outline-variant); - border-radius: 999px; - } - - .segmented-control button { - @apply min-h-10 px-3 text-sm font-normal transition; - border-radius: 999px; - color: var(--muted); - } - - .segmented-control-active { - background: var(--primary-container); - color: var(--on-primary-container) !important; - } - - .app-layout { - @apply mx-auto grid w-full gap-4 px-3 pb-28 pt-3; - max-width: 1720px; - } - - .app-content { - @apply min-w-0; - } - - .app-main { - @apply mt-4; - } - - .material-drawer { - @apply sticky top-6 hidden h-[calc(100vh-3rem)] flex-col border p-4 lg:flex; - background: color-mix(in srgb, var(--surface-container-lowest) 84%, transparent); - border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent); - border-radius: 32px; - box-shadow: var(--elevation-1); - } - - .drawer-brand { - @apply mb-5 flex items-center gap-3 px-1; - } - - .drawer-primary-action { - @apply mb-5 inline-flex min-h-14 items-center justify-center gap-3 px-5 text-sm font-medium shadow-can transition active:scale-[0.99]; - background: var(--primary-container); - border-radius: 18px; - color: var(--on-primary-container); - } - - .drawer-nav { - @apply grid gap-2; - } - - .drawer-footer { - @apply mt-auto grid gap-3; - } - - .drawer-info-card { - @apply border p-4; - background: var(--surface-container-high); - border-color: var(--outline-variant); - border-radius: 22px; - } - - .top-app-bar { - @apply border p-4 sm:p-5; - background: color-mix(in srgb, var(--surface-container-lowest) 86%, transparent); - border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent); - border-radius: 34px; - box-shadow: var(--elevation-1); - } - - .top-app-bar-main { - @apply flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between; - } - - .top-title-cluster { - @apply flex min-w-0 items-start gap-3; - } - - .top-app-icon { - @apply mt-1 flex h-12 w-12 shrink-0 items-center justify-center; - background: var(--primary-container); - border-radius: 16px; - color: var(--on-primary-container); - } - - .top-kicker { - @apply text-sm font-normal; - color: var(--primary); - } - - .top-title { - @apply mt-1 break-words text-4xl font-medium sm:text-5xl; - color: var(--text); - } - - .top-meta-row { - @apply flex flex-wrap items-center gap-2; - } - - .account-chip { - @apply inline-flex min-h-10 max-w-full items-center rounded-md px-3 text-xs font-normal; - background: var(--surface-container-high); - color: var(--muted); - } - - .top-action-row { - @apply mt-5 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between; - } - - .top-action-primary, - .top-action-secondary { - @apply flex flex-wrap gap-2; - } - - .mobile-nav-bar { - @apply fixed inset-x-3 bottom-3 z-40 grid grid-cols-5 gap-1 border p-1 shadow-fridge; - background: color-mix(in srgb, var(--surface-container-high) 92%, white); - border-color: var(--outline-variant); - border-radius: 28px; - padding-bottom: calc(0.25rem + env(safe-area-inset-bottom, 0px)); - } - - .mobile-nav-item { - @apply flex min-h-16 flex-col items-center justify-center gap-1 text-[11px] font-medium transition; - border-radius: 22px; - color: var(--muted); - } - - .mobile-nav-item-active { - background: var(--primary-container); - color: var(--on-primary-container) !important; - } - - @media (min-width: 1024px) { - .app-layout { - grid-template-columns: 300px minmax(0, 1fr); - gap: 24px; - padding: 24px; - } - - .app-main { - margin-top: 24px; - } - } - .glass-panel { - @apply rounded-lg border shadow-fridge; - background: color-mix(in srgb, var(--surface-container-lowest) 88%, transparent); - border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent); - border-radius: 34px; + @apply rounded-lg border shadow-fridge backdrop-blur-2xl; + background: color-mix(in srgb, var(--panel) 86%, white); + border-color: var(--border); } .can-panel { - @apply rounded-lg border shadow-can; - background: linear-gradient(135deg, var(--primary-container), var(--surface-container-high) 58%, var(--tertiary-container)); - border-color: color-mix(in srgb, var(--primary) 20%, var(--outline-variant)); - border-radius: 36px; - } - - .today-panel { + @apply rounded-lg border shadow-cyan backdrop-blur-2xl; background: - radial-gradient(circle at 18% 18%, color-mix(in srgb, var(--primary-container) 82%, white) 0 22%, transparent 44%), - linear-gradient(145deg, var(--surface-container-lowest), var(--surface-container) 54%, var(--tertiary-container)); - } - - .oura-hero { - background: - radial-gradient(circle at 14% 28%, color-mix(in srgb, var(--primary-container) 72%, white) 0 18%, transparent 42%), - radial-gradient(circle at 82% 8%, color-mix(in srgb, var(--secondary-container) 70%, white) 0 18%, transparent 38%), - var(--surface-container-lowest); - } - - .oura-ring { - @apply grid h-32 w-32 shrink-0 place-items-center p-2 shadow-can; - background: conic-gradient(var(--primary) var(--progress), var(--surface-container-high) 0); - border-radius: 999px; - } - - .oura-ring > div { - @apply flex h-full w-full flex-col items-center justify-center; - background: var(--surface-container-lowest); - border-radius: inherit; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--outline-variant) 72%, transparent); - } - - .oura-ring span { - @apply text-4xl font-medium leading-none; - color: var(--text); - } - - .oura-ring small { - @apply mt-1 text-xs font-normal uppercase; - color: var(--muted); - } - - .oura-ring--warn { - background: conic-gradient(var(--chart-tertiary) var(--progress), var(--surface-container-high) 0); - } - - .oura-ring--over { - background: conic-gradient(var(--chart-error) var(--progress), var(--surface-container-high) 0); - } - - .limits-card { - @apply border border-white/10; - } - - .limit-row { - @apply rounded-xl border p-4; - border-color: var(--outline-variant); - background: color-mix(in srgb, var(--surface-container-high) 72%, white); - } - - .limit-row--warn { - border-color: color-mix(in srgb, var(--chart-tertiary) 55%, transparent); - background: color-mix(in srgb, var(--chart-tertiary) 12%, var(--surface-container-high)); - } - - .limit-row--over { - border-color: color-mix(in srgb, var(--chart-error) 55%, transparent); - background: color-mix(in srgb, var(--chart-error) 12%, var(--surface-container-high)); - } - - .limit-row-head { - @apply flex items-center justify-between gap-2 text-sm; - } - - .limit-row-head span { - color: var(--muted); - } - - .limit-row-head strong { - color: var(--text); - font-weight: 500; - } - - .limit-row-value { - @apply mt-2 text-sm; - color: var(--muted); - } - - .limit-progress { - @apply mt-3 h-2 overflow-hidden rounded-full; - background: color-mix(in srgb, var(--outline-variant) 40%, transparent); - } - - .limit-progress-fill { - @apply h-full rounded-full transition-all duration-300; - background: var(--primary); - } - - .limit-row--warn .limit-progress-fill { - background: var(--chart-tertiary); - } - - .limit-row--over .limit-progress-fill { - background: var(--chart-error); - } - - .limit-banner { - @apply rounded-lg border px-3 py-2 text-sm leading-6; - border-color: color-mix(in srgb, var(--chart-tertiary) 45%, transparent); - background: color-mix(in srgb, var(--chart-tertiary) 14%, var(--surface-container-high)); - color: var(--text); - } - - .wellness-pill { - @apply flex items-center justify-between gap-3 rounded-full border px-4 py-3 text-sm; - background: color-mix(in srgb, var(--surface-container-high) 78%, white); - border-color: var(--outline-variant); - } - - .wellness-pill span { - color: var(--muted); - } - - .wellness-pill strong { - color: var(--text); - } - - .suggestion-chip { - @apply min-h-11 rounded-full border px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed; - background: color-mix(in srgb, var(--surface-container-high) 72%, white); - border-color: var(--outline-variant); - color: var(--text); - } - - .suggestion-chip:hover:not(:disabled) { - background: var(--primary-container); - color: var(--on-primary-container); - box-shadow: var(--elevation-1); - } - - .coach-panel { - @apply flex flex-col gap-3 p-5 sm:p-6; - } - - .coach-panel-compact { - min-height: 360px; - } - - .coach-panel-full { - min-height: calc(100vh - 220px); - } - - .coach-panel-header { - @apply flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between; - } - - .coach-panel-title { - @apply flex items-start gap-3; - } - - .coach-panel-icon { - @apply flex h-10 w-10 shrink-0 items-center justify-center rounded-xl; - background: var(--primary-container); - color: var(--on-primary-container); - } - - .coach-panel-kicker { - @apply text-xs font-medium uppercase tracking-wide; - color: var(--primary); - } - - .coach-panel-heading { - @apply text-lg font-medium leading-snug; - color: var(--text); - } - - .coach-panel-meta { - @apply flex flex-wrap items-center gap-2; - } - - .coach-status-pill { - @apply inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium; - background: var(--surface-container-high); - border-color: var(--outline-variant); - color: var(--muted); - } - - .coach-status-dot { - @apply h-2 w-2 rounded-full; - background: var(--chart-secondary); - } - - .coach-status-dot-busy { - background: var(--chart-tertiary); - @apply animate-pulse; - } - - .coach-model-tag { - @apply text-xs; - color: var(--muted); - } - - .coach-expand-button { - @apply inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium; - border-color: var(--outline-variant); - color: var(--text); - } - - .coach-thread-strip { - @apply flex flex-wrap items-center gap-2; - } - - .coach-thread-chip { - @apply inline-flex items-center overflow-hidden rounded-full border text-xs font-medium; - border-color: var(--outline-variant); - background: var(--surface-container-high); - } - - .coach-thread-chip button:first-child { - @apply px-3 py-1.5; - color: var(--text); - } - - .coach-thread-chip button:last-child { - @apply px-2 py-1.5 opacity-60; - color: var(--muted); - } - - .coach-thread-chip-active { - background: var(--primary-container); - border-color: color-mix(in srgb, var(--primary) 30%, var(--outline-variant)); - } - - .coach-thread-new { - @apply inline-flex h-8 w-8 items-center justify-center rounded-full border; - border-color: var(--outline-variant); - color: var(--text); - } - - .coach-panel-context { - @apply flex flex-wrap gap-3 text-xs font-medium; - color: var(--muted); - } - - .coach-panel-feed { - @apply grid flex-1 content-start gap-3 overflow-y-auto rounded-2xl border p-3; - background: var(--surface-container-low); - border-color: var(--outline-variant); - min-height: 200px; - max-height: min(56vh, 640px); - } - - .coach-panel-feed-compact { - min-height: 160px; - max-height: 280px; - } - - .coach-panel-empty { - @apply flex flex-col items-center justify-center gap-3 px-4 py-8 text-center; - color: var(--muted); - } - - .coach-panel-empty p { - @apply max-w-sm text-sm leading-6; - } - - .coach-quick-grid { - @apply grid w-full gap-2; - } - - .coach-line { - @apply grid grid-cols-[auto_1fr] gap-2; - } - - .coach-line-avatar { - @apply flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-medium; - background: var(--surface-container-high); - color: var(--text); - } - - .coach-line-assistant .coach-line-avatar { - background: var(--primary-container); - color: var(--on-primary-container); - } - - .coach-line-user .coach-line-avatar { - background: var(--tertiary-container); - color: var(--on-tertiary-container); - } - - .coach-line-body { - @apply min-w-0 rounded-2xl px-3 py-2 text-sm leading-relaxed; - background: var(--surface-container-high); - color: var(--text); - } - - .coach-line-user .coach-line-body { - background: var(--primary); - color: var(--on-primary); - } - - .coach-line-typing { - @apply inline-block animate-pulse; - color: var(--muted); - } - - .coach-panel-error { - @apply rounded-xl border px-3 py-2 text-sm; - border-color: var(--error-container); - background: var(--error-container); - color: var(--on-error-container); - } - - .coach-panel-composer { - @apply flex items-center gap-2; - } - - .coach-panel-input { - @apply min-h-11 flex-1; - } - - .coach-panel-send { - @apply min-h-11 min-w-11 px-0; - } - - .thinking-pill { - @apply mb-2 overflow-hidden rounded-full border; - background: color-mix(in srgb, var(--surface-container) 88%, black 4%); - border-color: color-mix(in srgb, var(--outline-variant) 80%, var(--primary) 20%); - } - - .thinking-pill-track { - @apply relative flex min-h-10 items-center justify-center overflow-hidden px-4; - } - - .thinking-pill-label { - @apply relative z-[1] text-xs font-medium tracking-wide; - color: var(--muted); - } - - .thinking-pill-chevron { - @apply absolute right-3 z-[1] text-xs font-medium opacity-70; - color: var(--primary); - animation: thinking-unlock-nudge 1.8s ease-in-out infinite; - } - - .thinking-pill-shimmer { - @apply absolute inset-y-0 left-0 w-16 rounded-full opacity-70; - background: linear-gradient( - 90deg, - transparent, - color-mix(in srgb, var(--primary-container) 70%, white), - transparent - ); - animation: thinking-unlock-slide 1.8s ease-in-out infinite; - } - - .thinking-pill-stopped .thinking-pill-shimmer, - .thinking-pill-stopped .thinking-pill-chevron { - animation: none; - opacity: 0.35; - } - - .thinking-details { - @apply mt-2; - } - - .thinking-details summary { - @apply cursor-pointer text-xs font-normal; - color: var(--muted); - } - - .thinking-details summary:hover { - color: var(--text); - } - - .thinking-trace { - @apply mt-2 max-h-56 overflow-auto rounded-xl border p-3 text-xs leading-5 whitespace-pre-wrap; - background: var(--surface-container); - border-color: var(--outline-variant); - color: var(--muted); - } - - .coach-composer { - @apply absolute inset-x-0 bottom-0 z-10; - background: linear-gradient(to top, var(--surface-container-lowest) 60%, transparent); - padding: 0 1rem 1rem; - } - - .coach-composer-inner { - @apply mx-auto flex max-w-3xl items-end gap-2 rounded-2xl border p-2; - background: var(--surface-container-high); - border-color: var(--outline-variant); - box-shadow: var(--elevation-2); - } - - .coach-input { - @apply min-h-11 flex-1 resize-none rounded-xl border-0 px-3 py-2 text-sm; - background: transparent; - color: var(--text); - field-sizing: content; - max-height: 160px; - } - - .coach-input:focus { - box-shadow: none; - } - - .coach-input::placeholder { - color: var(--muted); - } - - .composer-icon-button, - .composer-send-button { - @apply flex h-9 w-9 shrink-0 items-center justify-center rounded-xl transition disabled:cursor-not-allowed disabled:opacity-40; - } - - .composer-icon-button:hover { - background: var(--surface-container); - } - - .composer-send-button { - background: var(--primary); - color: var(--on-primary); - } - - .composer-send-button:hover:not(:disabled) { - filter: brightness(1.05); - } - - .composer-stop-button { - background: var(--surface-container-high); - color: var(--text); - } - - .coach-unlock-card { - @apply mt-6 flex w-full max-w-md flex-col gap-3; - } - - .coach-unlock-card .coach-input { - @apply rounded-xl border px-4 py-3; - background: var(--surface-container-lowest); - border-color: var(--outline-variant); - } - - .coach-error { - @apply mx-auto max-w-3xl px-4 pb-2; - } - - .coach-error-inner { - @apply rounded-xl border px-3 py-2 text-sm; - border-color: var(--error-container); - background: var(--error-container); - color: var(--on-error-container); - } - - .coach-typing-dots { - @apply flex items-center gap-1 py-1; - } - - .coach-typing-dots span { - @apply inline-block h-2 w-2 rounded-full; - background: var(--muted); - animation: coach-bounce 1.4s infinite ease-in-out both; - } - - .coach-typing-dots span:nth-child(1) { animation-delay: 0s; } - .coach-typing-dots span:nth-child(2) { animation-delay: 0.16s; } - .coach-typing-dots span:nth-child(3) { animation-delay: 0.32s; } - - @keyframes coach-bounce { - 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } - 40% { transform: scale(1); opacity: 1; } - } - - .coach-hint { - @apply mt-1.5 text-center text-xs; - color: var(--muted); - } - - @media (min-width: 1280px) { - .coach-shell { - /* sidebar visible */ - } + linear-gradient(135deg, color-mix(in srgb, var(--accent) 42%, white), rgba(255, 255, 255, 0.96) 52%, color-mix(in srgb, var(--accent-soft) 76%, white)); + border-color: var(--border); } .can-emblem { - @apply flex h-11 w-11 items-center justify-center rounded-lg border shadow-can; - background: var(--primary-container); - border-color: color-mix(in srgb, var(--primary) 24%, var(--outline-variant)); - color: var(--on-primary-container); + @apply flex h-11 w-11 items-center justify-center rounded-lg border text-[#193042] shadow-cyan; + background: linear-gradient(135deg, var(--accent), #ffffff 58%, var(--accent-warm)); + border-color: color-mix(in srgb, var(--accent-strong) 35%, white); } - .command-button, - .secondary-button { - @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-sm transition active:scale-[0.99] disabled:cursor-not-allowed; - background: var(--secondary-container); - border-color: transparent; - color: var(--on-secondary-container); - border-radius: 999px; - } - - .primary-button { - @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-can transition active:scale-[0.99] disabled:cursor-not-allowed; - background: var(--primary); - border-color: transparent; - color: var(--on-primary); - border-radius: 999px; - } - - .excel-button { - @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-sm transition active:scale-[0.99] disabled:cursor-not-allowed; - background: var(--tertiary-container); - border-color: transparent; - color: var(--on-tertiary-container); - border-radius: 999px; - } - - .primary-button:hover, - .secondary-button:hover, - .command-button:hover, - .excel-button:hover, - .icon-button:hover, - .quick-add:hover, - .list-button:hover { - filter: brightness(0.985); - box-shadow: var(--elevation-2); - } - - .primary-button:disabled, - .secondary-button:disabled, - .command-button:disabled, - .excel-button:disabled, - .danger-button:disabled { - box-shadow: none; - opacity: 0.58; - } - - .danger-button { - @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-sm transition active:scale-[0.99] disabled:cursor-not-allowed; - background: var(--error); - border-color: transparent; - color: var(--on-error); - border-radius: 999px; - } - - .nav-item { - @apply flex min-h-12 items-center gap-3 border border-transparent px-4 text-sm font-normal transition; - border-radius: 999px; - color: var(--muted); - } - - .nav-item:hover { - background: var(--surface-container-high); + .command-button { + @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 font-display text-sm font-semibold shadow-sm transition active:scale-[0.99]; + border-color: var(--border); color: var(--text); } + .primary-button { + @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-semibold text-[#193042] shadow-cyan transition disabled:cursor-not-allowed; + background: var(--accent); + border-color: color-mix(in srgb, var(--accent-strong) 42%, white); + } + + .secondary-button { + @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 text-sm font-semibold shadow-sm transition disabled:cursor-not-allowed; + border-color: var(--border); + color: var(--text); + } + + .excel-button { + @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-semibold text-[#193042] shadow-cyan transition hover:brightness-105 disabled:cursor-not-allowed; + background: linear-gradient(135deg, #ffe1ef, var(--accent)); + border-color: color-mix(in srgb, var(--accent-strong) 35%, white); + } + + .danger-button { + @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-red-300 bg-red-500/90 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-red-400 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-slate-200 disabled:text-slate-500; + } + + .nav-item { + @apply flex min-h-11 items-center gap-3 rounded-md px-3 text-sm font-medium transition; + color: var(--muted); + } + .nav-item-active { - background: var(--primary-container); - color: var(--on-primary-container) !important; - box-shadow: none; + @apply shadow-cyan; + background: var(--accent); + color: var(--text) !important; } .icon-button { - @apply inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border shadow-sm transition; - background: var(--surface-container-high); - border-color: var(--outline-variant); + @apply inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-white shadow-sm transition; + border-color: var(--border); color: var(--text); } .quick-add { - @apply inline-flex min-h-12 items-center gap-2 rounded-md border px-3 text-sm font-medium shadow-sm transition; - background: var(--surface-container-high); - border-color: var(--outline-variant); + @apply inline-flex min-h-12 items-center gap-2 rounded-md border bg-white px-3 font-display text-sm font-semibold shadow-sm transition hover:border-[var(--accent)]; color: var(--text); + border-color: var(--border); } .field-label { - @apply grid gap-2 text-sm font-normal; + @apply grid gap-2 text-sm font-medium; color: var(--muted); } .field-control { - @apply w-full rounded-md border px-3 py-3 text-base font-normal shadow-sm transition; - background: var(--surface-container-lowest); - border-color: var(--outline-variant); + @apply w-full rounded-md border bg-white px-3 py-3 text-base font-normal shadow-sm transition; + border-color: var(--border); color: var(--text); - border-radius: 16px; - } - - .field-control:focus { - border-color: var(--primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); } .modal-panel { - @apply max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg border p-5 shadow-fridge sm:p-6; - background: var(--surface-container-lowest); - border-color: var(--outline-variant); - color: var(--text); - border-radius: 28px; + @apply max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg border bg-white/95 p-5 shadow-fridge backdrop-blur-2xl sm:p-6; + border-color: var(--border); } .entry-row { - @apply grid gap-3 rounded-lg border p-4 transition sm:grid-cols-[1fr_auto] sm:items-center; - background: var(--surface-container-high); - border-color: var(--outline-variant); - border-radius: 18px; - } - - .entry-row:hover { - box-shadow: var(--elevation-1); + @apply grid gap-3 rounded-lg border bg-white/80 p-4 transition sm:grid-cols-[1fr_auto] sm:items-center; + border-color: var(--border); } .list-button { - @apply flex min-h-11 items-center justify-between rounded-lg border px-3 text-sm font-medium transition; - background: var(--secondary-container); - border-color: transparent; - color: var(--on-secondary-container); + @apply flex min-h-11 items-center justify-between rounded-lg border bg-white px-3 text-sm font-semibold transition; + border-color: var(--border); + color: var(--accent-strong); } .status-card { - @apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm; + @apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm backdrop-blur-xl; } - .theme-indicator { - @apply inline-flex min-h-11 items-center gap-2 rounded-full border px-3 text-sm font-medium transition; - background: var(--surface-container-high); - border-color: var(--outline-variant); - color: var(--text); + .accent-picker { + @apply inline-flex min-h-11 items-center gap-1 rounded-md border bg-white/80 p-1 shadow-sm; + border-color: var(--border); } - .theme-indicator:hover { - background: var(--primary-container); - color: var(--on-primary-container); - } - - .theme-indicator-swatch { - @apply h-4 w-4 rounded-full border border-white shadow-sm; - } - - .theme-indicator-label { - @apply max-w-[9rem] truncate; - } - - .settings-section { - @apply grid gap-4; - } - - .settings-tabs { - @apply inline-flex flex-wrap gap-1 rounded-full border p-1; - background: var(--surface-container-high); - border-color: var(--outline-variant); - } - - .settings-tabs button { - @apply rounded-full px-4 py-2 text-sm font-medium transition; + .accent-picker button { + @apply inline-flex min-h-9 items-center gap-2 rounded px-3 text-sm font-semibold transition; color: var(--muted); } - .settings-tabs button:hover, - .settings-tab-active { - background: var(--primary-container); - color: var(--on-primary-container) !important; + .accent-picker button:hover, + .accent-picker-active { + background: var(--accent-soft); + color: var(--text) !important; } - .theme-preview-strip { - @apply flex flex-wrap gap-2; + .accent-swatch { + @apply h-3 w-3 rounded-full border border-white shadow-sm; } - .theme-preview-chip { - color: var(--text); + .accent-swatch-blue { + background: #bdeeff; } - .theme-picker-grid { - @apply grid gap-3 sm:grid-cols-2 lg:grid-cols-3; - } - - .theme-tile { - @apply flex min-h-[4.5rem] items-center gap-3 rounded-xl border px-3 py-3 text-left transition; - background: var(--surface-container); - border-color: var(--outline-variant); - color: var(--text); - } - - .theme-tile:hover { - box-shadow: var(--elevation-1); - border-color: var(--outline); - } - - .theme-tile-active { - border-color: var(--primary); - box-shadow: var(--elevation-1); - background: var(--primary-container); - color: var(--on-primary-container); - } - - .theme-tile-swatch { - @apply h-10 w-10 shrink-0 rounded-full border border-white shadow-sm; - } - - .theme-tile-label { - @apply text-sm font-medium leading-5; + .accent-swatch-pink { + background: #ffd6e8; } } .app-shell { - --primary: #4b86ad; - --on-primary: #ffffff; - --primary-container: #dff2ff; - --on-primary-container: #10283a; - --secondary: #647887; - --on-secondary: #ffffff; - --secondary-container: #ecf3f7; - --on-secondary-container: #1f2d35; - --tertiary: #9b7b51; - --on-tertiary: #ffffff; - --tertiary-container: #f4eadb; - --on-tertiary-container: #332313; - --error: #ba1a1a; - --on-error: #ffffff; - --error-container: #ffdad6; - --on-error-container: #410002; - --bg: #f8fbff; - --surface: #f8fbff; - --surface-container-lowest: #ffffff; - --surface-container-low: #f1f7fb; - --surface-container: #edf3f7; - --surface-container-high: #e7eef3; - --panel: var(--surface-container); - --panel-strong: var(--surface-container-lowest); - --outline: #7c8992; - --outline-variant: #dce5ea; - --text: #1f252a; - --muted: #68747c; - --subtle: #839099; - --accent: var(--primary-container); - --accent-soft: var(--surface-container-low); - --accent-strong: var(--primary); - --accent-warm: #eef4f7; - --chart-primary: #4b86ad; - --chart-secondary: #6f8f7c; - --chart-tertiary: #9b7b51; - --chart-error: #ba1a1a; - --chart-grid: rgba(124, 137, 146, 0.18); - --elevation-1: 0 12px 34px rgba(69, 91, 108, 0.08), 0 1px 2px rgba(69, 91, 108, 0.06); - --elevation-2: 0 18px 44px rgba(69, 91, 108, 0.12), 0 2px 8px rgba(69, 91, 108, 0.08); + --accent: #bdeeff; + --accent-soft: #e7f8ff; + --accent-strong: #4aa8d6; + --accent-warm: #ffe2ef; + --bg: #f5fbff; + --panel: #f8fcff; + --panel-strong: #ffffff; + --border: rgba(104, 164, 198, 0.24); + --text: #193042; + --muted: #607587; + --subtle: #7e93a3; min-height: 100vh; background: var(--bg) !important; color: var(--text) !important; - font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; +} + +.app-shell[data-accent="pink"] { + --accent: #ffd6e8; + --accent-soft: #fff0f7; + --accent-strong: #d46c9d; + --accent-warm: #dff6ff; + --bg: #fff8fc; + --panel: #fffbfd; + --border: rgba(210, 108, 157, 0.22); } .backdrop-wash { background: - radial-gradient(circle at 70% 35%, var(--primary-container) 0 18%, transparent 42%), - radial-gradient(circle at 12% 12%, var(--surface-container-lowest) 0 18%, transparent 38%), - linear-gradient(180deg, var(--bg) 0%, var(--surface-container-lowest) 46%, var(--surface-container-low) 100%); + radial-gradient(circle at 14% 12%, color-mix(in srgb, var(--accent) 48%, transparent), transparent 32%), + radial-gradient(circle at 82% 6%, color-mix(in srgb, var(--accent-warm) 72%, transparent), transparent 34%), + linear-gradient(180deg, var(--bg) 0%, #ffffff 46%, color-mix(in srgb, var(--accent-soft) 66%, white) 100%); } .backdrop-grid { background-image: - linear-gradient(var(--chart-grid) 1px, transparent 1px), - linear-gradient(90deg, var(--chart-grid) 1px, transparent 1px); - background-size: 48px 48px; - opacity: 0; + linear-gradient(color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px); + background-size: 42px 42px; + opacity: 0.5; } .backdrop-rail { - background: linear-gradient(90deg, var(--primary-container), var(--accent-warm), var(--tertiary-container), var(--primary-container)); -} - -.app-shell *, -.app-shell .tracking-tight, -.app-shell [class*="tracking-"] { - letter-spacing: 0 !important; -} - -.app-shell .font-black, -.app-shell .font-extrabold { - font-weight: 600 !important; -} - -.app-shell .font-bold, -.app-shell .font-semibold, -.app-shell strong, -.app-shell b { - font-weight: 500 !important; -} - -.app-shell .font-medium { - font-weight: 400 !important; + background: linear-gradient(90deg, var(--accent), #ffffff, var(--accent-warm), var(--accent)); } .app-shell .text-white, @@ -1069,77 +254,46 @@ textarea:focus-visible { .app-shell .text-cyan-50, .app-shell .text-cyan-100, -.app-shell .text-cyan-200, -.app-shell .text-pink-100, -.app-shell .text-pink-200 { - color: var(--primary) !important; +.app-shell .text-cyan-200 { + color: var(--accent-strong) !important; } .app-shell .text-emerald-200 { - color: #2d6f57 !important; + color: #16845c !important; } .app-shell .text-amber-100, .app-shell .text-amber-200 { - color: #765930 !important; + color: #8a5a00 !important; } .app-shell .text-red-100, .app-shell .text-red-200 { - color: var(--error) !important; + color: #b4233c !important; } .app-shell .text-\[\#07101f\] { - color: var(--on-primary-container) !important; -} - -.app-shell .primary-button, -.app-shell .primary-button * { - color: var(--on-primary) !important; -} - -.app-shell .secondary-button, -.app-shell .secondary-button *, -.app-shell .command-button, -.app-shell .command-button * { - color: var(--on-secondary-container) !important; -} - -.app-shell .excel-button, -.app-shell .excel-button * { - color: var(--on-tertiary-container) !important; + color: var(--text) !important; } .app-shell .bg-\[\#050711\], .app-shell .bg-\[\#090f22\]\/90, .app-shell .bg-\[\#0d142c\], .app-shell .bg-\[\#080d1f\]\/95, -.app-shell .bg-\[\#070d1f\]\/90, -.app-shell .bg-\[\#07101f\] { - background: var(--surface-container-high) !important; +.app-shell .bg-\[\#070d1f\]\/90 { + background: var(--panel-strong) !important; } .app-shell .bg-cyan-300, .app-shell .bg-cyan-200, .app-shell .bg-cyan-300\/10, -.app-shell .bg-cyan-200\/10, -.app-shell .bg-cyan-300\/15 { - background-color: var(--primary-container) !important; +.app-shell .bg-cyan-200\/10 { + background-color: var(--accent) !important; } .app-shell .bg-pink-200, .app-shell .bg-pink-200\/10 { - background-color: var(--secondary-container) !important; -} - -.app-shell .bg-amber-300\/10, -.app-shell .bg-amber-300\/15 { - background-color: var(--tertiary-container) !important; -} - -.app-shell .bg-red-500\/10, -.app-shell .bg-red-500\/15 { - background-color: var(--error-container) !important; + background-color: var(--accent-soft) !important; } .app-shell .bg-white\/5, @@ -1151,7 +305,7 @@ textarea:focus-visible { .app-shell .hover\:bg-white\/10:hover, .app-shell .hover\:bg-white\/\[0\.10\]:hover, .app-shell .hover\:bg-white\/\[0\.12\]:hover { - background-color: var(--surface-container-high) !important; + background-color: color-mix(in srgb, var(--accent-soft) 52%, white) !important; } .app-shell .border-white\/10, @@ -1159,54 +313,26 @@ textarea:focus-visible { .app-shell .border-cyan-200\/25, .app-shell .border-cyan-200\/30, .app-shell .border-cyan-300\/30, -.app-shell .border-cyan-300\/40, .app-shell .border-pink-200\/30, -.app-shell .border-pink-200\/40, -.app-shell .border-amber-300\/40, -.app-shell .border-red-400\/40 { - border-color: var(--outline-variant) !important; +.app-shell .border-pink-200\/40 { + border-color: var(--border) !important; } .app-shell .shadow-fridge, .app-shell .shadow-cyan, .app-shell .shadow-sm { - box-shadow: var(--elevation-1) !important; + box-shadow: 0 18px 55px rgba(83, 139, 174, 0.14), 0 1px 2px rgba(83, 139, 174, 0.10) !important; } .app-shell .danger-button, .app-shell .danger-button * { - color: var(--on-error) !important; + color: #ffffff !important; } .app-shell .field-control::placeholder { color: color-mix(in srgb, var(--muted) 58%, white); } -.app-shell input[type="checkbox"] { - accent-color: var(--primary); -} - .app-shell .modal-panel { color: var(--text); } - -@keyframes thinking-unlock-slide { - 0% { - transform: translateX(-120%); - } - 100% { - transform: translateX(calc(100vw + 120%)); - } -} - -@keyframes thinking-unlock-nudge { - 0%, - 100% { - transform: translateX(0); - opacity: 0.45; - } - 50% { - transform: translateX(-4px); - opacity: 1; - } -} diff --git a/src/lib/appwrite.ts b/src/lib/appwrite.ts index b2265ef..ef7ed74 100644 --- a/src/lib/appwrite.ts +++ b/src/lib/appwrite.ts @@ -1,16 +1,15 @@ -import { Account, Channel, Client, ID, Permission, Query, Role, TablesDB } from "appwrite"; +import { Account, Channel, Client, ID, OAuthProvider, Permission, Query, Role, TablesDB } from "appwrite"; const env = import.meta.env; const currentOrigin = window.location.origin; export const appwriteConfig = { endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1", - projectId: env.VITE_APPWRITE_PROJECT_ID!, + projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138", databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker", collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries", - chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats", - barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products", - + oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL), + oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL), }; const client = new Client() @@ -24,6 +23,18 @@ export async function pingAppwrite() { return client.ping(); } -export { account, Channel, client, ID, Permission, Query, Role, tablesDB }; +export { account, Channel, client, ID, OAuthProvider, Permission, Query, Role, tablesDB }; +function resolveOAuthUrl(value?: string) { + if (!value) return currentOrigin; + const configured = new URL(value, currentOrigin); + const current = new URL(currentOrigin); + const localHosts = new Set(["localhost", "127.0.0.1", "::1"]); + + if (env.DEV && localHosts.has(configured.hostname) && localHosts.has(current.hostname)) { + return currentOrigin; + } + + return configured.toString().replace(/\/$/, ""); +} diff --git a/src/types.ts b/src/types.ts index 94f3fca..4bc3f58 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,57 +34,6 @@ export type EntryDraft = Omit< source?: RedBullEntry["source"]; }; -export type BarcodeFormatName = "ean-13" | "ean-8" | "upc-a" | "upc-e" | "unknown"; - -export type BarcodeProductDraft = { - flavourName: string; - sizeMl: number; - pricePerCan: number; - sugarFree?: boolean; - caffeineMgPerCan?: number; -}; - -export type ResolvedBarcodeProduct = BarcodeProductDraft & { - flavourAccent: string; - source: "built-in" | "user"; -}; - -export type BarcodeSeedProduct = BarcodeProductDraft & { - verifiedBy: string; - sourceName?: string; - sourceUrl?: string; - notes?: string; - variant?: string; -}; - -export type UserBarcodeMapping = BarcodeProductDraft & { - barcode: string; - createdAt: string; - updatedAt: string; -}; - -export type BarcodeLookupCatalog = { - verifiedProducts?: Record; - userMappings?: UserBarcodeMapping[]; -}; - -export type BarcodeLookupResult = - | { - status: "known" | "user"; - barcode: string; - product: ResolvedBarcodeProduct; - } - | { - status: "partial"; - barcode: string; - product: BarcodeProductDraft; - reason: string; - } - | { - status: "unknown"; - barcode: string; - }; - export type Filters = { flavour: string; dateRange: DateFilter; @@ -105,40 +54,3 @@ export type ImportPreview = { fileName: string; rows: ImportPreviewRow[]; }; - -export type ChatRole = "user" | "assistant"; - -export type CoachMessage = { - id: string; - role: ChatRole; - content: string; - thinking?: string; - pending?: boolean; - stopped?: boolean; -}; - -export type CoachChat = { - id: string; - userId: string; - title: string; - messages: CoachMessage[]; - createdAt: string; - updatedAt: string; -}; - -export type UserLimits = { - dailyCanLimit?: number; - dailySpendLimit?: number; - stopTime?: string; -}; - -export type LimitViolation = "cans" | "spend" | "stopTime"; - -export type LimitCheckResult = { - violations: LimitViolation[]; - projectedCans: number; - projectedSpend: number; - todayCans: number; - todaySpend: number; - pastStopTime: boolean; -}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 82823cc..419d521 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,11 +5,8 @@ interface ImportMetaEnv { readonly VITE_APPWRITE_PROJECT_ID?: string; readonly VITE_APPWRITE_DATABASE_ID?: string; readonly VITE_APPWRITE_COLLECTION_ID?: string; - readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string; - readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string; readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string; readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string; - readonly VITE_OLLAMA_PROXY_URL?: string; } interface ImportMeta { diff --git a/tailwind.config.ts b/tailwind.config.ts index 44a53ff..c13601e 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -6,21 +6,20 @@ export default { extend: { fontFamily: { display: [ - "Google Sans", - "Google Sans Text", - "Product Sans", - "Roboto", + "SF Pro Display", + "SF Pro Text", "-apple-system", "BlinkMacSystemFont", + "Avenir Next", + "Helvetica Neue", "sans-serif", ], body: [ - "Google Sans", - "Google Sans Text", - "Product Sans", - "Roboto", + "SF Pro Text", "-apple-system", "BlinkMacSystemFont", + "Avenir Next", + "Helvetica Neue", "sans-serif", ], }, @@ -39,11 +38,11 @@ export default { }, }, boxShadow: { - apple: "0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08)", - fridge: "0 2px 6px rgba(69, 54, 62, 0.12), 0 8px 18px rgba(69, 54, 62, 0.08)", - can: "0 1px 2px rgba(156, 65, 104, 0.18), 0 3px 8px rgba(156, 65, 104, 0.10)", - redline: "0 2px 8px rgba(186, 26, 26, 0.20)", - cyan: "0 1px 2px rgba(156, 65, 104, 0.16), 0 4px 12px rgba(156, 65, 104, 0.10)", + apple: "0 18px 55px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18)", + fridge: "0 18px 70px rgba(0, 0, 0, 0.34), 0 1px 2px rgba(255, 255, 255, 0.06)", + can: "0 10px 24px rgba(57, 213, 255, 0.12)", + redline: "0 12px 28px rgba(255, 52, 72, 0.26)", + cyan: "0 14px 32px rgba(57, 213, 255, 0.18)", }, backgroundImage: { "carbon-grid": diff --git a/vite.config.ts b/vite.config.ts index 115ecc9..83a6d9d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,140 +1,18 @@ +import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -import type { IncomingMessage, ServerResponse } from "node:http"; -import type { Plugin } from "vite"; -import { defineConfig, loadEnv } from "vite"; -const DEFAULT_MODEL = "deepseek-v4-pro:cloud"; - -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ""); - const ollamaProxy = { - target: "https://ollama.com", - changeOrigin: true, - rewrite: () => "/api/chat", - configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) { - proxy.on("proxyReq", (proxyReq) => { - if (env.OLLAMA_API_KEY) { - proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`); - } - }); - }, - }; - - return { - plugins: [react(), ollamaProxyPlugin(env)], - server: { - proxy: { - "/api/ollama-chat": ollamaProxy, - }, - }, - preview: { - proxy: { - "/api/ollama-chat": ollamaProxy, - }, - }, - build: { - chunkSizeWarningLimit: 700, - rollupOptions: { - output: { - manualChunks: { - charts: ["recharts"], - motion: ["framer-motion"], - icons: ["lucide-react"], - }, +export default defineConfig({ + plugins: [react()], + build: { + chunkSizeWarningLimit: 700, + rollupOptions: { + output: { + manualChunks: { + charts: ["recharts"], + motion: ["framer-motion"], + icons: ["lucide-react"], }, }, }, - }; + }, }); - -function ollamaProxyPlugin(env: Record): Plugin { - return { - name: "ollama-proxy", - configureServer(server) { - server.middlewares.use("/api/ollama-chat", createOllamaHandler(env)); - }, - configurePreviewServer(server) { - server.middlewares.use("/api/ollama-chat", createOllamaHandler(env)); - }, - }; -} - -function createOllamaHandler(env: Record) { - return (req: IncomingMessage, res: ServerResponse) => { - 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.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Method not allowed"); - return; - } - - void handleOllamaProxy(req, res, env); - }; -} - -async function handleOllamaProxy(req: IncomingMessage, res: ServerResponse, env: Record) { - const apiKey = env.OLLAMA_API_KEY; - if (!apiKey) { - res.statusCode = 500; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("OLLAMA_API_KEY is not configured on the server."); - return; - } - - try { - const payload = await readJsonBody(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 || 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.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end(error instanceof Error ? error.message : "Ollama proxy failed."); - } -} - -async function readJsonBody(req: IncomingMessage) { - let raw = ""; - for await (const chunk of req) raw += chunk; - return raw ? (JSON.parse(raw) as Record) : {}; -} From 08372febfe8d672391d46fa7f4a41f9a0fd941cf Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Fri, 22 May 2026 22:39:38 +0100 Subject: [PATCH 02/11] feat: enhance Appwrite integration and chat functionality - Added support for encrypted coach chats with a new `coach_chats` collection in the Appwrite database. - Updated `.env.example` to include `OLLAMA_API_KEY`, `OLLAMA_MODEL`, and `APPWRITE_API_KEY` for server-side configurations. - Introduced a setup script in `package.json` for initializing Appwrite database tables. - Enhanced the Vite configuration to proxy requests to the Ollama API. - Updated the main application structure to accommodate new chat features and improved theme management. - Refined CSS styles for better UI consistency and added new components for chat functionality. --- .env.example | 13 + APPWRITE_SETUP.md | 48 +- package.json | 3 +- scripts/setup-appwrite.mjs | 100 +--- src/App.tsx | 958 ++++++++++++++++++++++++++++++++----- src/data/themes.ts | 200 ++++---- src/index.css | 660 ++++++++++++++++++++++--- src/lib/appwrite.ts | 1 + src/lib/encryptedChats.ts | 178 +++++++ src/types.ts | 20 + src/vite-env.d.ts | 2 + vite.config.ts | 50 +- 12 files changed, 1845 insertions(+), 388 deletions(-) create mode 100644 src/lib/encryptedChats.ts diff --git a/.env.example b/.env.example index d1b772e..8705f05 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,21 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138 VITE_APPWRITE_DATABASE_ID=redbull_tracker VITE_APPWRITE_COLLECTION_ID=intake_entries +VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats # Optional. Leave blank in local dev so the app uses the current Vite origin, # including fallback ports like http://127.0.0.1:5174. VITE_APPWRITE_OAUTH_SUCCESS_URL= VITE_APPWRITE_OAUTH_FAILURE_URL= + +# 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 + +# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite. +APPWRITE_API_KEY= + +# Appwrite chat table columns needed for encrypted coach chats: +# userId, encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, updatedAt as strings +# version as integer. Enable row security and Users -> Create at table level. diff --git a/APPWRITE_SETUP.md b/APPWRITE_SETUP.md index 152ed1c..3ed9557 100644 --- a/APPWRITE_SETUP.md +++ b/APPWRITE_SETUP.md @@ -21,6 +21,14 @@ 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 +npm run setup:appwrite +``` + +The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code. + Configured defaults: - Endpoint: `https://fra.cloud.appwrite.io/v1` @@ -28,6 +36,7 @@ Configured defaults: - Project name: `Red Bull Tracker App` - Database ID: `redbull_tracker` - Collection ID: `intake_entries` +- Chat collection ID: `coach_chats` `client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`. @@ -154,11 +163,48 @@ Recommended indexes: - `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 encrypts chat titles and messages in the browser before writing rows. The encryption passphrase is not stored, and Appwrite only receives ciphertext. + +Create these chat columns: + +| Key | Type | Required | Notes | +| --- | --- | --- | --- | +| `userId` | String, 64 | Yes | Current Appwrite user ID | +| `encryptedTitle` | String, 4000 | Yes | AES-GCM ciphertext | +| `encryptedMessages` | String, 50000+ | Yes | AES-GCM ciphertext for message JSON | +| `titleIv` | String, 128 | Yes | Base64 IV | +| `messagesIv` | String, 128 | Yes | Base64 IV | +| `salt` | String, 128 | Yes | Base64 PBKDF2 salt | +| `version` | Integer | Yes | Crypto version | +| `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/data views, modals, and action state. +- `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/encryptedChats.ts`: Client-side encrypted chat storage for Appwrite. - `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. diff --git a/package.json b/package.json index e492d48..7fec1a0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "setup:appwrite": "node scripts/setup-appwrite.mjs" }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", diff --git a/scripts/setup-appwrite.mjs b/scripts/setup-appwrite.mjs index 036e48c..cc11891 100644 --- a/scripts/setup-appwrite.mjs +++ b/scripts/setup-appwrite.mjs @@ -1,7 +1,6 @@ /* global console, fetch, process, setTimeout */ import { existsSync, readFileSync } from "node:fs"; -import { URL } from "node:url"; const env = loadEnvFiles([".env", ".env.local"]); @@ -10,11 +9,7 @@ const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138"); const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker"); const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries"); const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats"); -const barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products"); const apiKey = readEnv("APPWRITE_API_KEY", ""); -const verifiedBarcodeProducts = JSON.parse( - readFileSync(new URL("../src/data/verified-barcodes.json", import.meta.url), "utf8"), -); if (!apiKey) { throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_."); @@ -49,49 +44,16 @@ await ensureTable({ name: "Coach chats", columns: [ { kind: "string", key: "userId", size: 64, required: true }, - { kind: "string", key: "title", size: 512, required: true }, - { kind: "longtext", key: "messages", required: true }, + { kind: "string", key: "encryptedTitle", size: 4000, required: true, encrypt: true }, + { kind: "longtext", key: "encryptedMessages", required: true, encrypt: true }, + { kind: "string", key: "titleIv", size: 128, required: true }, + { kind: "string", key: "messagesIv", size: 128, required: true }, + { kind: "string", key: "salt", size: 128, required: true }, + { kind: "integer", key: "version", required: true }, { kind: "datetime", key: "updatedAt", required: true }, ], indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }], }); -await retireLegacyChatColumns(chatTableId, [ - "encryptedTitle", - "encryptedMessages", - "titleIv", - "messagesIv", - "salt", - "version", -]); -await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]); -await ensureTable({ - tableId: barcodeTableId, - name: "Barcode products", - // Schema notes: - // - scope="verified" rows are seeded by this admin script and readable by signed-in users. - // - scope="user" rows are created by the browser SDK with per-user row permissions. - columns: [ - { kind: "string", key: "scope", size: 16, required: true }, - { kind: "string", key: "ownerUserId", size: 64, required: false }, - { kind: "string", key: "barcode", size: 32, required: true }, - { kind: "string", key: "flavourName", size: 128, required: true }, - { kind: "integer", key: "sizeMl", required: true }, - { kind: "float", key: "pricePerCan", required: true }, - { kind: "boolean", key: "sugarFree", required: true }, - { kind: "float", key: "caffeineMgPerCan", required: false }, - { kind: "string", key: "verifiedBy", size: 512, required: false }, - { kind: "string", key: "sourceName", size: 512, required: false }, - { kind: "string", key: "sourceUrl", size: 2048, required: false }, - { kind: "string", key: "variant", size: 64, required: false }, - { kind: "string", key: "notes", size: 2000, required: false }, - ], - indexes: [ - { key: "barcode", type: "key", columns: ["barcode"], orders: ["ASC"], lengths: [32] }, - { key: "scope_barcode", type: "key", columns: ["scope", "barcode"], orders: ["ASC", "ASC"], lengths: [16, 32] }, - { key: "user_barcode", type: "key", columns: ["ownerUserId", "barcode"], orders: ["ASC", "ASC"], lengths: [64, 32] }, - ], -}); -await seedVerifiedBarcodeProducts(barcodeTableId, verifiedBarcodeProducts); console.log("Appwrite database and tables ready."); @@ -160,19 +122,6 @@ async function ensureColumn(tableId, column) { console.log(`Column ${tableId}.${column.key} created.`); } -async function retireLegacyChatColumns(tableId, keys) { - for (const key of keys) { - const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]); - if (existing.status === 404) { - console.log(`Legacy column ${tableId}.${key} already removed.`); - continue; - } - - await request("DELETE", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [204, 404]); - console.log(`Legacy column ${tableId}.${key} removed.`); - } -} - async function ensureIndex(tableId, index) { const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]); if (existing.status === 200) { @@ -189,43 +138,6 @@ async function ensureIndex(tableId, index) { console.log(`Index ${tableId}.${index.key} created.`); } -async function seedVerifiedBarcodeProducts(tableId, products) { - for (const [barcode, product] of Object.entries(products)) { - const rowId = `verified_${barcode}`; - const data = { - scope: "verified", - ownerUserId: "", - barcode, - flavourName: product.flavourName, - sizeMl: product.sizeMl, - pricePerCan: product.pricePerCan, - sugarFree: Boolean(product.sugarFree), - caffeineMgPerCan: product.caffeineMgPerCan, - verifiedBy: product.verifiedBy ?? "", - sourceName: product.sourceName ?? "", - sourceUrl: product.sourceUrl ?? "", - variant: product.variant ?? "", - notes: product.notes ?? "", - }; - const path = `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`; - const existing = await request("GET", path, undefined, [200, 404]); - - if (existing.status === 404) { - await request( - "POST", - `/tablesdb/${databaseId}/tables/${tableId}/rows`, - { rowId, data, permissions: ['read("users")'] }, - [201], - ); - console.log(`Verified barcode ${barcode} seeded.`); - continue; - } - - await request("PUT", path, { data, permissions: ['read("users")'] }, [200]); - console.log(`Verified barcode ${barcode} updated.`); - } -} - async function waitForColumns(tableId, keys) { const pending = new Set(keys); for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) { diff --git a/src/App.tsx b/src/App.tsx index 0afdf62..502c1e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import type { Models } from "appwrite"; import { Activity, AlertTriangle, + Brain, CalendarDays, CheckCircle2, ChevronRight, @@ -16,15 +17,21 @@ import { Home, LineChart, Loader2, + Lock, LogIn, LogOut, + MessageCircle, + MessageSquarePlus, Plus, PoundSterling, RefreshCcw, RotateCcw, + Send, Search, Settings2, ShieldCheck, + Sparkles, + Square, TimerReset, Trash2, Upload, @@ -61,6 +68,16 @@ import { YAxis, } from "recharts"; import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta, mergedFlavours } from "./data/flavours"; +import { + APP_THEMES, + THEME_CATEGORIES, + THEME_STORAGE_KEY, + getThemeById, + readStoredThemeId, + type AppTheme, + type ThemeCategory, +} from "./data/themes"; +import { themeTokensToStyle } from "./lib/themeTokens"; import { account, appwriteConfig, Channel, client, OAuthProvider, pingAppwrite } from "./lib/appwrite"; import { appwriteErrorMessage, @@ -71,6 +88,13 @@ import { listEntries, updateEntry, } from "./lib/appwriteEntries"; +import { + chatStorageErrorMessage, + createEncryptedChat, + deleteEncryptedChat, + listEncryptedChats, + updateEncryptedChat, +} from "./lib/encryptedChats"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { caffeineFor, @@ -99,15 +123,15 @@ import { wholeNumber, } from "./lib/metrics"; import { exportPayload, parseImport } from "./lib/storage"; -import type { DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; +import type { CoachChat, CoachMessage, DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; -type AppView = "overview" | "logbook" | "trends" | "data"; +type AppView = "overview" | "logbook" | "trends" | "coach" | "settings"; type AuthMode = "login" | "signup"; -type AccentTheme = "blue" | "pink"; type AuthUser = Models.User; type SetupStatus = { state: "checking" | "ok" | "error"; message: string }; - -const ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1"; +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"; const DEFAULT_FILTERS: Filters = { flavour: "all", @@ -128,16 +152,14 @@ 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: "data", label: "Data", icon: Settings2 }, -]; - -const ACCENT_OPTIONS: Array<{ id: AccentTheme; label: string }> = [ - { id: "blue", label: "Baby blue" }, - { id: "pink", label: "Pastel pink" }, + { id: "coach", label: "Coach", icon: MessageCircle }, + { id: "settings", label: "Settings", icon: Settings2 }, ]; function App() { - const [themeAccent, setThemeAccent] = useState(() => readStoredAccent()); + const [themeId, setThemeId] = useState(() => readStoredThemeId()); + const activeTheme = useMemo(() => getThemeById(themeId), [themeId]); + const shellStyle = useMemo(() => themeTokensToStyle(activeTheme.tokens), [activeTheme]); const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [authError, setAuthError] = useState(""); @@ -160,8 +182,8 @@ function App() { const jsonFileInputRef = useRef(null); useEffect(() => { - localStorage.setItem(ACCENT_STORAGE_KEY, themeAccent); - }, [themeAccent]); + localStorage.setItem(THEME_STORAGE_KEY, themeId); + }, [themeId]); const refreshEntries = useCallback(async (userId: string, showLoader = true) => { if (showLoader) setDataLoading(true); @@ -485,17 +507,17 @@ function App() { } if (authLoading) { - return ; + return ; } if (!user) { return ( +
void logout()} + onOpenSettings={() => setActiveView("settings")} />
void exportExcel()} onImportExcel={() => excelFileInputRef.current?.click()} + onOpenSettings={() => setActiveView("settings")} onRefresh={() => void refreshEntries(user.$id)} /> @@ -572,8 +597,10 @@ function App() { recentEntries={recentEntries} chartData={chartData} flavourData={flavourData} + user={user} onQuickAdd={(item) => void quickAdd(item)} onAdd={openNewEntry} + onOpenCoach={() => setActiveView("coach")} onOpenLogbook={() => setActiveView("logbook")} /> )} @@ -607,16 +634,26 @@ function App() { /> )} - {activeView === "data" && ( - } + + {activeView === "settings" && ( + void exportExcel()} onImportExcel={() => excelFileInputRef.current?.click()} onExportJson={exportJson} onImportJson={() => jsonFileInputRef.current?.click()} + onLogout={() => void logout()} onReset={() => setIsResetOpen(true)} + onThemeChange={setThemeId} /> )} @@ -666,9 +703,17 @@ function ShellBackdrop() { ); } -function LoadingScreen({ setupStatus, themeAccent }: { setupStatus: SetupStatus; themeAccent: AccentTheme }) { +function LoadingScreen({ + setupStatus, + shellStyle, + themeId, +}: { + setupStatus: SetupStatus; + shellStyle: CSSProperties; + themeId: string; +}) { return ( -
+
@@ -684,20 +729,20 @@ function LoadingScreen({ setupStatus, themeAccent }: { setupStatus: SetupStatus; } function AuthView({ - accent, authError, busy, setupStatus, - onAccentChange, + shellStyle, + themeId, onLogin, onOAuth, onSignup, }: { - accent: AccentTheme; authError: string; busy: boolean; setupStatus: SetupStatus; - onAccentChange: (accent: AccentTheme) => void; + shellStyle: CSSProperties; + themeId: string; onLogin: (email: string, password: string) => Promise; onOAuth: (provider: "github" | "google") => void; onSignup: (name: string, email: string, password: string) => Promise; @@ -717,7 +762,7 @@ function AuthView({ } return ( -
+
@@ -741,9 +786,6 @@ function AuthView({ {setupStatus.message}
)} -
- -
@@ -824,50 +866,98 @@ function AuthSignal({ icon: Icon, label, value }: { icon: LucideIcon; label: str ); } -function AccentPicker({ - accent, - onChange, +function CurrentThemeIndicator({ + theme, + onClick, }: { - accent: AccentTheme; - onChange: (accent: AccentTheme) => void; + theme: AppTheme; + onClick: () => void; }) { return ( -
- {ACCENT_OPTIONS.map((option) => ( - - ))} + + ); +} + +function ThemePicker({ + themeId, + onChange, +}: { + 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
+
+ Chart +
+
+ +
+ {visibleThemes.map((theme) => ( + + ))} +
+ +

+ Current theme: {activeTheme.label} +

); } function Sidebar({ - accent, activeView, dataLoading, notice, setupStatus, user, - onAccentChange, + onAdd, onChange, - onLogout, + onOpenSettings, }: { - accent: AccentTheme; activeView: AppView; dataLoading: boolean; notice: string; setupStatus: SetupStatus; user: AuthUser; - onAccentChange: (accent: AccentTheme) => void; + onAdd: () => void; onChange: (view: AppView) => void; - onLogout: () => void; + onOpenSettings: () => void; }) { return ( ); @@ -943,28 +1025,28 @@ function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (v } function TopBar({ - accent, + activeTheme, activeView, actionLoading, dataLoading, entries, user, - onAccentChange, onAdd, onExportExcel, onImportExcel, + onOpenSettings, onRefresh, }: { - accent: AccentTheme; + activeTheme: AppTheme; activeView: AppView; actionLoading: string | null; dataLoading: boolean; entries: RedBullEntry[]; user: AuthUser; - onAccentChange: (accent: AccentTheme) => void; onAdd: () => void; onExportExcel: () => void; onImportExcel: () => void; + onOpenSettings: () => void; onRefresh: () => void; }) { const title = NAV_ITEMS.find((item) => item.id === activeView)?.label ?? "Overview"; @@ -985,8 +1067,14 @@ function TopBar({

{title}

-
- +
+ {user.email || "Synced user"} + +
+
+ +
+
+ + +
+
+ ); +} + +function WellnessPill({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + function TodayPanel({ dashboard, entries, @@ -1160,8 +1325,7 @@ function TodayPanel({ onAdd: () => void; }) { return ( -
-
+

Today

@@ -1361,68 +1525,535 @@ function TrendsView({ ); } -function DataView({ +function CoachView({ dashboard, entries, user }: { dashboard: Dashboard; entries: RedBullEntry[]; user: AuthUser }) { + const [chats, setChats] = useState([]); + const [activeChatId, setActiveChatId] = useState(null); + const [savedChatIds, setSavedChatIds] = useState>(() => new Set()); + const [chatKey, setChatKey] = useState(""); + const [chatKeyInput, setChatKeyInput] = useState(""); + const [chatStorageStatus, setChatStorageStatus] = useState("unlock encrypted chat storage"); + const [input, setInput] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [openThinkingIds, setOpenThinkingIds] = useState([]); + const abortRef = useRef(null); + const messagesEndRef = useRef(null); + const activeChat = chats.find((chat) => chat.id === activeChatId) ?? null; + const messages = useMemo(() => activeChat?.messages ?? [], [activeChat]); + const visibleMessages = useMemo(() => messages.filter((message) => message.id !== "coach-welcome"), [messages]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" }); + }, [activeChatId, messages]); + + const quickPrompts = [ + "what does my red bull pattern say about today?", + "give me one lower-sugar swap based on my favourite flavour.", + "how should i pace caffeine for the rest of the day?", + ]; + + async function unlockChats(event: FormEvent) { + event.preventDefault(); + const passphrase = chatKeyInput.trim(); + if (!passphrase) return; + + setBusy(true); + setError(""); + setChatStorageStatus("opening encrypted appwrite chats..."); + try { + const savedChats = await listEncryptedChats(user.$id, passphrase); + const initialChats = savedChats.length ? savedChats : [buildNewCoachChat(user)]; + setChatKey(passphrase); + setChats(initialChats); + setSavedChatIds(new Set(savedChats.map((chat) => chat.id))); + setActiveChatId(initialChats[0].id); + setChatStorageStatus(savedChats.length ? `${savedChats.length} encrypted chat${savedChats.length === 1 ? "" : "s"} loaded` : "new encrypted chat ready"); + } catch (caught) { + const message = chatStorageErrorMessage(caught); + setError(message); + setChatKey(""); + setChatStorageStatus("encrypted chat unlock failed"); + } finally { + setBusy(false); + } + } + + function startNewChat() { + if (!chatKey) return; + const chat = buildNewCoachChat(user); + setChats((current) => [chat, ...current]); + setActiveChatId(chat.id); + setInput(""); + setError(""); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + await sendPrompt(input); + } + + async function sendPrompt(prompt: string) { + const trimmed = prompt.trim(); + if (!trimmed || busy) return; + if (!chatKey) { + setError("unlock encrypted chat storage first."); + return; + } + + const currentChat = activeChat ?? buildNewCoachChat(user); + const userMessage: CoachMessage = { id: makeId(), role: "user", content: trimmed }; + const assistantId = makeId(); + const assistantMessage: CoachMessage = { id: assistantId, role: "assistant", content: "", thinking: "", pending: true }; + const conversation = [...currentChat.messages, userMessage]; + const now = new Date().toISOString(); + const draftChat: CoachChat = { + ...currentChat, + title: titleForChat(currentChat.title, trimmed), + messages: [...conversation, assistantMessage], + updatedAt: now, + }; + + upsertChatState(draftChat); + setActiveChatId(draftChat.id); + setInput(""); + setBusy(true); + setError(""); + + let streamedContent = ""; + let streamedThinking = ""; + const abortController = new AbortController(); + abortRef.current = abortController; + + try { + const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [ + { role: "system", content: buildCoachSystemPrompt(user, dashboard, entries) }, + ...conversation + .filter((message) => message.content.trim().length > 0) + .map((message) => ({ + role: message.role, + content: message.content, + ...(message.thinking ? { thinking: message.thinking } : {}), + })), + ]; + + const response = await fetch(OLLAMA_PROXY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + messages: requestMessages, + stream: true, + think: true, + }), + signal: abortController.signal, + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `Ollama request failed with status ${response.status}.`); + } + if (!response.body) { + throw new Error("Streaming response was empty."); + } + + await readOllamaStream(response.body, (chunk) => { + if (chunk.error) throw new Error(chunk.error); + if (chunk.message?.thinking) streamedThinking += chunk.message.thinking; + if (chunk.message?.content) streamedContent += chunk.message.content.toLocaleLowerCase(); + + patchAssistantMessage(draftChat.id, assistantId, { + content: streamedContent, + thinking: streamedThinking, + pending: true, + }); + }); + + const finalChat = withAssistantMessage(draftChat, assistantId, { + content: streamedContent || "no answer returned.", + thinking: streamedThinking, + pending: false, + }); + upsertChatState(finalChat); + await persistChat(finalChat); + } catch (caught) { + const aborted = abortController.signal.aborted; + const message = caught instanceof Error ? caught.message : "Coach request failed."; + const finalChat = withAssistantMessage(draftChat, assistantId, { + content: aborted ? streamedContent || "stopped thinking." : `coach unavailable: ${message}`.toLocaleLowerCase(), + thinking: streamedThinking, + pending: false, + stopped: aborted, + }); + upsertChatState(finalChat); + await persistChat(finalChat); + if (!aborted) setError(message); + } finally { + abortRef.current = null; + setBusy(false); + } + } + + function stopThinking() { + abortRef.current?.abort(); + } + + function toggleThinking(id: string) { + setOpenThinkingIds((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id])); + } + + function upsertChatState(chat: CoachChat) { + setChats((current) => { + const exists = current.some((item) => item.id === chat.id); + return exists ? current.map((item) => (item.id === chat.id ? chat : item)) : [chat, ...current]; + }); + } + + function patchAssistantMessage(chatId: string, messageId: string, patch: Partial) { + setChats((current) => + current.map((chat) => + chat.id === chatId + ? { + ...chat, + updatedAt: new Date().toISOString(), + messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), + } + : chat, + ), + ); + } + + function withAssistantMessage(chat: CoachChat, messageId: string, patch: Partial): CoachChat { + return { + ...chat, + updatedAt: new Date().toISOString(), + messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), + }; + } + + async function persistChat(chat: CoachChat) { + if (!chatKey) return; + try { + const saved = savedChatIds.has(chat.id) + ? await updateEncryptedChat(user.$id, chatKey, chat) + : await createEncryptedChat(user.$id, chatKey, chat); + setSavedChatIds((current) => new Set(current).add(saved.id)); + upsertChatState(saved); + setChatStorageStatus("encrypted chat saved to appwrite"); + } catch (caught) { + setChatStorageStatus("encrypted chat save failed"); + setError(chatStorageErrorMessage(caught)); + } + } + + async function removeChat(chatId: string) { + if (busy) return; + try { + if (savedChatIds.has(chatId)) await deleteEncryptedChat(chatId); + setSavedChatIds((current) => { + const next = new Set(current); + next.delete(chatId); + return next; + }); + setChats((current) => { + const next = current.filter((chat) => chat.id !== chatId); + const fallback = buildNewCoachChat(user); + setActiveChatId(next[0]?.id ?? fallback.id); + return next.length ? next : [fallback]; + }); + setChatStorageStatus("encrypted chat deleted"); + } catch (caught) { + setError(chatStorageErrorMessage(caught)); + } + } + + if (!chatKey) { + return ( +
+
+
+
+

unlock encrypted coach chats

+

+ messages encrypt in this browser before appwrite stores them. the passphrase is never saved, so use the same one on every device. +

+
+ setChatKeyInput(event.target.value)} + placeholder="encryption passphrase" + autoComplete="current-password" + /> + +
+ {error &&

{error}

} +
+
+ ); + } + + return ( +
+ + +
+
+ {OLLAMA_MODEL} + {busy ? "thinking" : "ready"} +
+ +
+ {!visibleMessages.length ? ( +
+
+
+

ready when you are

+

ask about caffeine pace, sugar, spend, or your flavour pattern. answers stay lower case.

+
+ {quickPrompts.map((prompt) => ( + + ))} +
+
+ ) : ( + visibleMessages.map((message) => ( + toggleThinking(message.id)} + /> + )) + )} +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + setInput(event.target.value)} + placeholder="ask coach" + disabled={busy} + /> + {busy ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function CoachMessageBubble({ + message, + thinkingOpen, + onToggleThinking, +}: { + message: CoachMessage; + thinkingOpen: boolean; + onToggleThinking: () => void; +}) { + const isAssistant = message.role === "assistant"; + const canShowThinking = isAssistant && (message.pending || Boolean(message.thinking)); + const thinkingLabel = message.stopped ? "stopped thinking" : message.pending ? "thinking" : "thinking"; + + return ( +
+
+

{isAssistant ? "coach" : "you"}

+
+ {message.content || (message.pending ? "streaming response..." : "")} +
+ + {canShowThinking && ( +
+ + + {thinkingOpen && ( + + {message.thinking || "waiting for reasoning trace..."} + + )} + +
+ )} +
+
+ ); +} + +function SettingsView({ + activeTheme, dashboard, + dataLoading, entries, + notice, + setupStatus, + themeId, + user, actionLoading, onExportExcel, onImportExcel, onExportJson, onImportJson, + onLogout, onReset, + onThemeChange, }: { + activeTheme: AppTheme; dashboard: Dashboard; + dataLoading: boolean; entries: RedBullEntry[]; + notice: string; + setupStatus: SetupStatus; + themeId: string; + user: AuthUser; actionLoading: string | null; onExportExcel: () => void; onImportExcel: () => void; onExportJson: () => void; onImportJson: () => void; + onLogout: () => void; onReset: () => void; + onThemeChange: (id: string) => void; }) { return (
- -
- - - -
+
+ +
+

{user.name || "Appwrite user"}

+

{user.email}

+
+ {dataLoading ?
+

{setupStatus.message}

+ +
+
-
- - - - -
+ + + -
-

Configured Appwrite IDs

-
- - - - -
-
+ +
+ + + +
- -
+
+ + + + +
+ +
+

Configured Appwrite IDs

+
+ + + + + +
+
+ + + +
@@ -2293,6 +2924,90 @@ function formatMetricValue(name: string, value: number) { return oneDecimal.format(value); } +async function readOllamaStream(stream: ReadableStream, onChunk: (chunk: OllamaStreamChunk) => void) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + function processLine(line: string) { + const chunk = parseOllamaLine(line); + if (chunk) onChunk(chunk); + } + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + lines.forEach(processLine); + } + + buffer += decoder.decode(); + if (buffer.trim()) processLine(buffer); +} + +function parseOllamaLine(line: string): OllamaStreamChunk | null { + const trimmed = line.trim().replace(/^data:\s*/, ""); + if (!trimmed || trimmed === "[DONE]") return null; + try { + return JSON.parse(trimmed) as OllamaStreamChunk; + } catch { + return null; + } +} + +function buildCoachSystemPrompt(user: AuthUser, dashboard: Dashboard, entries: RedBullEntry[]) { + const recent = entries + .slice(0, 12) + .map( + (entry) => + `- ${humanDateTime(entry.dateTime)}: ${entry.cans} can(s), ${entry.flavour}, ${entry.sizeMl}ml, ${currency.format(spendFor(entry))}, ${wholeNumber.format(caffeineFor(entry))}mg caffeine, ${oneDecimal.format(sugarFor(entry))}g sugar`, + ) + .join("\n"); + + return [ + "You are an upbeat Red Bull intake coach inside a tracking app.", + "Respond entirely in lower case, including headings and short labels.", + "Give concise, practical suggestions based only on the logged data provided.", + "Do not give medical advice; suggest checking labels and using personal judgement for caffeine tolerance.", + `User: ${user.name || user.email || "Appwrite user"}`, + `Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`, + `Favourite flavour: ${dashboard.favouriteFlavour}. Current streak: ${dashboard.currentStreak} day(s). Total spend: ${dashboard.totalSpend}.`, + `Recent entries:\n${recent || "No entries logged yet."}`, + ].join("\n"); +} + +function buildNewCoachChat(user: AuthUser): CoachChat { + const now = new Date().toISOString(); + return { + id: makeId(), + userId: user.$id, + title: "new chat", + createdAt: now, + updatedAt: now, + messages: [ + { + id: "coach-welcome", + role: "assistant", + content: `hey ${firstName(user).toLocaleLowerCase()}, i can help with caffeine pace, sugar swaps, spend trends, and smarter quick-add choices.`, + }, + ], + }; +} + +function titleForChat(currentTitle: string, prompt: string) { + if (currentTitle !== "new chat") return currentTitle; + const cleaned = prompt.trim().replace(/\s+/g, " ").toLocaleLowerCase(); + return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "new chat"; +} + +function firstName(user: AuthUser) { + const fallback = user.email?.split("@")[0] ?? "there"; + const value = (user.name || fallback).trim(); + return value.split(/\s+/)[0] || "there"; +} + function sizeToPreset(size: number) { if (size === 250 || size === 355 || size === 473) return size.toString(); return "custom"; @@ -2305,9 +3020,4 @@ function actionLabel(value: string) { .replace(/\b\w/g, (letter) => letter.toUpperCase()); } -function readStoredAccent(): AccentTheme { - const value = localStorage.getItem(ACCENT_STORAGE_KEY); - return value === "pink" ? "pink" : "blue"; -} - export default App; diff --git a/src/data/themes.ts b/src/data/themes.ts index 4aed0d7..fda4a06 100644 --- a/src/data/themes.ts +++ b/src/data/themes.ts @@ -51,149 +51,147 @@ export const APP_THEMES: AppTheme[] = [ tertiary: "#ffd8e7", }), - theme("original", "Original", "flavour", "#282874", { - primary: "#282874", - secondary: "#efefef", - tertiary: "#d4af37", - tokens: { - chartSecondary: "#e6301f", - }, + theme("original", "Original", "flavour", "#00a7ff", { + primary: "#0077c8", + secondary: "#00a7ff", + tertiary: "#1e3264", }), - theme("zero", "Zero", "flavour", "#b1d0ee", { - primary: "#b1d0ee", - secondary: "#efefef", - tertiary: "#e6301f", + theme("zero", "Zero", "flavour", "#2a2a2a", { + primary: "#2a2a2a", + secondary: "#5c5c5c", + tertiary: "#8a8a8a", + dark: true, }), theme("summer", "Summer Edition", "flavour", "#f0e53b", { - primary: "#f2e853", - secondary: "#efefef", - tertiary: "#8a8f98", + primary: "#d4c400", + secondary: "#f0e53b", + tertiary: "#ffc247", }), - theme("cherry", "Cherry Edition", "flavour", "#d81b60", { - primary: "#d81b60", - secondary: "#efefef", - tertiary: "#b50045", + theme("cherry", "Cherry Edition", "flavour", "#e40046", { + primary: "#c3093b", + secondary: "#e40046", + tertiary: "#ff6b8a", }), theme("spring", "Spring Edition", "flavour", "#ff8fab", { primary: "#e85d8a", secondary: "#ffb3c6", tertiary: "#ffd8e7", }), - theme("apple", "Apple Edition", "flavour", "#bf1431", { - primary: "#bf1431", - secondary: "#f6c300", - tertiary: "#f3911b", + theme("apple", "Apple Edition", "flavour", "#78be20", { + primary: "#5a9a12", + secondary: "#78be20", + tertiary: "#a8d84a", }), - theme("peach", "Peach Edition", "flavour", "#e24585", { - primary: "#e24585", - secondary: "#efefef", - tertiary: "#d6417e", + theme("peach", "Peach Edition", "flavour", "#ff9b63", { + primary: "#e87a3a", + secondary: "#ff9b63", + tertiary: "#ffc9a3", }), theme("ice", "Ice Edition", "flavour", "#49adbe", { - primary: "#53b2c2", - secondary: "#efefef", - tertiary: "#49adbe", + primary: "#2d8a9a", + secondary: "#49adbe", + tertiary: "#7ce7ff", }), - theme("blue-edition", "Blue Edition", "flavour", "#0085c8", { - primary: "#0085c8", - secondary: "#efefef", - tertiary: "#ff73d1", + theme("blue-edition", "Blue Edition", "flavour", "#496dff", { + primary: "#3a52cc", + secondary: "#496dff", + tertiary: "#9c73ff", }), - theme("red-edition", "Red Edition", "flavour", "#e6301f", { - primary: "#e6301f", - secondary: "#efefef", - tertiary: "#78b941", + theme("red-edition", "Red Edition", "flavour", "#ff355e", { + primary: "#e02045", + secondary: "#ff355e", + tertiary: "#ff6b8a", }), - theme("tropical", "Tropical Edition", "flavour", "#ffcb04", { - primary: "#ffcb04", - secondary: "#efefef", - tertiary: "#f6c300", + theme("tropical", "Tropical Edition", "flavour", "#ffc247", { + primary: "#e0a820", + secondary: "#ffc247", + tertiary: "#ff9b63", }), - theme("coconut", "Coconut Edition", "flavour", "#0070b8", { - primary: "#0070b8", - secondary: "#efefef", - tertiary: "#8a8f98", + theme("coconut", "Coconut Edition", "flavour", "#7ce7ff", { + primary: "#4ec4e0", + secondary: "#7ce7ff", + tertiary: "#d8f9ff", }), - theme("green-edition", "Green Edition", "flavour", "#78b941", { - primary: "#78b941", - secondary: "#efefef", - tertiary: "#f3911b", + theme("green-edition", "Green Edition", "flavour", "#b7ff4a", { + primary: "#7acc20", + secondary: "#b7ff4a", + tertiary: "#d4ff8a", }), - theme("apricot", "Apricot Edition", "flavour", "#f3911b", { - primary: "#f3911b", - secondary: "#efefef", - tertiary: "#d6417e", + theme("apricot", "Apricot Edition", "flavour", "#ff8c42", { + primary: "#e06a20", + secondary: "#ff8c42", + tertiary: "#ffb87a", }), - theme("ruby", "Ruby Edition", "flavour", "#b50045", { - primary: "#b50045", - secondary: "#efefef", - tertiary: "#a3e635", + theme("ruby", "Ruby Edition", "flavour", "#c3093b", { + primary: "#a00730", + secondary: "#c3093b", + tertiary: "#e04060", }), - theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", { - primary: "#009edf", - secondary: "#efefef", - tertiary: "#e6301f", + theme("sugarfree", "Sugarfree", "sugarfree", "#c8d4e0", { + primary: "#8a9bb0", + secondary: "#c8d4e0", + tertiary: "#e7eef8", sugarFree: true, }), - theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", { - primary: "#f2e853", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-summer", "Summer Sugarfree", "sugarfree", "#e8e4a0", { + primary: "#c4c020", + secondary: "#e8e4a0", + tertiary: "#f0e53b", sugarFree: true, }), - theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", { - primary: "#bf1431", - secondary: "#f6c300", - tertiary: "#009edf", + theme("sf-apple", "Apple Sugarfree", "sugarfree", "#b8d4a0", { + primary: "#6a9a30", + secondary: "#b8d4a0", + tertiary: "#78be20", sugarFree: true, }), - theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", { - primary: "#e24585", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-peach", "Peach Sugarfree", "sugarfree", "#f0d0b8", { + primary: "#d08050", + secondary: "#f0d0b8", + tertiary: "#ff9b63", sugarFree: true, }), - theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", { - primary: "#53b2c2", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-ice", "Ice Sugarfree", "sugarfree", "#b8e0e8", { + primary: "#4a9aaa", + secondary: "#b8e0e8", + tertiary: "#49adbe", sugarFree: true, }), - theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", { - primary: "#7d62ce", - secondary: "#44c7b7", - tertiary: "#009edf", + theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#d8c8f0", { + primary: "#9070c0", + secondary: "#d8c8f0", + tertiary: "#b898e0", sugarFree: true, }), - theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", { - primary: "#e77bab", - secondary: "#8a1f3d", - tertiary: "#009edf", + theme("sf-pink", "Pink Sugarfree", "sugarfree", "#f0c8d8", { + primary: "#d06090", + secondary: "#f0c8d8", + tertiary: "#ffb7d9", sugarFree: true, }), - theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", { - primary: "#0085c8", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-blue", "Blue Sugarfree", "sugarfree", "#c8d0f8", { + primary: "#5060c0", + secondary: "#c8d0f8", + tertiary: "#496dff", sugarFree: true, }), - theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", { - primary: "#0070b8", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#d0f0f8", { + primary: "#60b8d0", + secondary: "#d0f0f8", + tertiary: "#7ce7ff", sugarFree: true, }), - theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", { - primary: "#78b941", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-green", "Green Sugarfree", "sugarfree", "#d8f0b8", { + primary: "#70a830", + secondary: "#d8f0b8", + tertiary: "#b7ff4a", sugarFree: true, }), - theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", { - primary: "#b50045", - secondary: "#efefef", - tertiary: "#009edf", + theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#f0c0c8", { + primary: "#a03050", + secondary: "#f0c0c8", + tertiary: "#c3093b", sugarFree: true, }), theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", { diff --git a/src/index.css b/src/index.css index 40e3692..ef7e85f 100644 --- a/src/index.css +++ b/src/index.css @@ -4,8 +4,8 @@ :root { color-scheme: light; - font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif; - background: #f5fbff; + font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; + background: #f8fbff; } * { @@ -14,16 +14,16 @@ html { min-width: 320px; - background: #f5fbff; + background: #f8fbff; } body { min-width: 320px; min-height: 100vh; margin: 0; - background: #f5fbff; - color: #193042; - font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif; + background: #f8fbff; + color: #1f252a; + font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; } @@ -62,17 +62,489 @@ textarea:focus-visible { } @layer components { + .auth-layout { + @apply mx-auto grid min-h-screen w-full max-w-6xl gap-6 px-4 py-8 lg:grid-cols-[1.05fr_0.95fr]; + align-items: center; + } + + .auth-hero { + @apply min-w-0; + } + + .auth-signal-grid { + @apply mt-6 grid gap-3 sm:grid-cols-3; + } + + .auth-panel { + @apply border p-5 shadow-fridge sm:p-6; + background: color-mix(in srgb, var(--surface-container) 88%, white); + border-color: var(--outline-variant); + border-radius: 28px; + } + + .state-chip { + @apply inline-flex min-h-10 items-center gap-2 px-3 text-sm font-semibold; + background: var(--primary-container); + border-radius: 999px; + color: var(--on-primary-container); + } + + .segmented-control { + @apply grid grid-cols-2 gap-1 border p-1; + background: var(--surface-container-high); + border-color: var(--outline-variant); + border-radius: 999px; + } + + .segmented-control button { + @apply min-h-10 px-3 text-sm font-semibold transition; + border-radius: 999px; + color: var(--muted); + } + + .segmented-control-active { + background: var(--primary-container); + color: var(--on-primary-container) !important; + } + + .app-layout { + @apply mx-auto grid w-full gap-4 px-3 pb-28 pt-3; + max-width: 1720px; + } + + .app-content { + @apply min-w-0; + } + + .app-main { + @apply mt-4; + } + + .material-drawer { + @apply sticky top-6 hidden h-[calc(100vh-3rem)] flex-col border p-4 lg:flex; + background: color-mix(in srgb, var(--surface-container-lowest) 84%, transparent); + border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent); + border-radius: 32px; + box-shadow: var(--elevation-1); + } + + .drawer-brand { + @apply mb-5 flex items-center gap-3 px-1; + } + + .drawer-primary-action { + @apply mb-5 inline-flex min-h-14 items-center justify-center gap-3 px-5 text-sm font-semibold shadow-can transition active:scale-[0.99]; + background: var(--primary-container); + border-radius: 18px; + color: var(--on-primary-container); + } + + .drawer-nav { + @apply grid gap-2; + } + + .drawer-footer { + @apply mt-auto grid gap-3; + } + + .drawer-info-card { + @apply border p-4; + background: var(--surface-container-high); + border-color: var(--outline-variant); + border-radius: 22px; + } + + .top-app-bar { + @apply border p-4 sm:p-5; + background: color-mix(in srgb, var(--surface-container-lowest) 86%, transparent); + border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent); + border-radius: 34px; + box-shadow: var(--elevation-1); + } + + .top-app-bar-main { + @apply flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between; + } + + .top-title-cluster { + @apply flex min-w-0 items-start gap-3; + } + + .top-app-icon { + @apply mt-1 flex h-12 w-12 shrink-0 items-center justify-center; + background: var(--primary-container); + border-radius: 16px; + color: var(--on-primary-container); + } + + .top-kicker { + @apply text-sm font-medium; + color: var(--primary); + } + + .top-title { + @apply mt-1 break-words text-4xl font-semibold sm:text-5xl; + color: var(--text); + } + + .top-meta-row { + @apply flex flex-wrap items-center gap-2; + } + + .account-chip { + @apply inline-flex min-h-10 max-w-full items-center rounded-md px-3 text-xs font-semibold; + background: var(--surface-container-high); + color: var(--muted); + } + + .top-action-row { + @apply mt-5 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between; + } + + .top-action-primary, + .top-action-secondary { + @apply flex flex-wrap gap-2; + } + + .mobile-nav-bar { + @apply fixed inset-x-3 bottom-3 z-40 grid grid-cols-5 gap-1 border p-1 shadow-fridge; + background: color-mix(in srgb, var(--surface-container-high) 92%, white); + border-color: var(--outline-variant); + border-radius: 28px; + } + + .mobile-nav-item { + @apply flex min-h-16 flex-col items-center justify-center gap-1 text-[11px] font-semibold transition; + border-radius: 22px; + color: var(--muted); + } + + .mobile-nav-item-active { + background: var(--primary-container); + color: var(--on-primary-container) !important; + } + + @media (min-width: 1024px) { + .app-layout { + grid-template-columns: 300px minmax(0, 1fr); + gap: 24px; + padding: 24px; + } + + .app-main { + margin-top: 24px; + } + } + .glass-panel { - @apply rounded-lg border shadow-fridge backdrop-blur-2xl; - background: color-mix(in srgb, var(--panel) 86%, white); - border-color: var(--border); + @apply rounded-lg border shadow-fridge; + background: color-mix(in srgb, var(--surface-container-lowest) 88%, transparent); + border-color: color-mix(in srgb, var(--outline-variant) 62%, transparent); + border-radius: 34px; } .can-panel { - @apply rounded-lg border shadow-cyan backdrop-blur-2xl; + @apply rounded-lg border shadow-can; + background: linear-gradient(135deg, var(--primary-container), var(--surface-container-high) 58%, var(--tertiary-container)); + border-color: color-mix(in srgb, var(--primary) 20%, var(--outline-variant)); + border-radius: 36px; + } + + .today-panel { background: - linear-gradient(135deg, color-mix(in srgb, var(--accent) 42%, white), rgba(255, 255, 255, 0.96) 52%, color-mix(in srgb, var(--accent-soft) 76%, white)); - border-color: var(--border); + radial-gradient(circle at 18% 18%, color-mix(in srgb, var(--primary-container) 82%, white) 0 22%, transparent 44%), + linear-gradient(145deg, var(--surface-container-lowest), var(--surface-container) 54%, var(--tertiary-container)); + } + + .oura-hero { + background: + radial-gradient(circle at 14% 28%, color-mix(in srgb, var(--primary-container) 72%, white) 0 18%, transparent 42%), + radial-gradient(circle at 82% 8%, color-mix(in srgb, var(--secondary-container) 70%, white) 0 18%, transparent 38%), + var(--surface-container-lowest); + } + + .oura-ring { + @apply grid h-32 w-32 shrink-0 place-items-center p-2 shadow-can; + background: conic-gradient(var(--primary) var(--progress), var(--surface-container-high) 0); + border-radius: 999px; + } + + .oura-ring > div { + @apply flex h-full w-full flex-col items-center justify-center; + background: var(--surface-container-lowest); + border-radius: inherit; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--outline-variant) 72%, transparent); + } + + .oura-ring span { + @apply text-4xl font-semibold leading-none; + color: var(--text); + } + + .oura-ring small { + @apply mt-1 text-xs font-semibold uppercase; + color: var(--muted); + } + + .wellness-pill { + @apply flex items-center justify-between gap-3 rounded-full border px-4 py-3 text-sm; + background: color-mix(in srgb, var(--surface-container-high) 78%, white); + border-color: var(--outline-variant); + } + + .wellness-pill span { + color: var(--muted); + } + + .wellness-pill strong { + color: var(--text); + } + + .suggestion-chip { + @apply min-h-11 rounded-full border px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed; + background: color-mix(in srgb, var(--surface-container-high) 72%, white); + border-color: var(--outline-variant); + color: var(--text); + } + + .suggestion-chip:hover:not(:disabled) { + background: var(--primary-container); + color: var(--on-primary-container); + box-shadow: var(--elevation-1); + } + + .coach-gemini-shell { + @apply grid min-h-[760px] gap-4; + } + + .coach-locked-shell { + @apply place-items-center; + grid-template-columns: 1fr !important; + } + + .coach-chat-sidebar { + @apply hidden min-h-[760px] flex-col border p-3 xl:flex; + background: color-mix(in srgb, var(--surface-container-lowest) 80%, transparent); + border-color: color-mix(in srgb, var(--outline-variant) 64%, transparent); + border-radius: 34px; + box-shadow: var(--elevation-1); + } + + .coach-sidebar-brand { + @apply flex items-center gap-3 px-2 py-2; + } + + .coach-brand-orb { + @apply grid h-16 w-16 place-items-center rounded-full shadow-sm; + background: radial-gradient(circle at 30% 26%, #ffffff 0 12%, #d7ecff 13% 48%, #7fb6df 72%, #5d8fb3 100%); + color: #163247; + } + + .coach-brand-orb-small { + @apply h-10 w-10; + } + + .coach-new-chat { + @apply mt-4 inline-flex min-h-12 items-center gap-3 rounded-full px-4 text-sm font-semibold transition disabled:cursor-not-allowed; + background: var(--surface-container-high); + color: var(--text); + } + + .coach-new-chat:hover:not(:disabled) { + background: var(--primary-container); + } + + .coach-chat-list { + @apply mt-4 grid flex-1 content-start gap-1 overflow-y-auto; + } + + .coach-chat-row { + @apply grid grid-cols-[1fr_auto] items-center rounded-3xl transition; + color: var(--text); + } + + .coach-chat-row > button:first-child { + @apply grid min-w-0 gap-1 px-4 py-3 text-left; + } + + .coach-chat-row > button:last-child { + @apply mr-2 grid h-8 w-8 place-items-center rounded-full opacity-0 transition; + color: var(--muted); + } + + .coach-chat-row:hover > button:last-child, + .coach-chat-row-active > button:last-child { + opacity: 1; + } + + .coach-chat-row span { + @apply truncate text-sm font-medium; + } + + .coach-chat-row small { + @apply text-xs; + color: var(--muted); + } + + .coach-chat-row-active, + .coach-chat-row:hover { + background: var(--surface-container-high); + } + + .coach-context-card { + @apply mt-4 rounded-[28px] border p-4; + background: color-mix(in srgb, var(--surface-container-low) 76%, white); + border-color: var(--outline-variant); + } + + .coach-stage { + @apply relative flex min-h-[760px] flex-col overflow-hidden border; + background: + radial-gradient(circle at 50% 64%, rgba(192, 225, 250, 0.78) 0 18%, transparent 42%), + linear-gradient(180deg, rgba(255,255,255,0.94), rgba(248,251,255,0.96)); + border-color: color-mix(in srgb, var(--outline-variant) 64%, transparent); + border-radius: 38px; + box-shadow: var(--elevation-1); + } + + .coach-stage-topbar { + @apply flex items-center justify-between px-5 py-4 text-xs font-semibold; + color: var(--muted); + } + + .coach-stage-messages { + @apply flex-1 space-y-5 overflow-y-auto px-4 pb-36 pt-8 sm:px-8 lg:px-16; + } + + .coach-empty-state { + @apply mx-auto flex min-h-[520px] max-w-3xl flex-col items-center justify-center text-center; + } + + .coach-empty-state h2 { + @apply mt-6 text-5xl font-normal tracking-tight sm:text-6xl; + color: var(--text); + } + + .coach-empty-state p { + @apply mt-4 max-w-xl text-base leading-7; + color: var(--muted); + } + + .coach-prompt-grid { + @apply mt-7 grid gap-2 sm:grid-cols-3; + } + + .coach-message { + @apply flex; + } + + .coach-message-user { + @apply justify-end; + } + + .coach-message-assistant { + @apply justify-start; + } + + .coach-message-bubble { + @apply max-w-[840px] rounded-[34px] border px-5 py-4 shadow-sm; + background: rgba(255, 255, 255, 0.82); + border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent); + backdrop-filter: blur(18px); + } + + .coach-message-user .coach-message-bubble { + background: #ececec; + border-color: transparent; + } + + .coach-message-user .coach-message-bubble, + .coach-message-user .coach-message-bubble * { + color: var(--text) !important; + } + + .thinking-slider { + @apply w-full overflow-hidden rounded-full border px-3 py-2 text-sm font-medium; + background: rgba(255, 255, 255, 0.72); + border-color: transparent; + color: var(--muted); + } + + .thinking-slider-active { + border-color: color-mix(in srgb, var(--primary) 42%, var(--outline-variant)); + } + + .thinking-slider-track { + @apply block overflow-hidden whitespace-nowrap; + mask-image: linear-gradient(90deg, transparent, black 18%, black 82%, transparent); + } + + .thinking-slider-track span { + @apply inline-block; + padding-left: 100%; + animation: thinking-slide 3.2s linear infinite; + } + + .thinking-trace { + @apply mt-2 max-h-56 overflow-auto rounded-3xl border p-4 text-xs leading-5 whitespace-pre-wrap; + background: rgba(255, 255, 255, 0.72); + border-color: color-mix(in srgb, var(--outline-variant) 58%, transparent); + color: var(--muted); + } + + .coach-composer { + @apply absolute inset-x-4 bottom-4 z-10 mx-auto flex max-w-4xl items-center gap-3 rounded-full border p-3 sm:bottom-7; + background: rgba(255, 255, 255, 0.94); + border-color: color-mix(in srgb, var(--outline-variant) 68%, transparent); + box-shadow: 0 18px 50px rgba(74, 102, 122, 0.18); + backdrop-filter: blur(18px); + } + + .coach-input { + @apply min-h-12 flex-1 rounded-full border-0 px-2 text-lg shadow-none transition; + background: transparent; + color: var(--text); + } + + .coach-input:focus { + box-shadow: none; + } + + .composer-icon-button, + .composer-send-button { + @apply grid h-12 w-12 shrink-0 place-items-center rounded-full transition disabled:cursor-not-allowed disabled:opacity-45; + color: var(--text); + } + + .composer-icon-button:hover { + background: var(--surface-container-high); + } + + .composer-send-button { + background: #97cbf5; + color: #10283a; + } + + .composer-send-button:hover:not(:disabled) { + filter: brightness(0.98); + } + + .composer-stop-button { + background: var(--surface-container-high); + color: var(--text); + } + + .coach-unlock-card { + @apply mt-8 flex w-full max-w-xl flex-col gap-3 rounded-full border p-3 sm:flex-row; + background: rgba(255, 255, 255, 0.94); + border-color: color-mix(in srgb, var(--outline-variant) 68%, transparent); + box-shadow: var(--elevation-2); + } + + @media (min-width: 1280px) { + .coach-gemini-shell { + grid-template-columns: 340px minmax(0, 1fr); + } } .can-emblem { @@ -163,75 +635,147 @@ textarea:focus-visible { @apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm backdrop-blur-xl; } - .accent-picker { - @apply inline-flex min-h-11 items-center gap-1 rounded-md border bg-white/80 p-1 shadow-sm; - border-color: var(--border); + .theme-indicator { + @apply inline-flex min-h-11 items-center gap-2 rounded-full border px-3 text-sm font-semibold transition; + background: var(--surface-container-high); + border-color: var(--outline-variant); + color: var(--text); } - .accent-picker button { - @apply inline-flex min-h-9 items-center gap-2 rounded px-3 text-sm font-semibold transition; + .theme-indicator:hover { + background: var(--primary-container); + color: var(--on-primary-container); + } + + .theme-indicator-swatch { + @apply h-4 w-4 rounded-full border border-white shadow-sm; + } + + .theme-indicator-label { + @apply max-w-[9rem] truncate; + } + + .settings-section { + @apply grid gap-4; + } + + .settings-tabs { + @apply inline-flex flex-wrap gap-1 rounded-full border p-1; + background: var(--surface-container-high); + border-color: var(--outline-variant); + } + + .settings-tabs button { + @apply rounded-full px-4 py-2 text-sm font-semibold transition; color: var(--muted); } - .accent-picker button:hover, - .accent-picker-active { - background: var(--accent-soft); - color: var(--text) !important; + .settings-tabs button:hover, + .settings-tab-active { + background: var(--primary-container); + color: var(--on-primary-container) !important; } - .accent-swatch { - @apply h-3 w-3 rounded-full border border-white shadow-sm; + .theme-preview-strip { + @apply flex flex-wrap gap-2; } - .accent-swatch-blue { - background: #bdeeff; + .theme-preview-chip { + color: var(--text); } - .accent-swatch-pink { - background: #ffd6e8; + .theme-picker-grid { + @apply grid gap-3 sm:grid-cols-2 lg:grid-cols-3; + } + + .theme-tile { + @apply flex min-h-[4.5rem] items-center gap-3 rounded-xl border px-3 py-3 text-left transition; + background: var(--surface-container); + border-color: var(--outline-variant); + color: var(--text); + } + + .theme-tile:hover { + box-shadow: var(--elevation-1); + border-color: var(--outline); + } + + .theme-tile-active { + border-color: var(--primary); + box-shadow: var(--elevation-1); + background: var(--primary-container); + color: var(--on-primary-container); + } + + .theme-tile-swatch { + @apply h-10 w-10 shrink-0 rounded-full border border-white shadow-sm; + } + + .theme-tile-label { + @apply text-sm font-semibold leading-5; } } .app-shell { - --accent: #bdeeff; - --accent-soft: #e7f8ff; - --accent-strong: #4aa8d6; - --accent-warm: #ffe2ef; - --bg: #f5fbff; - --panel: #f8fcff; - --panel-strong: #ffffff; - --border: rgba(104, 164, 198, 0.24); - --text: #193042; - --muted: #607587; - --subtle: #7e93a3; + --primary: #4b86ad; + --on-primary: #ffffff; + --primary-container: #dff2ff; + --on-primary-container: #10283a; + --secondary: #647887; + --on-secondary: #ffffff; + --secondary-container: #ecf3f7; + --on-secondary-container: #1f2d35; + --tertiary: #9b7b51; + --on-tertiary: #ffffff; + --tertiary-container: #f4eadb; + --on-tertiary-container: #332313; + --error: #ba1a1a; + --on-error: #ffffff; + --error-container: #ffdad6; + --on-error-container: #410002; + --bg: #f8fbff; + --surface: #f8fbff; + --surface-container-lowest: #ffffff; + --surface-container-low: #f1f7fb; + --surface-container: #edf3f7; + --surface-container-high: #e7eef3; + --panel: var(--surface-container); + --panel-strong: var(--surface-container-lowest); + --outline: #7c8992; + --outline-variant: #dce5ea; + --text: #1f252a; + --muted: #68747c; + --subtle: #839099; + --accent: var(--primary-container); + --accent-soft: var(--surface-container-low); + --accent-strong: var(--primary); + --accent-warm: #eef4f7; + --chart-primary: #4b86ad; + --chart-secondary: #6f8f7c; + --chart-tertiary: #9b7b51; + --chart-error: #ba1a1a; + --chart-grid: rgba(124, 137, 146, 0.18); + --elevation-1: 0 12px 34px rgba(69, 91, 108, 0.08), 0 1px 2px rgba(69, 91, 108, 0.06); + --elevation-2: 0 18px 44px rgba(69, 91, 108, 0.12), 0 2px 8px rgba(69, 91, 108, 0.08); min-height: 100vh; background: var(--bg) !important; color: var(--text) !important; -} - -.app-shell[data-accent="pink"] { - --accent: #ffd6e8; - --accent-soft: #fff0f7; - --accent-strong: #d46c9d; - --accent-warm: #dff6ff; - --bg: #fff8fc; - --panel: #fffbfd; - --border: rgba(210, 108, 157, 0.22); + font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif; } .backdrop-wash { background: - radial-gradient(circle at 14% 12%, color-mix(in srgb, var(--accent) 48%, transparent), transparent 32%), - radial-gradient(circle at 82% 6%, color-mix(in srgb, var(--accent-warm) 72%, transparent), transparent 34%), - linear-gradient(180deg, var(--bg) 0%, #ffffff 46%, color-mix(in srgb, var(--accent-soft) 66%, white) 100%); + radial-gradient(circle at 70% 35%, var(--primary-container) 0 18%, transparent 42%), + radial-gradient(circle at 12% 12%, var(--surface-container-lowest) 0 18%, transparent 38%), + linear-gradient(180deg, var(--bg) 0%, var(--surface-container-lowest) 46%, var(--surface-container-low) 100%); } .backdrop-grid { background-image: - linear-gradient(color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px), - linear-gradient(90deg, color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px); - background-size: 42px 42px; - opacity: 0.5; + linear-gradient(var(--chart-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--chart-grid) 1px, transparent 1px); + background-size: 48px 48px; + opacity: 0; } .backdrop-rail { @@ -336,3 +880,9 @@ textarea:focus-visible { .app-shell .modal-panel { color: var(--text); } + +@keyframes thinking-slide { + to { + transform: translateX(-100%); + } +} diff --git a/src/lib/appwrite.ts b/src/lib/appwrite.ts index ef7ed74..30cd0c0 100644 --- a/src/lib/appwrite.ts +++ b/src/lib/appwrite.ts @@ -8,6 +8,7 @@ export const appwriteConfig = { projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138", databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker", collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries", + chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats", oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL), oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL), }; diff --git a/src/lib/encryptedChats.ts b/src/lib/encryptedChats.ts new file mode 100644 index 0000000..a12f5ad --- /dev/null +++ b/src/lib/encryptedChats.ts @@ -0,0 +1,178 @@ +import type { Models } from "appwrite"; +import type { CoachChat } from "../types"; +import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite"; + +const CHAT_CRYPTO_VERSION = 1; +const KEY_ITERATIONS = 210_000; + +type EncryptedChatRow = Models.Row & { + userId: string; + encryptedTitle: string; + encryptedMessages: string; + titleIv: string; + messagesIv: string; + salt: string; + version: number; + updatedAt: string; +}; + +type EncryptedValue = { + ciphertext: string; + iv: string; +}; + +export async function listEncryptedChats(userId: string, passphrase: string) { + const response = await tablesDB.listRows({ + databaseId: appwriteConfig.databaseId, + tableId: appwriteConfig.chatCollectionId, + queries: [Query.equal("userId", userId), Query.orderDesc("updatedAt"), Query.limit(50)], + }); + + const chats: CoachChat[] = []; + for (const row of response.rows) { + chats.push(await decryptChatRow(row, passphrase)); + } + + return chats; +} + +export async function createEncryptedChat(userId: string, passphrase: string, chat: CoachChat) { + const row = await tablesDB.createRow({ + databaseId: appwriteConfig.databaseId, + tableId: appwriteConfig.chatCollectionId, + rowId: ID.custom(chat.id), + data: await toEncryptedRowData(userId, passphrase, chat), + permissions: userRowPermissions(userId), + }); + + return decryptChatRow(row, passphrase); +} + +export async function updateEncryptedChat(userId: string, passphrase: string, chat: CoachChat) { + const row = await tablesDB.updateRow({ + databaseId: appwriteConfig.databaseId, + tableId: appwriteConfig.chatCollectionId, + rowId: chat.id, + data: await toEncryptedRowData(userId, passphrase, chat), + permissions: userRowPermissions(userId), + }); + + return decryptChatRow(row, passphrase); +} + +export async function deleteEncryptedChat(id: string) { + await tablesDB.deleteRow({ + databaseId: appwriteConfig.databaseId, + tableId: appwriteConfig.chatCollectionId, + rowId: id, + }); +} + +export function chatStorageErrorMessage(error: unknown) { + if (error instanceof Error) { + if (/decrypt|operation failed|unable to decrypt/i.test(error.message)) { + return "Encrypted chat key could not unlock saved chats."; + } + if (/not found|404/i.test(error.message)) { + return `Appwrite chat table '${appwriteConfig.chatCollectionId}' was not found.`; + } + if (/permissions?.*create|action 'create'|not authorized|401|unauthorized/i.test(error.message)) { + return `Appwrite chat table needs Users -> Create and row security on '${appwriteConfig.chatCollectionId}'.`; + } + return error.message; + } + return "Encrypted chat storage failed."; +} + +async function toEncryptedRowData(userId: string, passphrase: string, chat: CoachChat) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const key = await deriveKey(passphrase, userId, salt); + const title = await encryptText(chat.title, key); + const messages = await encryptText(JSON.stringify(chat.messages), key); + + return { + userId, + encryptedTitle: title.ciphertext, + encryptedMessages: messages.ciphertext, + titleIv: title.iv, + messagesIv: messages.iv, + salt: bytesToBase64(salt), + version: CHAT_CRYPTO_VERSION, + updatedAt: chat.updatedAt, + }; +} + +async function decryptChatRow(row: EncryptedChatRow, passphrase: string): Promise { + const salt = base64ToBytes(row.salt); + const key = await deriveKey(passphrase, row.userId, salt); + const title = await decryptText({ ciphertext: row.encryptedTitle, iv: row.titleIv }, key); + const messages = JSON.parse(await decryptText({ ciphertext: row.encryptedMessages, iv: row.messagesIv }, key)); + + return { + id: row.$id, + userId: row.userId, + title, + messages, + createdAt: row.$createdAt, + updatedAt: row.updatedAt || row.$updatedAt, + }; +} + +async function deriveKey(passphrase: string, userId: string, salt: Uint8Array) { + const material = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(`${userId}:${passphrase}`), + "PBKDF2", + false, + ["deriveKey"], + ); + + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt: bytesToArrayBuffer(salt), iterations: KEY_ITERATIONS, hash: "SHA-256" }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +function bytesToArrayBuffer(bytes: Uint8Array) { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +async function encryptText(value: string, key: CryptoKey): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(value)); + return { ciphertext: bytesToBase64(new Uint8Array(encrypted)), iv: bytesToBase64(iv) }; +} + +async function decryptText(value: EncryptedValue, key: CryptoKey) { + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(value.iv) }, + key, + base64ToBytes(value.ciphertext), + ); + return new TextDecoder().decode(decrypted); +} + +function bytesToBase64(bytes: Uint8Array) { + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +} + +function base64ToBytes(value: string) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function userRowPermissions(userId: string) { + const role = Role.user(userId); + return [Permission.read(role), Permission.update(role), Permission.delete(role)]; +} diff --git a/src/types.ts b/src/types.ts index 4bc3f58..a52d322 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,3 +54,23 @@ export type ImportPreview = { fileName: string; rows: ImportPreviewRow[]; }; + +export type ChatRole = "user" | "assistant"; + +export type CoachMessage = { + id: string; + role: ChatRole; + content: string; + thinking?: string; + pending?: boolean; + stopped?: boolean; +}; + +export type CoachChat = { + id: string; + userId: string; + title: string; + messages: CoachMessage[]; + createdAt: string; + updatedAt: string; +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 419d521..b6746b5 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,8 +5,10 @@ interface ImportMetaEnv { readonly VITE_APPWRITE_PROJECT_ID?: string; readonly VITE_APPWRITE_DATABASE_ID?: string; readonly VITE_APPWRITE_COLLECTION_ID?: string; + readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string; readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string; readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string; + readonly VITE_OLLAMA_PROXY_URL?: string; } interface ImportMeta { diff --git a/vite.config.ts b/vite.config.ts index 83a6d9d..e45d38c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,18 +1,44 @@ -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { defineConfig, loadEnv } from "vite"; -export default defineConfig({ - plugins: [react()], - build: { - chunkSizeWarningLimit: 700, - rollupOptions: { - output: { - manualChunks: { - charts: ["recharts"], - motion: ["framer-motion"], - icons: ["lucide-react"], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const ollamaProxy = { + target: "https://ollama.com", + changeOrigin: true, + rewrite: () => "/api/chat", + configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) { + proxy.on("proxyReq", (proxyReq) => { + if (env.OLLAMA_API_KEY) { + proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`); + } + }); + }, + }; + + return { + plugins: [react()], + server: { + proxy: { + "/api/ollama-chat": ollamaProxy, + }, + }, + preview: { + proxy: { + "/api/ollama-chat": ollamaProxy, + }, + }, + build: { + chunkSizeWarningLimit: 700, + rollupOptions: { + output: { + manualChunks: { + charts: ["recharts"], + motion: ["framer-motion"], + icons: ["lucide-react"], + }, }, }, }, - }, + }; }); From 4c7d719e026d4e4defa97c3a4975ccc4396b6670 Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Wed, 27 May 2026 17:30:35 +0100 Subject: [PATCH 03/11] rm coach chat and ollama proxy drop coach panel, session, chat store, and the limitsSummaryForCoach helper --- api/ollama-chat.js | 77 ------- src/components/CoachPanel.tsx | 195 ---------------- src/lib/coachChats.ts | 107 --------- src/lib/useCoachSession.ts | 417 ---------------------------------- src/lib/userLimits.ts | 19 -- 5 files changed, 815 deletions(-) delete mode 100644 api/ollama-chat.js delete mode 100644 src/components/CoachPanel.tsx delete mode 100644 src/lib/coachChats.ts delete mode 100644 src/lib/useCoachSession.ts 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/src/components/CoachPanel.tsx b/src/components/CoachPanel.tsx deleted file mode 100644 index 314ab09..0000000 --- a/src/components/CoachPanel.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { Brain, ChevronRight, Loader2, Plus, Send, Sparkles, Square, Trash2 } from "lucide-react"; -import type { FormEvent } from "react"; -import { getBstHour } from "../lib/greeting"; -import type { CoachSession } from "../lib/useCoachSession"; -import { OLLAMA_MODEL } from "../lib/useCoachSession"; -import type { CoachMessage } from "../types"; - -type CoachPanelProps = { - session: CoachSession; - mode: "compact" | "full"; - dashboard: { - todayCans: string; - todayCaffeine: string; - favouriteFlavour: string; - }; - userInitials: string; - onExpand?: () => void; -}; - -const QUICK_PROMPTS = [ - "what's my favourite flavour historically?", - "how should i pace caffeine for the rest of the day?", - "suggest a lower-sugar swap", -]; - -export function CoachPanel({ session, mode, dashboard, userInitials, onExpand }: CoachPanelProps) { - const { - busy, - chats, - error, - input, - activeChatId, - removeChat, - sendPrompt, - setActiveChatId, - setInput, - startNewChat, - stopThinking, - storageReady, - storageStatus, - visibleMessages, - } = session; - - const displayMessages = mode === "compact" ? visibleMessages.slice(-4) : visibleMessages; - const compact = mode === "compact"; - - async function submit(event: FormEvent) { - event.preventDefault(); - await sendPrompt(input); - } - - if (!storageReady) { - return ( -
-
-
-
- ); - } - - return ( -
-
-
-
-
-
-

coach

-

- {dashboard.todayCans} cans today · {dashboard.favouriteFlavour} -

-
-
-
- - - {busy ? "thinking" : storageStatus} - - {!compact && {OLLAMA_MODEL}} - {compact && onExpand && ( - - )} -
-
- - {!compact && chats.length > 1 && ( -
- {chats.map((chat) => ( -
- - -
- ))} - -
- )} - -
- {dashboard.todayCaffeine} caffeine - bst {getBstHour()}:00 -
- -
- {!displayMessages.length ? ( -
-
- ) : ( - displayMessages.map((message) => ( - - )) - )} -
- - {error &&

{error}

} - -
- {!compact && ( - - )} - setInput(event.target.value)} - placeholder="ask coach anything..." - disabled={busy} - /> - {busy ? ( - - ) : ( - - )} -
-
- ); -} - -function CoachLine({ message, userInitials }: { message: CoachMessage; userInitials: string }) { - const isAssistant = message.role === "assistant"; - const isThinking = isAssistant && message.pending && !message.content.trim(); - - return ( -
- {isAssistant ? : userInitials} -
- {isThinking && } - {message.content ?

{message.content}

: !isThinking ? ... : null} - {isAssistant && !message.pending && message.thinking?.trim() ? ( -
- reasoning -
{message.thinking}
-
- ) : null} -
-
- ); -} - -function ThinkingPill({ stopped }: { stopped?: boolean }) { - return ( -
-
-
-
- ); -} diff --git a/src/lib/coachChats.ts b/src/lib/coachChats.ts deleted file mode 100644 index 3896ba6..0000000 --- a/src/lib/coachChats.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Models } from "appwrite"; -import type { CoachChat, CoachMessage } from "../types"; -import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite"; - -type CoachChatRow = Models.Row & { - userId: string; - title: string; - messages: string; - updatedAt: string; -}; - -export async function listCoachChats(userId: string) { - const response = await tablesDB.listRows({ - databaseId: appwriteConfig.databaseId, - tableId: appwriteConfig.chatCollectionId, - queries: [Query.equal("userId", userId), Query.orderDesc("updatedAt"), Query.limit(50)], - }); - - return response.rows.filter(isPlainChatRow).map(fromRow); -} - -export async function createCoachChat(userId: string, chat: CoachChat) { - const row = await tablesDB.createRow({ - databaseId: appwriteConfig.databaseId, - tableId: appwriteConfig.chatCollectionId, - rowId: ID.custom(chat.id), - data: toRowData(userId, chat), - permissions: userRowPermissions(userId), - }); - - return fromRow(row); -} - -export async function updateCoachChat(userId: string, chat: CoachChat) { - const row = await tablesDB.updateRow({ - databaseId: appwriteConfig.databaseId, - tableId: appwriteConfig.chatCollectionId, - rowId: chat.id, - data: toRowData(userId, chat), - permissions: userRowPermissions(userId), - }); - - return fromRow(row); -} - -export async function deleteCoachChat(id: string) { - await tablesDB.deleteRow({ - databaseId: appwriteConfig.databaseId, - tableId: appwriteConfig.chatCollectionId, - rowId: id, - }); -} - -export function chatStorageErrorMessage(error: unknown) { - if (error instanceof Error) { - if (/not found|404/i.test(error.message)) { - return `Appwrite chat table '${appwriteConfig.chatCollectionId}' was not found. Run npm run setup:appwrite.`; - } - if (/permissions?.*create|action 'create'|not authorized|401|unauthorized/i.test(error.message)) { - return `Appwrite chat table needs Users -> Create and row security on '${appwriteConfig.chatCollectionId}'.`; - } - if (/unknown attribute|invalid document structure|missing required attribute/i.test(error.message)) { - if (/encrypted/i.test(error.message)) { - return "Coach chat table still requires legacy encrypted columns. Run npm run setup:appwrite or remove encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, and version as required in Appwrite Console."; - } - return "Coach chat schema needs title and messages columns. Run npm run setup:appwrite."; - } - return error.message; - } - return "Coach chat storage failed."; -} - -function toRowData(userId: string, chat: CoachChat) { - return { - userId, - title: chat.title.slice(0, 512) || "today", - messages: JSON.stringify(chat.messages), - updatedAt: chat.updatedAt, - }; -} - -function isPlainChatRow(row: CoachChatRow) { - return typeof row.title === "string" && row.title.length > 0 && typeof row.messages === "string" && row.messages.length > 0; -} - -function fromRow(row: CoachChatRow): CoachChat { - let messages: CoachMessage[] = []; - try { - messages = JSON.parse(row.messages) as CoachMessage[]; - } catch { - messages = []; - } - - return { - id: row.$id, - userId: row.userId, - title: row.title, - messages, - createdAt: row.$createdAt, - updatedAt: row.updatedAt || row.$updatedAt, - }; -} - -function userRowPermissions(userId: string) { - const role = Role.user(userId); - return [Permission.read(role), Permission.update(role), Permission.delete(role)]; -} diff --git a/src/lib/useCoachSession.ts b/src/lib/useCoachSession.ts deleted file mode 100644 index eb87584..0000000 --- a/src/lib/useCoachSession.ts +++ /dev/null @@ -1,417 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Models } from "appwrite"; -import { - chatStorageErrorMessage, - createCoachChat, - deleteCoachChat, - listCoachChats, - updateCoachChat, -} from "./coachChats"; -import { buildFlavourHistorySummary, getBstHour } from "./greeting"; -import { - caffeineFor, - currency, - humanDateTime, - makeId, - oneDecimal, - spendFor, - sugarFor, - wholeNumber, -} from "./metrics"; -import type { CoachChat, CoachMessage, LimitCheckResult, RedBullEntry, UserLimits } from "../types"; -import { limitsSummaryForCoach } from "./userLimits"; - -type AuthUser = Models.User; - -type Dashboard = { - todayCans: string; - todayCaffeine: string; - todaySugar: string; - favouriteFlavour: string; - currentStreak: string; - totalSpend: string; -}; - -const OLLAMA_MODEL = "deepseek-v4-pro:cloud"; -const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat"; - -type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } }; - -export type CoachSession = ReturnType; - -export function useCoachSession( - user: AuthUser, - dashboard: Dashboard, - entries: RedBullEntry[], - userLimits: UserLimits = {}, - limitCheck?: LimitCheckResult, -) { - const [chats, setChats] = useState([]); - const [activeChatId, setActiveChatId] = useState(null); - const [savedChatIds, setSavedChatIds] = useState>(() => new Set()); - const [storageStatus, setStorageStatus] = useState("loading"); - const [storageReady, setStorageReady] = useState(false); - const [input, setInput] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const abortRef = useRef(null); - const queuedPromptRef = useRef(null); - - const activeChat = useMemo(() => chats.find((chat) => chat.id === activeChatId) ?? null, [chats, activeChatId]); - const messages = useMemo(() => activeChat?.messages ?? [], [activeChat]); - const visibleMessages = useMemo(() => messages.filter((message) => message.id !== "coach-welcome"), [messages]); - - useEffect(() => { - let cancelled = false; - - async function loadChats() { - if (!user.$id) return; - setStorageStatus("loading"); - setError(""); - try { - const savedChats = await listCoachChats(user.$id); - if (cancelled) return; - const initialChats = savedChats.length ? savedChats : [buildNewCoachChat(user, dashboard)]; - setChats(initialChats); - setSavedChatIds(new Set(savedChats.map((chat) => chat.id))); - setActiveChatId(initialChats[0].id); - setStorageStatus(savedChats.length ? `${savedChats.length} synced` : "ready"); - setStorageReady(true); - } catch (caught) { - if (cancelled) return; - setError(chatStorageErrorMessage(caught)); - const fallback = buildNewCoachChat(user, dashboard); - setChats([fallback]); - setActiveChatId(fallback.id); - setStorageStatus("local only"); - setStorageReady(true); - } - } - - void loadChats(); - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user.$id]); - - const upsertChatState = useCallback((chat: CoachChat) => { - setChats((current) => { - const exists = current.some((item) => item.id === chat.id); - return exists ? current.map((item) => (item.id === chat.id ? chat : item)) : [chat, ...current]; - }); - }, []); - - const patchAssistantMessage = useCallback((chatId: string, messageId: string, patch: Partial) => { - setChats((current) => - current.map((chat) => - chat.id === chatId - ? { - ...chat, - updatedAt: new Date().toISOString(), - messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), - } - : chat, - ), - ); - }, []); - - const withAssistantMessage = useCallback((chat: CoachChat, messageId: string, patch: Partial): CoachChat => { - return { - ...chat, - updatedAt: new Date().toISOString(), - messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), - }; - }, []); - - const persistChat = useCallback( - async (chat: CoachChat) => { - try { - const saved = savedChatIds.has(chat.id) - ? await updateCoachChat(user.$id, chat) - : await createCoachChat(user.$id, chat); - setSavedChatIds((current) => new Set(current).add(saved.id)); - upsertChatState(saved); - setStorageStatus("synced"); - return true; - } catch (caught) { - setStorageStatus("save pending"); - setError(chatStorageErrorMessage(caught)); - return false; - } - }, - [savedChatIds, upsertChatState, user.$id], - ); - - const sendPrompt = useCallback( - async (prompt: string, chatOverride?: CoachChat | null) => { - const trimmed = prompt.trim(); - if (!trimmed || busy || !storageReady || !user.$id) return false; - - const currentChat = chatOverride ?? activeChat ?? buildNewCoachChat(user, dashboard); - const userMessage: CoachMessage = { id: makeId(), role: "user", content: trimmed }; - const assistantId = makeId(); - const assistantMessage: CoachMessage = { id: assistantId, role: "assistant", content: "", thinking: "", pending: true }; - const conversation = [...currentChat.messages, userMessage]; - const draftChat: CoachChat = { - ...currentChat, - title: titleForChat(currentChat.title, trimmed), - messages: [...conversation, assistantMessage], - updatedAt: new Date().toISOString(), - }; - - upsertChatState(draftChat); - setActiveChatId(draftChat.id); - setInput(""); - setBusy(true); - setError(""); - - let streamedContent = ""; - let streamedThinking = ""; - const abortController = new AbortController(); - abortRef.current = abortController; - - try { - const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [ - { role: "system", content: buildCoachSystemPrompt(user, dashboard, entries, userLimits, limitCheck) }, - ...conversation - .filter((message) => message.content.trim().length > 0) - .map((message) => ({ - role: message.role, - content: message.content, - ...(message.thinking ? { thinking: message.thinking } : {}), - })), - ]; - - const response = await fetch(OLLAMA_PROXY_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: OLLAMA_MODEL, - messages: requestMessages, - stream: true, - think: true, - }), - signal: abortController.signal, - }); - - if (!response.ok) { - const detail = await response.text(); - throw new Error(parseCoachError(detail, response.status)); - } - if (!response.body) { - throw new Error("streaming response was empty."); - } - - await readOllamaStream(response.body, (chunk) => { - if (chunk.error) throw new Error(chunk.error); - if (chunk.message?.thinking) streamedThinking += chunk.message.thinking; - if (chunk.message?.content) streamedContent += chunk.message.content.toLocaleLowerCase(); - - patchAssistantMessage(draftChat.id, assistantId, { - content: streamedContent, - thinking: streamedThinking, - pending: !streamedContent, - }); - }); - - const finalChat = withAssistantMessage(draftChat, assistantId, { - content: streamedContent || "no answer returned.", - thinking: streamedThinking, - pending: false, - }); - upsertChatState(finalChat); - void persistChat(finalChat); - return true; - } catch (caught) { - const aborted = abortController.signal.aborted; - const message = caught instanceof Error ? caught.message : "coach request failed."; - const finalChat = withAssistantMessage(draftChat, assistantId, { - content: aborted ? streamedContent || "stopped thinking." : `coach unavailable: ${message}`.toLocaleLowerCase(), - thinking: streamedThinking, - pending: false, - stopped: aborted, - }); - upsertChatState(finalChat); - void persistChat(finalChat); - if (!aborted) setError(message); - return false; - } finally { - abortRef.current = null; - setBusy(false); - } - }, - [activeChat, busy, dashboard, entries, limitCheck, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, userLimits, withAssistantMessage], - ); - - const queuePrompt = useCallback((prompt: string) => { - queuedPromptRef.current = prompt; - }, []); - - useEffect(() => { - const prompt = queuedPromptRef.current; - if (!storageReady || !prompt || busy) return; - queuedPromptRef.current = null; - void sendPrompt(prompt); - }, [storageReady, busy, sendPrompt]); - - const startNewChat = useCallback(() => { - const chat = buildNewCoachChat(user, dashboard); - setChats((current) => [chat, ...current]); - setActiveChatId(chat.id); - setInput(""); - setError(""); - }, [dashboard, user]); - - const removeChat = useCallback( - async (chatId: string) => { - if (busy) return; - try { - if (savedChatIds.has(chatId)) await deleteCoachChat(chatId); - setSavedChatIds((current) => { - const next = new Set(current); - next.delete(chatId); - return next; - }); - setChats((current) => { - const next = current.filter((chat) => chat.id !== chatId); - const fallback = buildNewCoachChat(user, dashboard); - setActiveChatId(next[0]?.id ?? fallback.id); - return next.length ? next : [fallback]; - }); - } catch (caught) { - setError(chatStorageErrorMessage(caught)); - } - }, - [busy, dashboard, savedChatIds, user], - ); - - const stopThinking = useCallback(() => { - abortRef.current?.abort(); - }, []); - - return { - activeChatId, - busy, - chats, - error, - input, - queuePrompt, - removeChat, - sendPrompt, - setActiveChatId, - setError, - setInput, - startNewChat, - stopThinking, - storageReady, - storageStatus, - visibleMessages, - }; -} - -function firstName(user: AuthUser) { - const fallback = user.email?.split("@")[0] ?? "there"; - const value = (user.name || fallback).trim(); - return value.split(/\s+/)[0] || "there"; -} - -function buildNewCoachChat(user: AuthUser, dashboard: Dashboard): CoachChat { - const now = new Date().toISOString(); - const favourite = dashboard.favouriteFlavour === "None yet" ? "your patterns" : dashboard.favouriteFlavour; - return { - id: makeId(), - userId: user.$id, - title: "today", - createdAt: now, - updatedAt: now, - messages: [ - { - id: "coach-welcome", - role: "assistant", - content: `hey ${firstName(user).toLocaleLowerCase()}, ${dashboard.todayCans} cans logged today. ask about ${favourite}, caffeine pace, or spend.`, - }, - ], - }; -} - -function titleForChat(currentTitle: string, prompt: string) { - if (currentTitle !== "today" && currentTitle !== "new chat") return currentTitle; - const cleaned = prompt.trim().replace(/\s+/g, " ").toLocaleLowerCase(); - return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "today"; -} - -function buildCoachSystemPrompt( - user: AuthUser, - dashboard: Dashboard, - entries: RedBullEntry[], - userLimits: UserLimits, - limitCheck?: LimitCheckResult, -) { - const recent = entries - .slice(0, 12) - .map( - (entry) => - `- ${humanDateTime(entry.dateTime)}: ${entry.cans} can(s), ${entry.flavour}, ${entry.sizeMl}ml, ${currency.format(spendFor(entry))}, ${wholeNumber.format(caffeineFor(entry))}mg caffeine, ${oneDecimal.format(sugarFor(entry))}g sugar`, - ) - .join("\n"); - - return [ - "You are an upbeat Red Bull intake coach inside a tracking app.", - "Respond entirely in lower case.", - "Give concise, practical suggestions based only on the logged data provided.", - "When asked about favourite flavour historically, use the flavour history breakdown below.", - "Do not give medical advice.", - `User: ${user.name || user.email || "Appwrite user"}`, - `Current time (BST): ${getBstHour()}:00.`, - `Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`, - `Personal limits: ${limitsSummaryForCoach(userLimits, limitCheck ?? { violations: [], projectedCans: 0, projectedSpend: 0, todayCans: 0, todaySpend: 0, pastStopTime: false })}`, - `All-time favourite: ${dashboard.favouriteFlavour}. Streak: ${dashboard.currentStreak} day(s). Spend: ${dashboard.totalSpend}.`, - `Flavour history:\n${buildFlavourHistorySummary(entries)}`, - `Recent entries:\n${recent || "No entries logged yet."}`, - ].join("\n"); -} - -function parseCoachError(detail: string, status: number) { - const trimmed = detail.trim(); - if (trimmed.startsWith("<") || /nginx|405 not allowed/i.test(trimmed)) { - return `coach api unavailable (${status}). run npm run dev with OLLAMA_API_KEY set, or proxy POST /api/ollama-chat on your host.`; - } - return trimmed || `request failed (${status}).`; -} - -async function readOllamaStream(body: ReadableStream, onChunk: (chunk: OllamaStreamChunk) => void) { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - const chunk = parseOllamaLine(line); - if (chunk) onChunk(chunk); - } - } - - buffer += decoder.decode(); - if (buffer.trim()) { - const chunk = parseOllamaLine(buffer); - if (chunk) onChunk(chunk); - } -} - -function parseOllamaLine(line: string): OllamaStreamChunk | null { - const trimmed = line.trim().replace(/^data:\s*/, ""); - if (!trimmed || trimmed === "[DONE]") return null; - try { - return JSON.parse(trimmed) as OllamaStreamChunk; - } catch { - return null; - } -} - -export { OLLAMA_MODEL }; diff --git a/src/lib/userLimits.ts b/src/lib/userLimits.ts index 4c38bbe..dcb611f 100644 --- a/src/lib/userLimits.ts +++ b/src/lib/userLimits.ts @@ -178,25 +178,6 @@ export function limitStatusMessage( return lines.join(" "); } -export function limitsSummaryForCoach(limits: UserLimits, check: LimitCheckResult): string { - const parts: string[] = []; - - if (limits.dailyCanLimit != null) { - parts.push(`daily can limit: ${limits.dailyCanLimit} (${check.todayCans} logged today)`); - } - if (limits.dailySpendLimit != null) { - parts.push(`daily spend limit: ${currency.format(limits.dailySpendLimit)} (${currency.format(check.todaySpend)} today)`); - } - if (limits.stopTime) { - parts.push( - `stop drinking by: ${formatStopTimeLabel(limits.stopTime)} bst (${check.pastStopTime ? "past stop time now" : "before stop time"})`, - ); - } - - if (!parts.length) return "no personal daily limits configured yet."; - return parts.join(". "); -} - export function hasAnyLimit(limits: UserLimits) { return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime); } From cb375adbd6c4c28116719f9ec3271b0b3e326893 Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Wed, 27 May 2026 17:30:43 +0100 Subject: [PATCH 04/11] swap coach table for barcode products in appwrite replace chat collection with barcode_products table, seed verified products, drop ollama env vars and types, prune setup docs --- .env.example | 14 +-- APPWRITE_SETUP.md | 250 ++++++++----------------------------- scripts/setup-appwrite.mjs | 76 +++++++++-- src/lib/appwrite.ts | 2 +- src/vite-env.d.ts | 3 +- 5 files changed, 119 insertions(+), 226 deletions(-) diff --git a/.env.example b/.env.example index 8705f05..a9fa015 100644 --- a/.env.example +++ b/.env.example @@ -2,21 +2,11 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138 VITE_APPWRITE_DATABASE_ID=redbull_tracker VITE_APPWRITE_COLLECTION_ID=intake_entries -VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats +VITE_APPWRITE_BARCODE_COLLECTION_ID=barcode_products -# Optional. Leave blank in local dev so the app uses the current Vite origin, -# including fallback ports like http://127.0.0.1:5174. +# Optional. Leave blank in local dev so the app uses the current Vite origin. VITE_APPWRITE_OAUTH_SUCCESS_URL= VITE_APPWRITE_OAUTH_FAILURE_URL= -# 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 - # Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite. APPWRITE_API_KEY= - -# Appwrite chat table columns needed for encrypted coach chats: -# userId, encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, updatedAt as strings -# version as integer. Enable row security and Users -> Create at table level. diff --git a/APPWRITE_SETUP.md b/APPWRITE_SETUP.md index 3ed9557..0fe41f1 100644 --- a/APPWRITE_SETUP.md +++ b/APPWRITE_SETUP.md @@ -1,222 +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` +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. - -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 encrypts chat titles and messages in the browser before writing rows. The encryption passphrase is not stored, and Appwrite only receives ciphertext. - -Create these chat columns: - -| Key | Type | Required | Notes | -| --- | --- | --- | --- | -| `userId` | String, 64 | Yes | Current Appwrite user ID | -| `encryptedTitle` | String, 4000 | Yes | AES-GCM ciphertext | -| `encryptedMessages` | String, 50000+ | Yes | AES-GCM ciphertext for message JSON | -| `titleIv` | String, 128 | Yes | Base64 IV | -| `messagesIv` | String, 128 | Yes | Base64 IV | -| `salt` | String, 128 | Yes | Base64 PBKDF2 salt | -| `version` | Integer | Yes | Crypto version | -| `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/encryptedChats.ts`: Client-side encrypted chat storage for Appwrite. -- `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/scripts/setup-appwrite.mjs b/scripts/setup-appwrite.mjs index cc11891..3077f3a 100644 --- a/scripts/setup-appwrite.mjs +++ b/scripts/setup-appwrite.mjs @@ -1,6 +1,7 @@ /* global console, fetch, process, setTimeout */ import { existsSync, readFileSync } from "node:fs"; +import { URL } from "node:url"; const env = loadEnvFiles([".env", ".env.local"]); @@ -8,8 +9,11 @@ const endpoint = readEnv("VITE_APPWRITE_ENDPOINT", "https://fra.cloud.appwrite.i const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138"); const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker"); const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries"); -const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats"); +const barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products"); const apiKey = readEnv("APPWRITE_API_KEY", ""); +const verifiedBarcodeProducts = JSON.parse( + readFileSync(new URL("../src/data/verified-barcodes.json", import.meta.url), "utf8"), +); if (!apiKey) { throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_."); @@ -40,20 +44,30 @@ await ensureTable({ ], }); await ensureTable({ - tableId: chatTableId, - name: "Coach chats", + tableId: barcodeTableId, + name: "Barcode products", columns: [ - { kind: "string", key: "userId", size: 64, required: true }, - { kind: "string", key: "encryptedTitle", size: 4000, required: true, encrypt: true }, - { kind: "longtext", key: "encryptedMessages", required: true, encrypt: true }, - { kind: "string", key: "titleIv", size: 128, required: true }, - { kind: "string", key: "messagesIv", size: 128, required: true }, - { kind: "string", key: "salt", size: 128, required: true }, - { kind: "integer", key: "version", required: true }, - { kind: "datetime", key: "updatedAt", required: true }, + { kind: "string", key: "scope", size: 16, required: true }, + { kind: "string", key: "ownerUserId", size: 64, required: false }, + { kind: "string", key: "barcode", size: 32, required: true }, + { kind: "string", key: "flavourName", size: 128, required: true }, + { kind: "integer", key: "sizeMl", required: true }, + { kind: "float", key: "pricePerCan", required: true }, + { kind: "boolean", key: "sugarFree", required: true }, + { kind: "float", key: "caffeineMgPerCan", required: false }, + { kind: "string", key: "verifiedBy", size: 512, required: false }, + { kind: "string", key: "sourceName", size: 512, required: false }, + { kind: "string", key: "sourceUrl", size: 2048, required: false }, + { kind: "string", key: "variant", size: 64, required: false }, + { kind: "string", key: "notes", size: 2000, required: false }, + ], + indexes: [ + { key: "barcode", type: "key", columns: ["barcode"], orders: ["ASC"], lengths: [32] }, + { key: "scope_barcode", type: "key", columns: ["scope", "barcode"], orders: ["ASC", "ASC"], lengths: [16, 32] }, + { key: "user_barcode", type: "key", columns: ["ownerUserId", "barcode"], orders: ["ASC", "ASC"], lengths: [64, 32] }, ], - indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }], }); +await seedVerifiedBarcodeProducts(barcodeTableId, verifiedBarcodeProducts); console.log("Appwrite database and tables ready."); @@ -116,12 +130,48 @@ async function ensureColumn(tableId, column) { array: false, }; if (column.size) body.size = column.size; - if (column.encrypt) body.encrypt = true; await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]); console.log(`Column ${tableId}.${column.key} created.`); } +async function seedVerifiedBarcodeProducts(tableId, products) { + for (const [barcode, product] of Object.entries(products)) { + const rowId = `verified_${barcode}`; + const data = { + scope: "verified", + ownerUserId: "", + barcode, + flavourName: product.flavourName, + sizeMl: product.sizeMl, + pricePerCan: product.pricePerCan, + sugarFree: Boolean(product.sugarFree), + caffeineMgPerCan: product.caffeineMgPerCan, + verifiedBy: product.verifiedBy ?? "", + sourceName: product.sourceName ?? "", + sourceUrl: product.sourceUrl ?? "", + variant: product.variant ?? "", + notes: product.notes ?? "", + }; + const path = `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`; + const existing = await request("GET", path, undefined, [200, 404]); + + if (existing.status === 404) { + await request( + "POST", + `/tablesdb/${databaseId}/tables/${tableId}/rows`, + { rowId, data, permissions: ['read("users")'] }, + [201], + ); + console.log(`Verified barcode ${barcode} seeded.`); + continue; + } + + await request("PUT", path, { data, permissions: ['read("users")'] }, [200]); + console.log(`Verified barcode ${barcode} updated.`); + } +} + async function ensureIndex(tableId, index) { const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]); if (existing.status === 200) { diff --git a/src/lib/appwrite.ts b/src/lib/appwrite.ts index 30cd0c0..d015ea9 100644 --- a/src/lib/appwrite.ts +++ b/src/lib/appwrite.ts @@ -8,7 +8,7 @@ export const appwriteConfig = { projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138", databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker", collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries", - chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats", + barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products", oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL), oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL), }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b6746b5..e5ecceb 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,10 +5,9 @@ interface ImportMetaEnv { readonly VITE_APPWRITE_PROJECT_ID?: string; readonly VITE_APPWRITE_DATABASE_ID?: string; readonly VITE_APPWRITE_COLLECTION_ID?: string; - readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string; + readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string; readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string; readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string; - readonly VITE_OLLAMA_PROXY_URL?: string; } interface ImportMeta { From ea8b10a81f20b06dc2f866da8ae4bd5e76be51a6 Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Wed, 27 May 2026 17:30:48 +0100 Subject: [PATCH 05/11] wire up barcode scanning with zxing scanner modal, product preview, verified catalog with 12 red bull barcodes, appwrite-backed user mappings, lookup pipeline with known/user/partial/unknown result --- src/components/BarcodeScannerModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/BarcodeScannerModal.tsx b/src/components/BarcodeScannerModal.tsx index c7ad63f..6bc5705 100644 --- a/src/components/BarcodeScannerModal.tsx +++ b/src/components/BarcodeScannerModal.tsx @@ -301,7 +301,7 @@ export function BarcodeScannerModal({ {open && (
-

Camera scan

-

+

Camera scan

+

Scan barcode

-

Point your camera at the barcode on the can.

+

Point your camera at the barcode on the can.

); } @@ -713,15 +869,15 @@ function LoadingScreen({ themeId: string; }) { return ( -
+
-
+
-

Red Bull command centre

-

{setupStatus.message}

+

Red Bull tracker

+

{setupStatus.message}

@@ -762,82 +918,75 @@ function AuthView({ } return ( -
+
-
-
-
-
-

- Red Bull Tracker App -

-

- Glossy intake telemetry with Appwrite authentication, device sync, and finance-grade Excel exports. -

-
- - - -
- {setupStatus.state !== "ok" && ( -
- {setupStatus.message} -
- )} -
- -
-
- - +
+
+
+

Red Bull tracker

+

Track intake, sync across devices.

-
- {mode === "signup" && ( - - )} - - - - {authError && ( -
- {authError} +
+ {setupStatus.state !== "ok" && ( +
+ {setupStatus.message}
)} - - +
+ + +
-
- - OAuth - +
+ {mode === "signup" && ( + + )} + + + + {authError && ( +
+ {authError} +
+ )} + + +
+ +
+ + or + +
+ +
+ + +
@@ -856,31 +1005,6 @@ function AuthView({ ); } -function AuthSignal({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) { - return ( -
-
- ); -} - -function CurrentThemeIndicator({ - theme, - onClick, -}: { - theme: AppTheme; - onClick: () => void; -}) { - return ( - - ); -} - function ThemePicker({ themeId, onChange, @@ -888,37 +1012,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) => (
); @@ -947,6 +1054,7 @@ function Sidebar({ setupStatus, user, onAdd, + onScan, onChange, onOpenSettings, }: { @@ -956,6 +1064,7 @@ function Sidebar({ setupStatus: SetupStatus; user: AuthUser; onAdd: () => void; + onScan: () => void; onChange: (view: AppView) => void; onOpenSettings: () => void; }) { @@ -966,20 +1075,32 @@ function Sidebar({
-

Red Bull

-

Intake telemetry

+

Red Bull

+

Intake tracker

-