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) : {}; -}