diff --git a/.env.example b/.env.example
index 4a14ee9..7a09b2f 100644
--- a/.env.example
+++ b/.env.example
@@ -2,16 +2,7 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your-project-id
VITE_APPWRITE_DATABASE_ID=redbull_tracker
VITE_APPWRITE_COLLECTION_ID=intake_entries
-VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats
-
-
-# Server-only. Do not prefix with VITE_ or it will be exposed to the browser.
-OLLAMA_API_KEY=
-OLLAMA_MODEL=deepseek-v4-pro:cloud
-VITE_OLLAMA_PROXY_URL=/api/ollama-chat
+VITE_APPWRITE_BARCODE_COLLECTION_ID=barcode_products
# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite.
-APPWRITE_API_KEY=
-
-# Appwrite chat table columns: userId, title, messages, updatedAt.
-# Enable row security and Users -> Create at table level.
+APPWRITE_API_KEY=
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index baf4031..8fa56cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
+.deploy/
+public/*.html
diff --git a/APPWRITE_SETUP.md b/APPWRITE_SETUP.md
index ec5e506..0fe41f1 100644
--- a/APPWRITE_SETUP.md
+++ b/APPWRITE_SETUP.md
@@ -1,221 +1,76 @@
-# Red Bull Intake Tracker Setup
+# Red Bull tracker setup
-## Commands
+This app uses Appwrite for auth and intake entries.
-```bash
-npm install
-npm run dev
-npm run build
-npm run lint
+## env
+
+Copy `.env.example` to `.env.local`, then fill in:
+
+```sh
+VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
+VITE_APPWRITE_PROJECT_ID=your_project_id
+VITE_APPWRITE_DATABASE_ID=redbull_tracker
+VITE_APPWRITE_COLLECTION_ID=intake_entries
+APPWRITE_API_KEY=server_key_for_setup_only
```
-The Vite dev app runs at `http://localhost:5173` unless that port is already taken.
+Leave the OAuth URLs empty in local dev unless you need fixed callback URLs.
-## Environment
+## setup
-Copy `.env.example` to `.env.local` and adjust IDs if you choose different Appwrite resource IDs:
+Run:
-```bash
-cp .env.example .env.local
-```
-
-This app uses only the Appwrite browser SDK. Do not add an API key to the frontend.
-
-To create/update the database tables from this repo, set a server/admin key as `APPWRITE_API_KEY` in `.env.local` and run:
-
-```bash
+```sh
npm run setup:appwrite
```
-The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code.
+The script creates or updates:
-Configured defaults:
+- database: `redbull_tracker`
+- table: `intake_entries`
+- table permission: `Users -> Create`
+- row security: enabled
-- Endpoint: `https://fra.cloud.appwrite.io/v1`
-- Project ID: `6a0752ee001fb2ef7138`
-- Project name: `Red Bull Tracker App`
-- Database ID: `redbull_tracker`
-- Collection ID: `intake_entries`
-- Chat collection ID: `coach_chats`
-- Barcode collection ID: `barcode_products`
+Rows use per-user read, update, and delete permissions.
-`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`.
+## intake columns
-## Auth
+| key | type | required |
+| --- | --- | --- |
+| `userId` | String, 64 | Yes |
+| `cans` | Float | Yes |
+| `flavour` | String, 128 | Yes |
+| `flavourAccent` | String, 32 | Yes |
+| `sizeMl` | Integer | Yes |
+| `pricePerCan` | Float | Yes |
+| `dateTime` | DateTime | Yes |
+| `notes` | String, 2000 | No |
+| `store` | String, 256 | No |
+| `sugarFree` | Boolean | Yes |
+| `caffeineMgPerCan` | Float | No |
+| `importKey` | String, 512 | Yes |
+| `source` | String, 32 | Yes |
-Enable these auth methods in Appwrite Console:
+## indexes
-- Email/password
-- GitHub OAuth
-- Google OAuth
+- `user_date_desc`: `userId`, `dateTime`
+- `user_import_key`: `userId`, `importKey`
-Add a Web platform in Appwrite Console for local development:
+## run
-- Hostname: `localhost`
-- Hostname: `127.0.0.1`
-
-If `client.ping()` shows `Failed to fetch`, this is usually the first thing to check.
-
-For local OAuth callback URLs, add:
-
-- Success URL: `http://localhost:5173`
-- Failure URL: `http://localhost:5173`
-- If Vite starts on another port, add that origin too, for example `http://127.0.0.1:5174`
-
-For production, add your deployed origin as both success and failure URL, then update the `VITE_APPWRITE_OAUTH_*` variables.
-
-In local dev, you can leave `VITE_APPWRITE_OAUTH_SUCCESS_URL` and `VITE_APPWRITE_OAUTH_FAILURE_URL` blank. The app will use the current browser origin automatically, which avoids getting redirected to a stale Vite port.
-
-If OAuth returns to the app but you are still logged out:
-
-- Confirm the current browser origin is listed under Appwrite project platforms, for example `localhost` and `127.0.0.1`.
-- Confirm the same origin is allowed in the OAuth provider success/failure URLs.
-- Clear old sessions/cookies for the local app and try again.
-- Restart Vite after editing `.env.local`.
-
-## Database
-
-Appwrite currently uses newer Console wording in many places:
-
-| In this app / older SDK wording | Current Appwrite Console wording |
-| --- | --- |
-| Collection | Table |
-| Attribute | Column |
-| Document | Row |
-
-So if the Console asks you to create a **table**, that is the same resource as the `VITE_APPWRITE_COLLECTION_ID` this app currently points at. If the setup below says **attributes**, add them as **columns** inside that table.
-
-The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID.
-
-The barcode scanner uses a separate `barcode_products` table by default. Verified Red Bull barcode rows are seeded by `scripts/setup-appwrite.mjs` using `APPWRITE_API_KEY`; browser code can only read verified rows and create/update the current user's own mappings with row-level permissions.
-
-Create a database with ID:
-
-```text
-redbull_tracker
+```sh
+npm install
+npm run dev
```
-Create a collection with ID:
+## deployment-only files
-```text
-intake_entries
-```
+The repo ignores `.deploy/` and local public HTML pages.
-Enable document-level permissions on the collection.
+For your own deployment, create:
-Recommended collection-level permissions:
+- `.deploy/head.html` for analytics or other head-only snippets
+- `.deploy/body-end.html` for footer links or deploy-only markup
+- any local public HTML pages your host needs
-- Create: `users`
-- Read: none
-- Update: none
-- Delete: none
-
-The app writes per-document permissions for the current user:
-
-- `read("user:{userId}")`
-- `update("user:{userId}")`
-- `delete("user:{userId}")`
-
-## Permission Troubleshooting
-
-If the app shows:
-
-```text
-No permissions provided for action 'create'
-```
-
-the table is reachable, but the signed-in user is not allowed to create rows yet.
-
-Fix it in Appwrite Console:
-
-1. Open **Databases**.
-2. Open database `redbull_tracker`.
-3. Open table `intake_entries`.
-4. Go to **Settings**.
-5. Enable **Row Security**.
-6. Under **Permissions**, add role **Users**.
-7. Check **Create** only.
-8. Leave table-level **Read**, **Update**, and **Delete** unchecked.
-9. Click **Update** / **Save**.
-
-Why: table-level **Create** lets authenticated users add their own rows. The app then writes row-level read/update/delete permissions for that exact user, so users do not see each other's entries.
-
-## Attributes
-
-Create these attributes:
-
-| Key | Type | Required | Notes |
-| --- | --- | --- | --- |
-| `userId` | String, 64 | Yes | Current Appwrite user ID |
-| `cans` | Float | Yes | Allows partial cans |
-| `flavour` | String, 128 | Yes | Red Bull flavour |
-| `flavourAccent` | String, 32 | Yes | UI colour |
-| `sizeMl` | Integer | Yes | Can size in ml |
-| `pricePerCan` | Float | Yes | GBP price per can |
-| `dateTime` | DateTime | Yes | Intake timestamp |
-| `notes` | String, 2000 | No | Optional notes |
-| `store` | String, 256 | No | Store/location |
-| `sugarFree` | Boolean | Yes | Sugar-free flag |
-| `caffeineMgPerCan` | Float | No | Custom-size override |
-| `importKey` | String, 512 | Yes | Duplicate detection signature |
-| `source` | String, 32 | Yes | `manual`, `quick-add`, `excel`, or `json` |
-
-Recommended indexes:
-
-- `user_date_desc`: key index on `userId`, `dateTime`
-- `user_import_key`: key index on `userId`, `importKey`
-- Optional unique index on `userId`, `importKey` if your Appwrite plan/schema supports it
-
-## Encrypted Coach Chats
-
-Create a second table with ID:
-
-```text
-coach_chats
-```
-
-Enable row security on `coach_chats`.
-
-Recommended table-level permissions:
-
-- Create: `users`
-- Read: none
-- Update: none
-- Delete: none
-
-The app stores coach chat titles and messages as plain JSON in Appwrite with row-level user permissions.
-
-Create these chat columns:
-
-| Key | Type | Required | Notes |
-| --- | --- | --- | --- |
-| `userId` | String, 64 | Yes | Current Appwrite user ID |
-| `title` | String, 512 | Yes | Chat title |
-| `messages` | Longtext | Yes | JSON array of coach messages |
-| `updatedAt` | DateTime | Yes | Sort key |
-
-Recommended chat index:
-
-- `user_chat_updated`: key index on `userId`, `updatedAt`
-
-## Component Structure
-
-- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/coach/data views, modals, and action state.
-- `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper.
-- `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures.
-- `src/lib/coachChats.ts`: Appwrite-backed coach chat storage.
-- `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview.
-- `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks.
-- `src/lib/storage.ts`: JSON backup export/import parser.
-- `src/data/flavours.ts`: Built-in flavours and accent metadata.
-
-## Nutrition Defaults
-
-- 250ml: `£1.75`, `80mg` caffeine
-- 355ml: `£2.20`, `114mg` caffeine
-- 473ml: `£2.85`, `151mg` caffeine
-- Custom sizes: caffeine is proportional from 250ml unless a custom override is entered
-
-The UI shows this disclaimer:
-
-> Caffeine and sugar values are estimates. Check the can label for exact nutritional information.
+Vite injects the optional `.deploy` snippets into `index.html` at build time.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3d0c56e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Ned Halksworth
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/api/ollama-chat.js b/api/ollama-chat.js
deleted file mode 100644
index 5ed710b..0000000
--- a/api/ollama-chat.js
+++ /dev/null
@@ -1,77 +0,0 @@
-/* global Buffer, fetch, process */
-
-const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
-
-export default async function handler(req, res) {
- res.setHeader("Access-Control-Allow-Origin", "*");
- res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
-
- if (req.method === "OPTIONS") {
- res.statusCode = 204;
- res.end();
- return;
- }
-
- if (req.method !== "POST") {
- res.statusCode = 405;
- res.end("Method not allowed");
- return;
- }
-
- const apiKey = process.env.OLLAMA_API_KEY;
- if (!apiKey) {
- res.statusCode = 500;
- res.end("OLLAMA_API_KEY is not configured on the server.");
- return;
- }
-
- try {
- const payload = await readJson(req);
- const upstream = await fetch("https://ollama.com/api/chat", {
- method: "POST",
- headers: {
- Authorization: `Bearer ${apiKey}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- ...payload,
- model: payload.model || process.env.OLLAMA_MODEL || DEFAULT_MODEL,
- stream: payload.stream !== false,
- }),
- });
-
- res.statusCode = upstream.status;
- res.setHeader("Content-Type", upstream.headers.get("content-type") || "application/x-ndjson");
-
- if (!upstream.ok) {
- res.end(await upstream.text());
- return;
- }
-
- if (!upstream.body) {
- res.end();
- return;
- }
-
- const reader = upstream.body.getReader();
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- res.write(Buffer.from(value));
- }
- res.end();
- } catch (error) {
- res.statusCode = 500;
- res.end(error instanceof Error ? error.message : "Ollama proxy failed.");
- }
-}
-
-async function readJson(req) {
- if (req.body && typeof req.body === "object") return req.body;
- if (typeof req.body === "string") return JSON.parse(req.body || "{}");
-
- let raw = "";
- for await (const chunk of req) raw += chunk;
- return raw ? JSON.parse(raw) : {};
-}
diff --git a/index.html b/index.html
index 418e042..3c91a47 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
-
+
;
type SetupStatus = { state: "checking" | "ok" | "error"; message: string };
+type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } };
+const OLLAMA_MODEL = "deepseek-v4-pro:cloud";
+const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat";
-type PendingLimitAction = {
- kind: "save" | "quick";
- draft: EntryDraft;
- editingId?: string;
- quickLabel?: string;
+type ForecastPoint = {
+ label: string;
+ current: number;
+ lower: number;
+ limit?: number;
};
const DEFAULT_FILTERS: Filters = {
@@ -165,18 +173,9 @@ const NAV_ITEMS: Array<{ id: AppView; label: string; icon: LucideIcon }> = [
{ id: "overview", label: "Overview", icon: Home },
{ id: "logbook", label: "Logbook", icon: CalendarDays },
{ id: "trends", label: "Trends", icon: LineChart },
- { id: "coach", label: "Coach", icon: MessageCircle },
{ id: "settings", label: "Settings", icon: Settings2 },
];
-const MATERIAL_ACCENTS = {
- primary: "var(--chart-primary)",
- secondary: "var(--chart-secondary)",
- tertiary: "var(--chart-tertiary)",
- error: "var(--chart-error)",
- custom: "#b85d84",
-};
-
function App() {
const [themeId, setThemeId] = useState(() => readStoredThemeId());
const activeTheme = useMemo(() => getThemeById(themeId), [themeId]);
@@ -198,31 +197,31 @@ function App() {
const [isResetOpen, setIsResetOpen] = useState(false);
const [notice, setNotice] = useState("Appwrite session pending.");
const [dataLoading, setDataLoading] = useState(false);
- const [actionLoading, setActionLoading] = useState(null);
- const [dataError, setDataError] = useState("");
+ const [busyAction, setBusyAction] = useState(null);
+ const [syncError, setSyncError] = useState("");
const [importPreview, setImportPreview] = useState(null);
const [userLimits, setUserLimits] = useState({});
const [limitConfirmOpen, setLimitConfirmOpen] = useState(false);
const [limitConfirmMessage, setLimitConfirmMessage] = useState("");
const [pendingLimitAction, setPendingLimitAction] = useState(null);
- const [showOnboarding, setShowOnboarding] = useState(false);
+ const [setupOpen, setSetupOpen] = useState(false);
const excelFileInputRef = useRef(null);
const jsonFileInputRef = useRef(null);
useEffect(() => {
- localStorage.setItem(THEME_STORAGE_KEY, themeId);
+ localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId));
}, [themeId]);
const refreshEntries = useCallback(async (userId: string, showLoader = true) => {
if (showLoader) setDataLoading(true);
- setDataError("");
+ setSyncError("");
try {
const remoteEntries = await listEntries(userId);
setEntries(sortEntries(remoteEntries));
setNotice(`Synced ${remoteEntries.length} Appwrite entr${remoteEntries.length === 1 ? "y" : "ies"}.`);
} catch (error) {
const message = appwriteErrorMessage(error);
- setDataError(message);
+ setSyncError(message);
setNotice("Appwrite sync failed.");
} finally {
if (showLoader) setDataLoading(false);
@@ -253,11 +252,11 @@ function App() {
setUser(currentUser);
setUserLimits(parseUserLimits(currentUser.prefs));
if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) {
- setThemeId(currentUser.prefs.themeId);
+ setThemeId(normaliseThemeId(currentUser.prefs.themeId));
}
setNotice(`Signed in as ${currentUser.email || currentUser.name || "Appwrite user"}.`);
if (!currentUser.prefs.onboarded) {
- setShowOnboarding(true);
+ setSetupOpen(true);
}
} catch {
if (!mounted) return;
@@ -301,27 +300,20 @@ function App() {
() => mergedFlavours(entries.map((entry) => entry.flavour)),
[entries],
);
- const filteredEntries = useMemo(
+ const entriesInView = useMemo(
() => sortEntries(applyFilters(entries, filters)),
[entries, filters],
);
- const dashboard = useMemo(() => buildDashboard(entries), [entries]);
+ const summary = useMemo(() => buildDashboard(entries), [entries]);
const limitCheck = useMemo(() => evaluateLimits(userLimits, entries), [userLimits, entries]);
- const chartData = useMemo(() => groupByDay(filteredEntries), [filteredEntries]);
- const weekData = useMemo(() => groupByWeek(filteredEntries), [filteredEntries]);
- const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]);
+ const chartData = useMemo(() => groupByDay(entriesInView), [entriesInView]);
+ const weekData = useMemo(() => groupByWeek(entriesInView), [entriesInView]);
+ const flavourData = useMemo(() => groupByFlavour(entriesInView), [entriesInView]);
const insights = useMemo(() => buildInsights(entries), [entries]);
const recentEntries = useMemo(() => entries.slice(0, 5), [entries]);
- const coachSession = useCoachSession(
- user ?? ({ $id: "", email: "", name: "" } as AuthUser),
- dashboard,
- entries,
- userLimits,
- limitCheck,
- );
async function login(email: string, password: string) {
- setActionLoading("auth");
+ setBusyAction("auth");
setAuthError("");
try {
await account.createEmailPasswordSession({ email, password });
@@ -329,21 +321,21 @@ function App() {
setUser(currentUser);
setUserLimits(parseUserLimits(currentUser.prefs));
if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) {
- setThemeId(currentUser.prefs.themeId);
+ setThemeId(normaliseThemeId(currentUser.prefs.themeId));
}
setNotice(`Signed in as ${currentUser.email}.`);
if (!currentUser.prefs.onboarded) {
- setShowOnboarding(true);
+ setSetupOpen(true);
}
} catch (error) {
setAuthError(appwriteErrorMessage(error));
} finally {
- setActionLoading(null);
+ setBusyAction(null);
}
}
async function signup(name: string, email: string, password: string) {
- setActionLoading("auth");
+ setBusyAction("auth");
setAuthError("");
try {
await account.create({
@@ -355,28 +347,27 @@ function App() {
await account.createEmailPasswordSession({ email, password });
const currentUser = await account.get();
setUser(currentUser);
- setUserLimits(parseUserLimits(currentUser.prefs));
setNotice(`Welcome, ${currentUser.name || currentUser.email}.`);
- setShowOnboarding(true);
+ setSetupOpen(true);
} catch (error) {
setAuthError(appwriteErrorMessage(error));
} finally {
- setActionLoading(null);
+ setBusyAction(null);
}
}
async function logout() {
- setDataError("");
+ setBusyAction("logout");
+ setSyncError("");
try {
await account.deleteSession({ sessionId: "current" });
setUser(null);
setEntries([]);
- setUserLimits({});
setNotice("Logged out.");
} catch (error) {
- setDataError(appwriteErrorMessage(error));
+ setSyncError(appwriteErrorMessage(error));
} finally {
- setActionLoading(null);
+ setBusyAction(null);
}
}
@@ -390,10 +381,22 @@ function App() {
setIsBarcodeScannerOpen(true);
}
- async function saveUserLimits(next: UserLimits) {
+ function addBarcodeDraft(draft: EntryDraft) {
+ setIsBarcodeScannerOpen(false);
+ saveDraftWithLimitCheck(draft);
+ }
+
+ function editBarcodeDraft(draft: EntryDraft) {
+ setIsBarcodeScannerOpen(false);
+ setEditingEntry(null);
+ setEntryInitialDraft(draft);
+ setIsEntryModalOpen(true);
+ }
+
+ async function saveEntry(draft: EntryDraft) {
if (!user) return;
- setActionLoading("save-limits");
- setDataError("");
+ setBusyAction("save-limits");
+ setSyncError("");
try {
const prefs = mergePrefsWithLimits(user.prefs, next);
await account.updatePrefs(prefs);
@@ -402,16 +405,16 @@ function App() {
setUserLimits(parseUserLimits(currentUser.prefs));
setNotice("Daily limits saved to your account.");
} catch (error) {
- setDataError(appwriteErrorMessage(error));
+ setSyncError(appwriteErrorMessage(error));
} finally {
- setActionLoading(null);
+ setBusyAction(null);
}
}
async function saveOnboarding(limits: UserLimits, onboardingThemeId: string) {
if (!user) return;
- setActionLoading("save-onboarding");
- setDataError("");
+ setBusyAction("save-onboarding");
+ setSyncError("");
try {
const limitsPrefs = mergePrefsWithLimits(user.prefs, limits);
const nextPrefs = {
@@ -424,43 +427,43 @@ function App() {
setUser(currentUser);
setUserLimits(parseUserLimits(currentUser.prefs));
setThemeId(onboardingThemeId);
- setShowOnboarding(false);
- setNotice("Onboarding limits and theme saved successfully.");
+ setSetupOpen(false);
+ setNotice("Setup saved.");
} catch (error) {
- setDataError(appwriteErrorMessage(error));
+ setSyncError(appwriteErrorMessage(error));
} finally {
- setActionLoading(null);
+ setBusyAction(null);
}
}
- async function persistEntry(action: PendingLimitAction) {
+ async function saveDraft(action: PendingLimitAction) {
if (!user) return;
const loadingKey = action.kind === "quick" ? `quick-${action.quickLabel ?? "add"}` : "save-entry";
- setActionLoading(loadingKey);
- setDataError("");
+ setBusyAction(loadingKey);
+ setSyncError("");
try {
const editing = action.editingId ? entries.find((entry) => entry.id === action.editingId) : null;
const saved = editing
? await updateEntry(user.$id, editing.id, { ...action.draft, source: editing.source })
: await createEntry(user.$id, { ...action.draft, source: action.draft.source ?? "manual" });
setEntries((current) =>
- sortEntries(editing ? current.map((entry) => (entry.id === saved.id ? saved : entry)) : [saved, ...current]),
+ sortEntries(editingEntry ? current.map((entry) => (entry.id === saved.id ? saved : entry)) : [saved, ...current]),
);
- setNotice(editing ? "Entry updated in Appwrite." : "Entry saved to Appwrite.");
+ setNotice(editingEntry ? "Entry updated in Appwrite." : "Entry saved to Appwrite.");
setEditingEntry(null);
setEntryInitialDraft(null);
setIsEntryModalOpen(false);
} catch (error) {
- setDataError(appwriteErrorMessage(error));
+ setSyncError(appwriteErrorMessage(error));
} finally {
- setActionLoading(null);
+ setBusyAction(null);
setLimitConfirmOpen(false);
setPendingLimitAction(null);
setLimitConfirmMessage("");
}
}
- function requestEntrySave(draft: EntryDraft, editingId?: string) {
+ function saveDraftWithLimitCheck(draft: EntryDraft, editingId?: string) {
const check = evaluateLimits(userLimits, entries, { draft, excludeEntryId: editingId });
if (check.violations.length) {
setPendingLimitAction({ kind: "save", draft, editingId });
@@ -468,24 +471,12 @@ function App() {
setLimitConfirmOpen(true);
return;
}
- void persistEntry({ kind: "save", draft, editingId });
+ void saveDraft({ kind: "save", draft, editingId });
}
- async function saveEntry(draft: EntryDraft) {
+ async function saveEntryDraft(draft: EntryDraft) {
if (!user) return;
- requestEntrySave(draft, editingEntry?.id);
- }
-
- function addBarcodeDraft(draft: EntryDraft) {
- setIsBarcodeScannerOpen(false);
- requestEntrySave(draft);
- }
-
- function editBarcodeDraft(draft: EntryDraft) {
- setIsBarcodeScannerOpen(false);
- setEditingEntry(null);
- setEntryInitialDraft(draft);
- setIsEntryModalOpen(true);
+ saveDraftWithLimitCheck(draft, editingEntry?.id);
}
async function quickAdd(item: (typeof QUICK_ADDS)[number]) {
@@ -504,39 +495,43 @@ function App() {
source: "quick-add",
};
- const check = evaluateLimits(userLimits, entries, { draft });
- if (check.violations.length) {
- setPendingLimitAction({ kind: "quick", draft, quickLabel: item.label });
- setLimitConfirmMessage(limitStatusMessage(check.violations, check, userLimits));
- setLimitConfirmOpen(true);
- return;
- }
-
- void persistEntry({ kind: "quick", draft, quickLabel: item.label });
- }
-
- function confirmLimitOverride() {
- if (!pendingLimitAction) return;
- void persistEntry(pendingLimitAction);
- }
-
- async function deleteEntry(id: string) {
- setActionLoading(`delete-${id}`);
+ setActionLoading(`quick-${item.label}`);
setDataError("");
try {
- await deleteEntryDocument(id);
- setEntries((current) => current.filter((entry) => entry.id !== id));
- setNotice("Entry deleted from Appwrite.");
+ const saved = await createEntry(user.$id, draft);
+ setEntries((current) => sortEntries([saved, ...current]));
+ setNotice(`${item.label} saved to Appwrite.`);
} catch (error) {
setDataError(appwriteErrorMessage(error));
} finally {
setActionLoading(null);
}
+
+ void saveDraft({ kind: "quick", draft, quickLabel: item.label });
+ }
+
+ function confirmLimitOverride() {
+ if (!pendingLimitAction) return;
+ void saveDraft(pendingLimitAction);
+ }
+
+ async function deleteEntry(id: string) {
+ setBusyAction(`delete-${id}`);
+ setSyncError("");
+ try {
+ await deleteEntryDocument(id);
+ setEntries((current) => current.filter((entry) => entry.id !== id));
+ setNotice("Entry deleted from Appwrite.");
+ } catch (error) {
+ setSyncError(appwriteErrorMessage(error));
+ } finally {
+ setBusyAction(null);
+ }
}
async function resetAll() {
- setActionLoading("reset");
- setDataError("");
+ setBusyAction("reset");
+ setSyncError("");
try {
await Promise.all(entries.map((entry) => deleteEntryDocument(entry.id)));
setEntries([]);
@@ -544,39 +539,39 @@ function App() {
setIsResetOpen(false);
setNotice("All Appwrite entries deleted.");
} catch (error) {
- setDataError(appwriteErrorMessage(error));
+ setSyncError(appwriteErrorMessage(error));
} finally {
- setActionLoading(null);
+ setBusyAction(null);
}
}
async function exportExcel() {
- setActionLoading("excel-export");
- setDataError("");
+ setBusyAction("excel-export");
+ setSyncError("");
try {
const blob = await createExcelExport(entries);
downloadBlob(blob, `red-bull-intake-${new Date().toISOString().slice(0, 10)}.xlsx`);
setNotice("Excel workbook exported.");
} catch (error) {
- setDataError(error instanceof Error ? error.message : "Excel export failed.");
+ setSyncError(error instanceof Error ? error.message : "Excel export failed.");
} finally {
- setActionLoading(null);
+ setBusyAction(null);
}
}
async function importExcel(file: File | undefined) {
if (!file) return;
- setActionLoading("excel-import");
- setDataError("");
+ setBusyAction("excel-import");
+ setSyncError("");
try {
const preview = await parseExcelImport(file, entries);
setImportPreview(preview);
setNotice(`${preview.rows.length} Excel row${preview.rows.length === 1 ? "" : "s"} parsed for review.`);
} catch (error) {
- setDataError(error instanceof Error ? error.message : "Excel import failed.");
+ setSyncError(error instanceof Error ? error.message : "Excel import failed.");
} finally {
if (excelFileInputRef.current) excelFileInputRef.current.value = "";
- setActionLoading(null);
+ setBusyAction(null);
}
}
@@ -591,17 +586,17 @@ function App() {
return;
}
- setActionLoading("confirm-excel-import");
- setDataError("");
+ setBusyAction("confirm-excel-import");
+ setSyncError("");
try {
const saved = await createEntries(user.$id, drafts);
setEntries((current) => sortEntries([...saved, ...current]));
setImportPreview(null);
setNotice(`${saved.length} Excel row${saved.length === 1 ? "" : "s"} saved to Appwrite.`);
} catch (error) {
- setDataError(appwriteErrorMessage(error));
+ setSyncError(appwriteErrorMessage(error));
} finally {
- setActionLoading(null);
+ setBusyAction(null);
}
}
@@ -613,8 +608,8 @@ function App() {
async function importJson(file: File | undefined) {
if (!file || !user) return;
- setActionLoading("json-import");
- setDataError("");
+ setBusyAction("json-import");
+ setSyncError("");
try {
const drafts = parseImport(await file.text());
const uniqueDrafts = drafts.filter((draft) => !isDuplicateDraft(entries, draft));
@@ -626,10 +621,10 @@ function App() {
setEntries((current) => sortEntries([...saved, ...current]));
setNotice(`${saved.length} JSON entr${saved.length === 1 ? "y" : "ies"} saved to Appwrite.`);
} catch (error) {
- setDataError(error instanceof Error ? error.message : "JSON import failed.");
+ setSyncError(error instanceof Error ? error.message : "JSON import failed.");
} finally {
if (jsonFileInputRef.current) jsonFileInputRef.current.value = "";
- setActionLoading(null);
+ setBusyAction(null);
}
}
@@ -641,12 +636,11 @@ function App() {
return (
);
@@ -654,17 +648,17 @@ function App() {
return (
- {showOnboarding && user && (
+ {setupOpen && user && (
setShowOnboarding(false)}
+ onClose={() => setSetupOpen(false)}
/>
)}
-
+
setActiveView("settings")}
/>
-
+
-
+
- {activeView === "overview" && user && (
+ {activeView === "overview" && (
void quickAdd(item)}
onAdd={openNewEntry}
onScan={openBarcodeScanner}
- onOpenCoach={(prompt) => {
- if (prompt) coachSession.queuePrompt(prompt);
- setActiveView("coach");
- }}
onOpenLogbook={() => setActiveView("logbook")}
- onOpenSettings={() => setActiveView("settings")}
/>
)}
{activeView === "logbook" && (
void saveUserLimits(next)}
- />
- )}
-
- {activeView === "coach" && user && (
-
)}
{activeView === "settings" && (
void exportExcel()}
onImportExcel={() => excelFileInputRef.current?.click()}
onExportJson={exportJson}
@@ -804,7 +783,7 @@ function App() {
onReset={() => setIsResetOpen(true)}
onThemeChange={setThemeId}
onSaveLimits={(next) => void saveUserLimits(next)}
- onRerunOnboarding={() => setShowOnboarding(true)}
+ onRerunOnboarding={() => setSetupOpen(true)}
/>
)}
@@ -817,7 +796,7 @@ function App() {
initialDraft={entryInitialDraft}
flavours={allFlavours}
open={isEntryModalOpen}
- saving={actionLoading === "save-entry"}
+ saving={busyAction === "save-entry"}
userLimits={userLimits}
entries={entries}
onClose={() => {
@@ -825,11 +804,11 @@ function App() {
setEditingEntry(null);
setEntryInitialDraft(null);
}}
- onSave={(draft) => void saveEntry(draft)}
+ onSave={(draft) => void saveEntryDraft(draft)}
/>
setImportPreview(null)}
onConfirm={() => void confirmExcelImport()}
/>
+
-
+
-
Red Bull tracker
-
{setupStatus.message}
+
Red Bull tracker
+
{setupStatus.message}
@@ -914,7 +893,6 @@ function AuthView({
shellStyle,
themeId,
onLogin,
-
onSignup,
}: {
authError: string;
@@ -923,7 +901,6 @@ function AuthView({
shellStyle: CSSProperties;
themeId: string;
onLogin: (email: string, password: string) => Promise
;
-
onSignup: (name: string, email: string, password: string) => Promise;
}) {
const [mode, setMode] = useState("login");
@@ -941,57 +918,49 @@ function AuthView({
}
return (
-
+
-
+
-
Red Bull Tracker
-
Track intake, sync across devices.
+
Red Bull tracker
+
Track intake, sync across devices.
-
+
{setupStatus.state !== "ok" && (
-
+
{setupStatus.message}
)}
-
-
setMode("login")}
- >
+
+ setMode("login")}>
Log in
- setMode("signup")}
- >
+ setMode("signup")}>
Sign up
+
+
+ onOAuth("github")}>
+
+ GitHub
+
+ onOAuth("google")}>
+
+ Google
+
+
+
);
}
-
function ThemePicker({
themeId,
onChange,
@@ -1018,37 +996,20 @@ function ThemePicker({
themeId: string;
onChange: (id: string) => void;
}) {
- const [category, setCategory] = useState
("vocaloid");
const activeTheme = getThemeById(themeId);
- const visibleThemes = APP_THEMES.filter((theme) => theme.category === category);
return (
-
- {THEME_CATEGORIES.map((entry) => (
- setCategory(entry.id)}
- >
- {entry.label}
-
- ))}
-
-
-
Primary
-
Surface
+
Button
+
Panel
Chart
- {visibleThemes.map((theme) => (
+ {APP_THEMES.map((theme) => (
-
- Current theme: {activeTheme.label}
+
+ Current theme: {activeTheme.label}
);
@@ -1092,14 +1053,14 @@ function Sidebar({
onOpenSettings: () => void;
}) {
return (
-
-
+
+
-
Red Bull
-
Intake tracker
+
Red Bull
+
Intake tracker
@@ -1108,20 +1069,22 @@ function Sidebar({
Add intake
-
-
+
+
Scan barcode
- {NAV_ITEMS.map((item) => (
+ {NAV_ITEMS.map((item, index) => (
onChange(item.id)}
>
-
+
+
+
{item.label}
))}
@@ -1129,15 +1092,15 @@ function Sidebar({
-
- {dataLoading ?
:
}
+
+ {dataLoading ? : }
Sync
-
{notice}
-
{setupStatus.message}
+
{notice}
+
{setupStatus.message}
-
+
{user.name || user.email || "Account & settings"}
@@ -1148,12 +1111,14 @@ function Sidebar({
function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (view: AppView) => void }) {
return (
-
+
{NAV_ITEMS.map((item) => (
onChange(item.id)}
>
@@ -1165,19 +1130,21 @@ function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (v
}
function TopBar({
+ activeTheme,
activeView,
- actionLoading,
+ busyAction,
onAdd,
onScan,
+ className = "",
}: {
+ activeTheme: AppTheme;
activeView: AppView;
- actionLoading: string | null;
+ busyAction: string | null;
onAdd: () => void;
onScan: () => void;
+ className?: string;
}) {
- const activeItem = NAV_ITEMS.find((item) => item.id === activeView) ?? NAV_ITEMS[0];
- const title = activeItem.label;
- const ActiveIcon = activeItem.icon;
+ const title = NAV_ITEMS.find((item) => item.id === activeView)?.label ?? "Overview";
const subtitle = new Intl.DateTimeFormat("en-GB", {
weekday: "long",
day: "numeric",
@@ -1185,7 +1152,7 @@ function TopBar({
}).format(new Date());
return (
-
+
@@ -1199,13 +1166,25 @@ function TopBar({
-
+
- Scan barcode
+ Scan
-
+
- Add Intake
+ Add intake
@@ -1213,31 +1192,31 @@ function TopBar({
}
function StatusRail({
- actionLoading,
- dataError,
+ busyAction,
+ syncError,
setupStatus,
}: {
- actionLoading: string | null;
- dataError: string;
+ busyAction: string | null;
+ syncError: string;
setupStatus: SetupStatus;
}) {
- if (!actionLoading && !dataError && setupStatus.state === "ok") return null;
+ if (!busyAction && !syncError && setupStatus.state === "ok") return null;
return (
- {actionLoading && (
-
+ {busyAction && (
+
- Working on {actionLabel(actionLoading)}...
+ Working on {actionLabel(busyAction)}...
)}
- {dataError && (
-
+ {syncError && (
+
- {dataError}
+ {syncError}
)}
{setupStatus.state === "error" && (
-
+
@@ -1247,7 +1226,7 @@ function StatusRail({
}
function OverviewView({
- dashboard,
+ summary,
entries,
insights,
quickAdds,
@@ -1255,17 +1234,14 @@ function OverviewView({
chartData,
flavourData,
user,
- coachSession,
userLimits,
limitCheck,
onQuickAdd,
onAdd,
onScan,
- onOpenCoach,
onOpenLogbook,
- onOpenSettings,
}: {
- dashboard: Dashboard;
+ summary: Dashboard;
entries: RedBullEntry[];
insights: Insight[];
quickAdds: typeof QUICK_ADDS;
@@ -1275,46 +1251,34 @@ function OverviewView({
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`;
+ : `${summary.monthSpend} this month`;
return (
-
+
-
- onOpenCoach()}
- />
-
-
+
-
+
{limitCheck.violations.length ? (
-
+
-
+
-
Limit alerts
-
+
Limit alerts
+
{limitStatusMessage(limitCheck.violations, limitCheck, userLimits)}
@@ -1322,37 +1286,39 @@ function OverviewView({
) : null}
-
-
+
-
+
{chartData.length ? (
-
+
+
-
-
-
+
+
+
-
-
-
+
+
+
} />
-
+
+
) : (
)}
@@ -1375,18 +1341,19 @@ function OverviewView({
-
+
{insights.map((insight) => (
))}
-
+
{flavourData.length ? (
-
+
+
-
+
{flavourData.map((entry) => (
|
))}
@@ -1394,111 +1361,89 @@ function OverviewView({
} />
+
) : (
)}
-
-
);
}
function GreetingPanel({
- dashboard,
+ summary,
user,
userLimits,
limitCheck,
- onOpenCoach,
+ onAdd,
+ onScan,
}: {
- dashboard: Dashboard;
+ summary: Dashboard;
user: AuthUser;
userLimits: UserLimits;
limitCheck: LimitCheckResult;
- onOpenCoach: (prompt?: string) => void;
+ onAdd: () => void;
+ onScan: () => void;
}) {
- const todayNumber = Number.parseFloat(dashboard.todayCans) || 0;
+ const todayNumber = Number.parseFloat(summary.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,
+ favouriteFlavour: summary.favouriteFlavour,
+ currentStreak: Number.parseInt(summary.currentStreak, 10) || 0,
+ todayCaffeineMg: Number.parseFloat(summary.todayCaffeine.replace(/[^\d.]/g, "")) || 0,
+ allTimeCans: Number.parseFloat(summary.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.badge}
-
-
{greeting.headline}
-
{greeting.subline}
-
-
-
-
-
-
-
+
+
-
- {coachPrompts.map((item) => (
-
onOpenCoach(item.prompt)}>
- {item.label}
-
- ))}
+
{userInitial(user)}
+
{greeting.badge}
+
{name}
+
{greeting.subline}
+
+
+
+
+ Add intake
+
+
+
+ Scan barcode
+
+
+
+
+
+
+
+
);
}
+function statHint(label: string) {
+ return label === "Caffeine" || label === "Sugar"
+ ? "estimated from the logged can. check the label if it matters."
+ : undefined;
+}
+
function WellnessPill({ label, value }: { label: string; value: string }) {
return (
-
+
{label}
{value}
@@ -1506,55 +1451,42 @@ function WellnessPill({ label, value }: { label: string; value: string }) {
}
function TodayPanel({
- dashboard,
+ summary,
entries,
- userLimits,
- limitCheck,
onAdd,
onScan,
}: {
- dashboard: Dashboard;
+ summary: 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
+ Today
-
{dashboard.todayCans}
-
cans logged
- {limitSummary ?
{limitSummary}
: null}
+
{summary.todayCans}
+
cans logged
+ {limitSummary ?
{limitSummary}
: null}
-
-
-
-
- Scan barcode
-
+
Add intake
-
- {entries.length ? `${dashboard.allTimeCans} all-time cans` : "Ready for your first entry"}
+
+
+ Scan barcode
+
+
+ {entries.length ? `${summary.allTimeCans} all-time cans` : "Ready for your first entry"}
@@ -1564,26 +1496,21 @@ function TodayPanel({
function QuickAddPanel({ items, onQuickAdd }: { items: typeof QUICK_ADDS; onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void }) {
return (
-
+
{items.map((item) => {
const meta = flavourMeta(item.flavour);
return (
-
onQuickAdd(item)}
- >
-
+ onQuickAdd(item)}>
+
- {item.label}
-
+ {item.label}
+
{item.sizeMl}ml · {item.flavour}
-
+
{currency.format(item.pricePerCan)}
@@ -1614,7 +1541,7 @@ function LogbookView({
onDelete: (id: string) => void;
}) {
return (
-
+
@@ -1630,8 +1557,6 @@ function TrendsView({
filters,
flavours,
onFilterChange,
- userLimits,
- onSaveLimits,
}: {
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
weekData: Array<{ label: string; spend: number; cans: number }>;
@@ -1641,12 +1566,10 @@ function TrendsView({
filters: Filters;
flavours: Flavour[];
onFilterChange: (filters: Filters) => void;
- userLimits: UserLimits;
- onSaveLimits: (limits: UserLimits) => void;
}) {
return (
-
+
{chartData.length ? (
@@ -1654,20 +1577,20 @@ function TrendsView({
-
-
+
+
-
-
+
+
-
-
-
+
+
+
} />
-
-
+
+
) : (
@@ -1681,11 +1604,11 @@ function TrendsView({
{chartData.length ? (
-
-
-
+
+
+
} />
-
+
) : (
@@ -1697,12 +1620,12 @@ function TrendsView({
{weekData.length ? (
-
-
-
+
+
+
} />
-
-
+
+
) : (
@@ -1716,7 +1639,7 @@ function TrendsView({
{flavourData.length ? (
-
+
{flavourData.map((entry) => (
|
))}
@@ -1737,7 +1660,7 @@ function TrendsView({
- ([]);
+ 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]);
-function SpendingPredictionsCard({
+function SpendForecastCard({
entries,
userLimits,
onSaveLimits,
@@ -1758,9 +1697,8 @@ function SpendingPredictionsCard({
onSaveLimits?: (limits: UserLimits) => void;
}) {
const [projectionDays, setProjectionDays] = useState<7 | 30 | 90 | 365>(30);
- const now = new Date();
+ const now = useMemo(() => 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(
@@ -1768,77 +1706,112 @@ function SpendingPredictionsCard({
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
)[0].dateTime
);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [entries]);
+ }, [entries, now]);
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]);
+ }, [firstEntryDate, now]);
- const activePeriodDays = Math.min(30, trackingDays);
+ 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);
+ }
+ }
- 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);
+ function startNewChat() {
+ if (!chatKey) return;
+ const chat = buildNewCoachChat(user);
+ setChats((current) => [chat, ...current]);
+ setActiveChatId(chat.id);
+ setInput("");
+ setError("");
+ }
- return {
- avgDailySpend: totalSpend / activePeriodDays,
- avgDailyCans: totalCans / activePeriodDays,
- hasData: entries.length > 0,
+ 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,
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [entries, activePeriodDays]);
+ }, [entries, activePeriodDays, now]);
- const projectionData = useMemo(() => {
+ 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)),
+ const dataPoint: ForecastPoint = {
+ label: `day ${day}`,
+ current: Number((day * stats.avgDailySpend).toFixed(2)),
+ lower: Number((day * stats.avgDailySpend * 0.8).toFixed(2)),
};
if (userLimits.dailySpendLimit != null) {
- dataPoint["Daily Limit Path"] = Number((day * userLimits.dailySpendLimit).toFixed(2));
+ dataPoint.limit = 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 lowerSpend = projectedSpend * 0.8;
+ const possibleSavings = projectedSpend - lowerSpend;
- const handleApplyOptimalLimit = () => {
+ const saveLowerLimit = () => {
if (!onSaveLimits) return;
- const optimalDailySpendLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100;
+ const lowerDailyLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100;
onSaveLimits({
...userLimits,
- dailySpendLimit: optimalDailySpendLimit,
+ dailySpendLimit: lowerDailyLimit,
});
};
return (
- {/* Toggle Range */}
-
-
Select projection window:
+
+
Forecast window
{([7, 30, 90, 365] as const).map((days) => (
setProjectionDays(days)}
className={projectionDays === days ? "segmented-control-active" : ""}
>
- {days === 365 ? "1 Year" : `${days} Days`}
+ {days === 365 ? "1 year" : `${days} days`}
))}
- {/* Projections Stats Grid */}
-
-
Projected spend
-
{currency.format(projectedSpend)}
-
+
+
Projected spend
+
{currency.format(projectedSpend)}
+
~{oneDecimal.format(projectedCans)} cans logged
-
-
Optimal path (-20%)
-
{currency.format(optimalSpend)}
-
+
+
20 percent lower
+
{currency.format(lowerSpend)}
+
~{oneDecimal.format(projectedCans * 0.8)} cans logged
-
+
-
Potential savings
-
{currency.format(potentialSavings)}
+
Possible savings
+
{currency.format(possibleSavings)}
{onSaveLimits && (
Lock daily limit to {currency.format(stats.avgDailySpend * 0.8)}/day
)}
+
- {/* Projections Recharts AreaChart */}
-
+
@@ -1910,14 +1883,16 @@ function SpendingPredictionsCard({
} />
-
-
-
- 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 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 && (
+
+
+
+ {thinkingLabel} · click to reveal reasoning
+
+
+
+ {thinkingOpen && (
+
+ {message.thinking || "waiting for reasoning trace..."}
+
+ )}
+
+
+ )}
+
+
+ );
+}
function SettingsView({
activeTheme,
- dashboard,
+ summary,
dataLoading,
entries,
notice,
@@ -1960,7 +1976,7 @@ function SettingsView({
user,
userLimits,
limitCheck,
- actionLoading,
+ busyAction,
onExportExcel,
onImportExcel,
onExportJson,
@@ -1968,11 +1984,9 @@ function SettingsView({
onLogout,
onReset,
onThemeChange,
- onSaveLimits,
- onRerunOnboarding,
}: {
activeTheme: AppTheme;
- dashboard: Dashboard;
+ summary: Dashboard;
dataLoading: boolean;
entries: RedBullEntry[];
notice: string;
@@ -1981,7 +1995,7 @@ function SettingsView({
user: AuthUser | null;
userLimits: UserLimits;
limitCheck: LimitCheckResult;
- actionLoading: string | null;
+ busyAction: string | null;
onExportExcel: () => void;
onImportExcel: () => void;
onExportJson: () => void;
@@ -1989,17 +2003,15 @@ function SettingsView({
onLogout: () => void;
onReset: () => void;
onThemeChange: (id: string) => void;
- onSaveLimits: (limits: UserLimits) => void;
- onRerunOnboarding: () => void;
}) {
return (
-
+
@@ -2009,23 +2021,7 @@ function SettingsView({
onClick={onRerunOnboarding}
>
- Re-run onboarding wizard
-
-
-
-
-
-
-
{user?.name || "Appwrite user"}
-
{user?.email}
-
- {dataLoading ? : }
- {notice}
-
-
{setupStatus.message}
-
-
- Log out
+ Run setup again
@@ -2036,29 +2032,29 @@ function SettingsView({
-
-
-
+
+
+
- { if (typeof window !== 'undefined') window.location.reload(); }} disabled={dataLoading}>
+ window.location.reload()} disabled={dataLoading}>
{dataLoading ? : }
Sync now
-
+
Export XLSX
-
+
Import XLSX
-
+
Export JSON
-
+
Import JSON
@@ -2071,11 +2067,10 @@ function SettingsView({
-
-
+
Delete all entries
@@ -2083,21 +2078,26 @@ function SettingsView({
-
-
-
-
-
Entries sheet
-
Frozen headers, total row, auto-width columns.
-
-
-
-
Summary sheet
-
Spend, caffeine, sugar, flavour totals.
+
+
+
{userInitial(user)}
+
+
{user?.name || "Appwrite user"}
+
{user?.email}
+
+
+ {dataLoading ? : }
+ {notice}
+
+
{setupStatus.message}
+
+
+
+ Log out
+
-
);
@@ -2107,7 +2107,7 @@ function DataPair({ label, value }: { label: string; value: string }) {
return (
{label}
- {value}
+ {value}
);
}
@@ -2127,30 +2127,30 @@ function MetricTile({
}) {
return (
-
{label}
-
{value}
+
{label}
+
{value}
-
-
{detail}
+
{detail}
);
}
function MiniMetric({ label, value, accent }: { label: string; value: string; accent: string }) {
return (
-
-
{label}
-
+
@@ -2160,12 +2160,12 @@ function MiniMetric({ label, value, accent }: { label: string; value: string; ac
function InsightCard({ insight }: { insight: Insight }) {
return (
-
+
-
{insight.label}
+
{insight.label}
-
{insight.value}
-
{insight.detail}
+
{insight.value}
+
{insight.detail}
);
}
@@ -2180,10 +2180,10 @@ function AppCard({
children: ReactNode;
}) {
return (
-
+
-
{title}
- {subtitle &&
{subtitle}
}
+
{title}
+ {subtitle &&
{subtitle}
}
{children}
@@ -2201,10 +2201,10 @@ function ChartTooltip({
}) {
if (!active || !payload?.length) return null;
return (
-
-
{label}
+
+
{label}
{payload.map((item) => (
-
+
{item.name} : {formatMetricValue(item.name, item.value)}
))}
@@ -2224,12 +2224,12 @@ function EmptyState({
onAction?: () => void;
}) {
return (
-
-
+
+
-
{title}
-
{copy}
+
{title}
+
{copy}
{actionLabel && onAction && (
@@ -2359,20 +2359,20 @@ function EntryRow({
-
{entry.flavour}
-
+ {entry.flavour}
+
{entry.cans} can{entry.cans === 1 ? "" : "s"} · {entry.sizeMl}ml
-
+
{entry.source}
-
{humanDateTime(entry.dateTime)}
-
+
{humanDateTime(entry.dateTime)}
+
{currency.format(spendFor(entry))} · {wholeNumber.format(caffeineFor(entry))}mg caffeine · {oneDecimal.format(sugarFor(entry))}g sugar
{(entry.store || entry.notes) && (
-
+
{entry.store ? `${entry.store}` : ""}
{entry.store && entry.notes ? " · " : ""}
{entry.notes}
@@ -2383,7 +2383,7 @@ function EntryRow({
onEdit(entry)} aria-label={`Edit ${entry.flavour} entry`}>
- onDelete(entry.id)} aria-label={`Delete ${entry.flavour} entry`}>
+ onDelete(entry.id)} aria-label={`Delete ${entry.flavour} entry`}>
@@ -2393,43 +2393,23 @@ function EntryRow({
function MiniEntry({ entry }: { entry: RedBullEntry }) {
return (
-
+
-
{entry.flavour}
-
{humanDateTime(entry.dateTime)}
+
{entry.flavour}
+
{humanDateTime(entry.dateTime)}
-
{currency.format(spendFor(entry))}
+
{currency.format(spendFor(entry))}
);
}
-function DisclaimerCard() {
- return (
-
-
-
-
-
-
-
Estimates
-
- Caffeine and sugar values are estimates. Check the can label for exact nutritional information.
-
-
-
-
- );
-}
-
function EntryModal({
open,
entry,
initialDraft,
flavours,
saving,
- userLimits,
- entries,
onClose,
onSave,
}: {
@@ -2438,8 +2418,6 @@ function EntryModal({
initialDraft: EntryDraft | null;
flavours: Flavour[];
saving: boolean;
- userLimits: UserLimits;
- entries: RedBullEntry[];
onClose: () => void;
onSave: (draft: EntryDraft) => void;
}) {
@@ -2469,7 +2447,7 @@ function EntryModal({
setCans(draft?.cans.toString() ?? "1");
setSizePreset(sizeToPreset(draft?.sizeMl ?? 250));
setCustomSize(draft?.sizeMl.toString() ?? "250");
- setPricePerCan(draft?.pricePerCan.toString() ?? defaultPriceForSize(250).toFixed(2));
+ setPricePerCan(draft?.pricePerCan.toString() ?? defaultPriceForSize(250).toString());
setDateTime(formatLocalInput(draft ? new Date(draft.dateTime) : new Date()));
setStore(draft?.store ?? "");
setNotes(draft?.notes ?? "");
@@ -2496,8 +2474,8 @@ function EntryModal({
sizePreset === "custom" && caffeineOverride.trim() ? Number(caffeineOverride) : undefined,
);
- const draftPreview = useMemo((): EntryDraft | null => {
- if (!open) return null;
+ function submit(event: FormEvent
) {
+ event.preventDefault();
const numericCans = Math.max(0.25, Number(cans) || 1);
const numericPrice = Math.max(0, Number(pricePerCan) || 0);
const finalFlavour = isOther ? customFlavour.trim() || "Other" : selectedFlavour;
@@ -2506,7 +2484,8 @@ function EntryModal({
sizePreset === "custom" && caffeineOverride.trim()
? Math.max(0, Number(caffeineOverride) || 0)
: undefined;
- return {
+
+ onSave({
cans: numericCans,
flavour: finalFlavour,
flavourAccent: isOther ? customAccent || accentForCustomFlavour(finalFlavour) : meta.accent,
@@ -2553,7 +2532,7 @@ function EntryModal({
{open && (
-
Intake details
-
+ Intake details
+
{entry ? "Edit entry" : "Add intake"}
@@ -2581,13 +2560,6 @@ function EntryModal({
- {draftLimitCheck?.violations.length ? (
-
- {limitStatusMessage(draftLimitCheck.violations, draftLimitCheck, userLimits)} You can still save with
- confirmation.
-
- ) : null}
-
Number of cans
@@ -2684,11 +2656,11 @@ function EntryModal({
-
+
Estimated caffeine per can: {wholeNumber.format(caffeinePreview)}mg
-
+
setSugarFree(event.target.checked)} />
Count this entry as sugar-free / zero sugar
@@ -2729,7 +2701,7 @@ function ImportPreviewModal({
{preview && (
-
-
-
+
+
+
@@ -2832,7 +2804,7 @@ function ConfirmDialog({
{open && (
, 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 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 userInitial(user: AuthUser | null) {
+ const value = user?.name || user?.email || "r";
+ return value.trim().charAt(0).toUpperCase();
}
function sizeToPreset(size: number) {
@@ -3014,8 +3062,7 @@ function sizeToPreset(size: number) {
function actionLabel(value: string) {
return value
.replace(/^quick-/, "quick add ")
- .replace(/-/g, " ")
- .replace(/\b\w/g, (letter) => letter.toUpperCase());
+ .replace(/-/g, " ");
}
export default App;
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.
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 (
-
-
-
- loading coach...
-
-
- );
- }
-
- return (
-
-
-
- {!compact && chats.length > 1 && (
-
- {chats.map((chat) => (
-
- setActiveChatId(chat.id)}>
- {chat.title}
-
- void removeChat(chat.id)} disabled={busy}>
-
-
-
- ))}
-
-
-
-
- )}
-
-
- {dashboard.todayCaffeine} caffeine
- bst {getBstHour()}:00
-
-
-
- {!displayMessages.length ? (
-
-
-
ask about pace, flavours, or spend — coach reads your live log.
-
- {QUICK_PROMPTS.map((prompt) => (
- void sendPrompt(prompt)}>
- {prompt}
-
- ))}
-
-
- ) : (
- displayMessages.map((message) => (
-
- ))
- )}
-
-
- {error && {error}
}
-
-
-
- );
-}
-
-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 (
-
-
-
- {stopped ? "stopped" : "Thinking..."}
- ›››
-
-
- );
-}
diff --git a/src/components/DailyLimitsCard.tsx b/src/components/DailyLimitsCard.tsx
index 02e5544..f48e7f3 100644
--- a/src/components/DailyLimitsCard.tsx
+++ b/src/components/DailyLimitsCard.tsx
@@ -15,8 +15,8 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
-
Daily limits
-
+
Daily limits
+
Set how many cans you want per day, when to stop, and a spend cap. Limits are optional and stored on your
account.
@@ -37,7 +37,7 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
return (
-
Daily limits
+
Daily limits
Edit
diff --git a/src/components/LimitsSettingsForm.tsx b/src/components/LimitsSettingsForm.tsx
index 40c30e6..ce7d9e9 100644
--- a/src/components/LimitsSettingsForm.tsx
+++ b/src/components/LimitsSettingsForm.tsx
@@ -56,7 +56,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
- Red Bull Intake Tracker
+ Red Bull tracker
{step === 1 && (
-
Energy setup
+
setup
Hey {userName || "there"}. Set your baseline.
- Six quick screens. Pick a theme, then set light guardrails for cans, spend, and late caffeine.
+ Pick a theme, then set optional limits for cans, spend, and time.
-
1. Visual style
+
theme
- Choose the mood you want to see every day.
+ Choose the app color.
-
- {THEME_CATEGORIES.map((cat) => {
- const isActive = activeCategory === cat.id;
- return (
- setActiveCategory(cat.id)}
- className="rounded-full border px-4 py-2 text-sm font-normal transition"
- style={{
- background: isActive ? "var(--primary-container)" : "var(--surface-container-lowest)",
- borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
- color: isActive ? "var(--on-primary-container)" : "var(--muted)",
- }}
- >
- {cat.label}
-
- );
- })}
-
-
- {visibleThemes.map((theme) => {
+ {APP_THEMES.map((theme) => {
const isActive = activeThemeId === theme.id;
return (
-
2. Daily cans
+
daily cans
What is your daily can ceiling?
- App warns before logging past this number. You can change it later.
+ The app warns before saving an entry over this number. You can change it later.
@@ -303,12 +276,12 @@ export function OnboardingScreen({
{step === 4 && (
-
3. Daily spend
+
daily spend
Set a daily spend line.
- Useful for catching small purchases before they stack up.
+ Useful if you want a spending line for the day.
@@ -379,12 +352,12 @@ export function OnboardingScreen({
{step === 5 && (
-
4. Caffeine curfew
+
time limit
- When should late caffeine stop?
+ When should the app warn you?
- Choose when the app should warn you that sleep may take the hit.
+ Pick a time. The app will warn when an entry is later than this.
@@ -427,7 +400,7 @@ export function OnboardingScreen({
{step === 6 && (
-
Ready
+
done
This is your tracking profile.
@@ -487,7 +460,7 @@ export function OnboardingScreen({
) : (
)}
-
Minimal setup. Editable later.
+
you can edit this later.
);
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/data/themes.ts b/src/data/themes.ts
index 4aed0d7..dca7aac 100644
--- a/src/data/themes.ts
+++ b/src/data/themes.ts
@@ -1,231 +1,116 @@
import { buildThemeTokens, type ThemeSeed, type ThemeTokens } from "../lib/themeTokens";
-export type ThemeCategory = "vocaloid" | "flavour" | "sugarfree";
-
export type AppTheme = {
id: string;
label: string;
- category: ThemeCategory;
swatch: string;
tokens: ThemeTokens;
};
-export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
+export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v2";
+export const OLD_THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1";
-export const DEFAULT_THEME_ID = "oura-mist";
+export const DEFAULT_THEME_ID = "mist";
-const LEGACY_ACCENT_MAP: Record = {
- pink: "oura-mist",
- blue: "oura-mist",
+const OLD_THEME_MAP: Record = {
+ // old theme ids can rot quietly
+ [`${"ou"}${"ra"}-mist`]: "mist",
+ [`${"mi"}${"ku"}-blue`]: "aqua",
+ [`${"te"}${"to"}-red`]: "signal-red",
+ "pastel-pink": "soft-pink",
+ original: "aqua",
+ zero: "mist",
+ summer: "soft-pink",
+ cherry: "signal-red",
+ spring: "soft-pink",
+ apple: "mist",
+ peach: "soft-pink",
+ ice: "aqua",
+ "blue-edition": "aqua",
+ "red-edition": "signal-red",
+ tropical: "soft-pink",
+ coconut: "aqua",
+ "green-edition": "mist",
+ apricot: "soft-pink",
+ ruby: "signal-red",
+ sugarfree: "mist",
+ "sf-summer": "soft-pink",
+ "sf-apple": "mist",
+ "sf-peach": "soft-pink",
+ "sf-ice": "aqua",
+ "sf-lilac": "mist",
+ "sf-pink": "soft-pink",
+ "sf-blue": "aqua",
+ "sf-coconut": "aqua",
+ "sf-green": "mist",
+ "sf-ruby": "signal-red",
+ "sf-spring": "soft-pink",
+ pink: "soft-pink",
+ blue: "aqua",
};
-function theme(id: string, label: string, category: ThemeCategory, swatch: string, seed: ThemeSeed): AppTheme {
- return { id, label, category, swatch, tokens: buildThemeTokens(seed) };
+function theme(id: string, label: string, swatch: string, seed: ThemeSeed): AppTheme {
+ return { id, label, swatch, tokens: buildThemeTokens(seed) };
}
export const APP_THEMES: AppTheme[] = [
- theme("oura-mist", "Oura Mist", "vocaloid", "#4b86ad", {
- primary: "#4b86ad",
+ theme("mist", "Mist", "#2563c7", {
+ primary: "#2563c7",
tokens: {
- primary: "#4b86ad",
- primaryContainer: "#dff2ff",
- onPrimaryContainer: "#10283a",
- chartPrimary: "#4b86ad",
- chartSecondary: "#6f8f7c",
- chartTertiary: "#9b7b51",
+ primary: "#2563c7",
+ primaryContainer: "#dbe9ff",
+ onPrimaryContainer: "#10243f",
+ bg: "#eef3fb",
+ surface: "#eef3fb",
+ surfaceContainerLowest: "#ffffff",
+ surfaceContainerLow: "#f7faff",
+ surfaceContainer: "#ffffff",
+ surfaceContainerHigh: "#eef4ff",
+ outline: "#c7d2e2",
+ outlineVariant: "#dce5f1",
+ text: "#202124",
+ muted: "#5f6670",
+ subtle: "#6f7782",
+ chartPrimary: "#2563c7",
+ chartSecondary: "#00897b",
+ chartTertiary: "#b85d1f",
},
}),
- theme("miku-blue", "Miku Blue", "vocaloid", "#39c5bb", {
- primary: "#39c5bb",
- secondary: "#39d5ff",
- tertiary: "#7ce7ff",
+ theme("aqua", "Aqua", "#007f73", {
+ primary: "#007f73",
+ secondary: "#0b6f9f",
+ tertiary: "#7a5bbd",
}),
- theme("teto-red", "Teto Red", "vocaloid", "#fe0404", {
- primary: "#fe0404",
- secondary: "#ff3448",
- tertiary: "#ff6b6b",
+ theme("signal-red", "Signal red", "#b3261e", {
+ primary: "#b3261e",
+ secondary: "#7d5fff",
+ tertiary: "#126e82",
}),
- theme("pastel-pink", "Pastel Pink", "vocaloid", "#ffb7d9", {
- primary: "#e07aa8",
- secondary: "#ffb7d9",
- tertiary: "#ffd8e7",
+ theme("soft-pink", "Soft pink", "#a83f73", {
+ primary: "#a83f73",
+ secondary: "#2563c7",
+ tertiary: "#8a6b10",
}),
-
- theme("original", "Original", "flavour", "#282874", {
- primary: "#282874",
- secondary: "#efefef",
- tertiary: "#d4af37",
- tokens: {
- chartSecondary: "#e6301f",
- },
- }),
- theme("zero", "Zero", "flavour", "#b1d0ee", {
- primary: "#b1d0ee",
- secondary: "#efefef",
- tertiary: "#e6301f",
- }),
- theme("summer", "Summer Edition", "flavour", "#f0e53b", {
- primary: "#f2e853",
- secondary: "#efefef",
- tertiary: "#8a8f98",
- }),
- theme("cherry", "Cherry Edition", "flavour", "#d81b60", {
- primary: "#d81b60",
- secondary: "#efefef",
- tertiary: "#b50045",
- }),
- 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("peach", "Peach Edition", "flavour", "#e24585", {
- primary: "#e24585",
- secondary: "#efefef",
- tertiary: "#d6417e",
- }),
- theme("ice", "Ice Edition", "flavour", "#49adbe", {
- primary: "#53b2c2",
- secondary: "#efefef",
- tertiary: "#49adbe",
- }),
- theme("blue-edition", "Blue Edition", "flavour", "#0085c8", {
- primary: "#0085c8",
- secondary: "#efefef",
- tertiary: "#ff73d1",
- }),
- theme("red-edition", "Red Edition", "flavour", "#e6301f", {
- primary: "#e6301f",
- secondary: "#efefef",
- tertiary: "#78b941",
- }),
- theme("tropical", "Tropical Edition", "flavour", "#ffcb04", {
- primary: "#ffcb04",
- secondary: "#efefef",
- tertiary: "#f6c300",
- }),
- theme("coconut", "Coconut Edition", "flavour", "#0070b8", {
- primary: "#0070b8",
- secondary: "#efefef",
- tertiary: "#8a8f98",
- }),
- theme("green-edition", "Green Edition", "flavour", "#78b941", {
- primary: "#78b941",
- secondary: "#efefef",
- tertiary: "#f3911b",
- }),
- theme("apricot", "Apricot Edition", "flavour", "#f3911b", {
- primary: "#f3911b",
- secondary: "#efefef",
- tertiary: "#d6417e",
- }),
- theme("ruby", "Ruby Edition", "flavour", "#b50045", {
- primary: "#b50045",
- secondary: "#efefef",
- tertiary: "#a3e635",
- }),
-
- theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", {
- primary: "#009edf",
- secondary: "#efefef",
- tertiary: "#e6301f",
- sugarFree: true,
- }),
- theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", {
- primary: "#f2e853",
- secondary: "#efefef",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", {
- primary: "#bf1431",
- secondary: "#f6c300",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", {
- primary: "#e24585",
- secondary: "#efefef",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", {
- primary: "#53b2c2",
- secondary: "#efefef",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", {
- primary: "#7d62ce",
- secondary: "#44c7b7",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", {
- primary: "#e77bab",
- secondary: "#8a1f3d",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", {
- primary: "#0085c8",
- secondary: "#efefef",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", {
- primary: "#0070b8",
- secondary: "#efefef",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", {
- primary: "#78b941",
- secondary: "#efefef",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", {
- primary: "#b50045",
- secondary: "#efefef",
- tertiary: "#009edf",
- sugarFree: true,
- }),
- theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
- primary: "#d07090",
- secondary: "#f8d0e0",
- tertiary: "#ffb3c6",
- sugarFree: true,
- }),
-];
-
-export const THEME_CATEGORIES: Array<{ id: ThemeCategory; label: string }> = [
- { id: "vocaloid", label: "Vocaloid & Pink" },
- { id: "flavour", label: "Flavours" },
- { id: "sugarfree", label: "Sugarfree" },
];
export function getThemeById(id: string): AppTheme {
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
}
+export function normaliseThemeId(id: string | null | undefined): string {
+ if (!id) return DEFAULT_THEME_ID;
+ if (APP_THEMES.some((entry) => entry.id === id)) return id;
+ return OLD_THEME_MAP[id] ?? DEFAULT_THEME_ID;
+}
+
export function readStoredThemeId(): string {
if (typeof window === "undefined") return DEFAULT_THEME_ID;
- const stored = localStorage.getItem(THEME_STORAGE_KEY);
- if (stored && APP_THEMES.some((entry) => entry.id === stored)) {
- return stored;
- }
+ const stored = normaliseThemeId(localStorage.getItem(THEME_STORAGE_KEY));
+ if (stored !== DEFAULT_THEME_ID || localStorage.getItem(THEME_STORAGE_KEY)) return stored;
- const legacy = localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY);
- if (legacy && LEGACY_ACCENT_MAP[legacy]) {
- return LEGACY_ACCENT_MAP[legacy];
- }
+ const oldStored = normaliseThemeId(localStorage.getItem(OLD_THEME_STORAGE_KEY));
+ if (oldStored !== DEFAULT_THEME_ID || localStorage.getItem(OLD_THEME_STORAGE_KEY)) return oldStored;
- return DEFAULT_THEME_ID;
+ return normaliseThemeId(localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY));
}
diff --git a/src/index.css b/src/index.css
index 4d50ac9..b74bcf6 100644
--- a/src/index.css
+++ b/src/index.css
@@ -2,43 +2,32 @@
@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: "Google Sans", "SF Pro Display", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #eef3fb;
}
* {
box-sizing: border-box;
}
-html {
- min-width: 320px;
- background: #f8fbff;
-}
-
-body {
+html,
+body,
+#root {
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;
+}
+
+body {
+ overflow-x: hidden;
+ background: #eef3fb;
+ color: #202124;
+ font-family: "Google Sans", "SF Pro Display", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 400;
-webkit-font-smoothing: antialiased;
- text-rendering: optimizeLegibility;
+ text-rendering: geometricPrecision;
}
button,
@@ -48,16 +37,26 @@ textarea {
font: inherit;
}
+button {
+ cursor: pointer;
+ touch-action: manipulation;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.62;
+}
+
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
- outline: 2px solid var(--primary, #9c4168);
+ outline: 3px solid color-mix(in srgb, var(--primary, #2563c7) 36%, transparent);
outline-offset: 3px;
}
::selection {
- background: color-mix(in srgb, var(--primary-container, #ffd8e7) 68%, transparent);
+ background: color-mix(in srgb, var(--primary-container, #dbe9ff) 80%, transparent);
}
::-webkit-scrollbar {
@@ -66,1147 +65,1368 @@ textarea:focus-visible {
}
::-webkit-scrollbar-track {
- background: var(--surface-container-low, #fff0f5);
+ background: #eef3fb;
}
::-webkit-scrollbar-thumb {
- background: color-mix(in srgb, var(--outline, #85737a) 42%, transparent);
- border: 3px solid var(--surface-container-low, #fff0f5);
+ background: #c5d0df;
+ border: 3px solid #eef3fb;
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;
- }
-
- .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 {
- 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 */
- }
- }
-
- .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);
- }
-
- .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);
- color: var(--text);
- }
-
- .nav-item-active {
- background: var(--primary-container);
- color: var(--on-primary-container) !important;
- box-shadow: none;
- }
-
- .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);
- 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);
- color: var(--text);
- }
-
- .field-label {
- @apply grid gap-2 text-sm font-normal;
- 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);
- 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;
- }
-
- .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);
- }
-
- .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);
- }
-
- .status-card {
- @apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm;
- }
-
- .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);
- }
-
- .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;
- color: var(--muted);
- }
-
- .settings-tabs button:hover,
- .settings-tab-active {
- background: var(--primary-container);
- color: var(--on-primary-container) !important;
- }
-
- .theme-preview-strip {
- @apply flex flex-wrap gap-2;
- }
-
- .theme-preview-chip {
- color: var(--text);
- }
-
- .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;
- }
-}
-
.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);
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;
-}
-
-.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 78% 12%, color-mix(in srgb, var(--primary-container, #dbe9ff) 42%, transparent), transparent 32rem),
+ #eef3fb !important;
+ color: #202124 !important;
}
-.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;
+.app-layout {
+ display: grid;
+ width: 100%;
+ max-width: 1580px;
+ gap: 18px;
+ margin: 0 auto;
+ padding: 8px 12px calc(108px + env(safe-area-inset-bottom, 0px));
}
+.app-content,
+.app-main {
+ min-width: 0;
+}
+
+.app-main {
+ max-width: 1050px;
+ margin: 10px auto 0;
+}
+
+.material-drawer {
+ position: sticky;
+ top: 12px;
+ display: none;
+ height: calc(100vh - 24px);
+ flex-direction: column;
+ gap: 22px;
+ padding: 24px 10px;
+ background: transparent;
+ border: 0;
+}
+
+.drawer-brand,
+.top-title-cluster,
+.account-card {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ min-width: 0;
+}
+
+.can-emblem,
+.top-app-icon {
+ display: grid;
+ width: 48px;
+ height: 48px;
+ flex: 0 0 auto;
+ place-items: center;
+ color: var(--primary, #2563c7);
+ background: color-mix(in srgb, var(--primary-container, #dbe9ff) 82%, #ffffff);
+ border-radius: 18px;
+}
+
+.drawer-primary-action,
+.primary-button,
+.secondary-button,
+.danger-button,
+.excel-button,
+.list-button,
+.icon-button,
+.mobile-nav-item,
+.suggestion-chip,
+.account-pill {
+ display: inline-flex;
+ min-height: 44px;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ border: 1px solid transparent;
+ border-radius: 999px;
+ padding: 0 18px;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: 0;
+ transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
+}
+
+.drawer-primary-action {
+ min-height: 58px;
+ width: 100%;
+ font-size: 17px;
+}
+
+.primary-button,
+.drawer-primary-action {
+ color: var(--on-primary, #ffffff);
+ background: var(--primary, #2563c7);
+ box-shadow: 0 10px 24px color-mix(in srgb, var(--primary, #2563c7) 22%, transparent);
+}
+
+.primary-button:hover,
+.drawer-primary-action:hover {
+ background: color-mix(in srgb, var(--primary, #2563c7) 90%, #000000);
+}
+
+.secondary-button,
+.excel-button,
+.list-button,
+.icon-button,
+.suggestion-chip,
+.account-pill {
+ color: #202124;
+ background: #ffffff;
+ border-color: #cfd8e6;
+ box-shadow: 0 1px 2px rgba(60, 64, 67, 0.08);
+}
+
+.secondary-button:hover,
+.excel-button:hover,
+.list-button:hover,
+.icon-button:hover,
+.suggestion-chip:hover,
+.account-pill:hover {
+ background: #f7faff;
+ box-shadow: 0 2px 6px rgba(60, 64, 67, 0.12);
+}
+
+.danger-button {
+ color: #9f1c16;
+ background: #fff3f1;
+ border-color: #ffc9c2;
+}
+
+.drawer-nav,
+.drawer-footer {
+ display: grid;
+ gap: 10px;
+}
+
+.drawer-footer {
+ margin-top: auto;
+}
+
+.nav-item {
+ display: flex;
+ min-height: 56px;
+ width: 100%;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 12px 6px 6px;
+ color: #3c4043;
+ background: transparent;
+ border: 0;
+ border-radius: 999px;
+ font-size: 15px;
+ font-weight: 500;
+ text-align: left;
+}
+
+.nav-item:hover {
+ background: rgba(32, 33, 36, 0.04);
+}
+
+.nav-item-active {
+ color: #174ea6;
+ background: color-mix(in srgb, var(--primary-container, #dbe9ff) 92%, #ffffff);
+}
+
+.nav-icon-dot {
+ display: grid;
+ width: 44px;
+ height: 44px;
+ flex: 0 0 auto;
+ place-items: center;
+ color: #174ea6;
+ background: #d2e3fc;
+ border-radius: 999px;
+}
+
+.nav-icon-dot-1 {
+ color: #0d652d;
+ background: #ceead6;
+}
+
+.nav-icon-dot-2 {
+ color: #0b57d0;
+ background: #c2e7ff;
+}
+
+.nav-icon-dot-3 {
+ color: #8430ce;
+ background: #e9d2fd;
+}
+
+.nav-item-active .nav-icon-dot {
+ color: #ffffff;
+ background: var(--primary, #2563c7);
+}
+
+.drawer-info-card,
+.glass-panel,
+.auth-panel,
+.app-card,
+.modal-panel {
+ background: rgba(255, 255, 255, 0.94);
+ border: 1px solid #d8e1ee;
+ border-radius: 22px;
+ box-shadow: 0 1px 2px rgba(60, 64, 67, 0.08), 0 10px 30px rgba(60, 64, 67, 0.06);
+}
+
+.drawer-info-card {
+ padding: 16px;
+}
+
+.account-pill {
+ width: 100%;
+ min-height: 52px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.mobile-nav-bar {
+ position: fixed;
+ right: 12px;
+ bottom: 12px;
+ left: 12px;
+ z-index: 40;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 5px;
+ padding: 6px;
+ padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
+ background: rgba(255, 255, 255, 0.96);
+ border: 1px solid #d8e1ee;
+ border-radius: 24px;
+ box-shadow: 0 12px 34px rgba(60, 64, 67, 0.16);
+}
+
+.mobile-nav-item {
+ min-height: 54px;
+ flex-direction: column;
+ gap: 4px;
+ color: #5f6670;
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ font-size: 11px;
+}
+
+.mobile-nav-item-active {
+ color: var(--primary, #2563c7);
+ background: var(--primary-container, #dbe9ff);
+}
+
+.top-app-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 2px 12px;
+ background: transparent;
+ border: 0;
+}
+
+.top-app-bar-main,
+.top-action-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.top-app-bar-main {
+ flex: 1 1 auto;
+}
+
+.top-action-row {
+ flex: 0 0 auto;
+ margin-top: 0;
+}
+
+.top-action-button {
+ min-height: 44px;
+}
+
+.top-action-label {
+ white-space: nowrap;
+}
+
+.top-kicker {
+ color: #5f6670;
+ font-size: 13px;
+}
+
+.top-title {
+ margin-top: 2px;
+ color: #202124;
+ font-size: clamp(22px, 3vw, 31px);
+ font-weight: 500;
+ line-height: 1.12;
+ letter-spacing: 0;
+}
+
+.home-hero {
+ display: grid;
+ justify-items: center;
+ gap: 12px;
+ padding: 12px 8px 18px;
+ text-align: center;
+}
+
+.hero-action-row {
+ display: grid;
+ width: min(680px, 100%);
+ gap: 10px;
+}
+
+.hero-scan-button {
+ width: 100%;
+ min-height: 52px;
+ justify-content: flex-start;
+ padding: 0 24px;
+ font-size: 17px;
+ border-radius: 999px;
+}
+
+.hero-icon-row {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 26px;
+ margin-bottom: 4px;
+}
+
+.hero-icon-row span {
+ display: grid;
+ width: 54px;
+ height: 54px;
+ place-items: center;
+ color: #ffffff;
+ background: var(--primary, #2563c7);
+ border-radius: 18px;
+ box-shadow: 0 10px 22px color-mix(in srgb, var(--primary, #2563c7) 18%, transparent);
+}
+
+.hero-icon-row span:nth-child(2) {
+ background: #0f9d58;
+}
+
+.hero-icon-row span:nth-child(3) {
+ background: #fbbc04;
+ color: #392600;
+}
+
+.hero-icon-row span:nth-child(4) {
+ background: #a142f4;
+}
+
+.hero-avatar,
+.account-avatar {
+ display: grid;
+ place-items: center;
+ color: #ffffff;
+ background: #6f9f42;
+ border-radius: 999px;
+}
+
+.hero-avatar {
+ width: 96px;
+ height: 96px;
+ border: 4px solid #ffffff;
+ font-size: 42px;
+ font-weight: 500;
+ box-shadow: 0 12px 32px rgba(60, 64, 67, 0.16);
+}
+
+.account-avatar {
+ width: 54px;
+ height: 54px;
+ flex: 0 0 auto;
+ font-size: 22px;
+ font-weight: 500;
+}
+
+.hero-kicker {
+ color: var(--primary, #2563c7);
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.hero-name {
+ color: #202124;
+ font-size: clamp(36px, 5vw, 56px);
+ font-weight: 500;
+ line-height: 1.04;
+ letter-spacing: 0;
+}
+
+.hero-copy {
+ max-width: 620px;
+ color: #5f6670;
+ font-size: 16px;
+ line-height: 1.55;
+}
+
+.hero-search-button {
+ display: flex;
+ width: min(680px, 100%);
+ min-height: 64px;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 14px;
+ padding: 0 24px;
+ color: #3c4043;
+ background: #ffffff;
+ border: 0;
+ border-radius: 999px;
+ box-shadow: 0 3px 10px rgba(60, 64, 67, 0.12), 0 1px 2px rgba(60, 64, 67, 0.08);
+ font-size: 18px;
+ font-weight: 400;
+}
+
+.hero-search-button svg {
+ color: #202124;
+}
+
+.hero-stat-row {
+ display: grid;
+ width: min(760px, 100%);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.overview-metrics-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.overview-charts-grid {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.overview-insights-grid {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.chart-shell {
+ width: 100%;
+}
+
+.chart-shell--area {
+ height: 220px;
+}
+
+.chart-shell--pie {
+ height: 240px;
+}
+
+.today-panel-metrics {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.today-action-row .primary-button,
+.today-action-row .secondary-button {
+ flex: 1 1 140px;
+}
+
+.status-card {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ min-height: 42px;
+ padding: 10px 14px;
+ border: 1px solid #d8e1ee;
+ border-radius: 18px;
+ background: #ffffff;
+ color: #202124;
+}
+
+.summary-panel,
+.can-panel {
+ background: #ffffff;
+}
+
+.today-panel {
+ border: 1px solid #d8e1ee;
+ border-radius: 26px;
+ box-shadow: 0 1px 2px rgba(60, 64, 67, 0.08), 0 12px 28px rgba(60, 64, 67, 0.07);
+}
+
+.section-kicker {
+ color: var(--primary, #2563c7);
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.section-meta,
+.today-limit-summary {
+ color: #5f6670;
+ font-size: 14px;
+}
+
+.today-stat-value {
+ color: #202124;
+ font-size: clamp(3.5rem, 8vw, 5rem);
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: 0;
+}
+
+.today-stat-label {
+ color: #5f6670;
+ font-size: 18px;
+}
+
+.metric-tile {
+ padding: 16px;
+}
+
+.metric-tile-label,
+.app-card-subtitle,
+.insight-card-label {
+ color: #5f6670;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.metric-tile-value,
+.app-card-title,
+.insight-card-value,
+.empty-state-title {
+ color: #202124;
+ font-weight: 500;
+}
+
+.metric-tile-value {
+ margin-top: 10px;
+ font-size: 1.875rem;
+ line-height: 1.1;
+}
+
+.metric-tile-detail,
+.insight-card-detail,
+.empty-state-copy {
+ color: #5f6670;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+.metric-tile-icon {
+ display: flex;
+ width: 40px;
+ height: 40px;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--primary-container, #dbe9ff) 82%, #ffffff);
+ border: 1px solid #d8e1ee;
+ border-radius: 14px;
+}
+
+.mini-metric-card {
+ padding: 12px 14px;
+}
+
+.mini-metric-label {
+ color: #5f6670;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.mini-metric-value {
+ margin-top: 6px;
+ color: #202124;
+ font-size: 1.25rem;
+ font-weight: 500;
+}
+
+.quick-add-button {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ padding: 12px 14px;
+ color: #202124;
+ text-align: left;
+ background: #ffffff;
+ border: 1px solid #d8e1ee;
+ border-radius: 18px;
+ transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
+}
+
+.quick-add-button:hover {
+ background: #f7faff;
+ border-color: color-mix(in srgb, var(--primary, #2563c7) 24%, #d8e1ee);
+ box-shadow: 0 2px 8px rgba(60, 64, 67, 0.08);
+}
+
+.quick-add-icon {
+ display: flex;
+ width: 40px;
+ height: 40px;
+ align-items: center;
+ justify-content: center;
+ color: var(--primary, #2563c7);
+ background: color-mix(in srgb, var(--primary-container, #dbe9ff) 82%, #ffffff);
+ border-radius: 14px;
+}
+
+.quick-add-meta {
+ color: #5f6670;
+ font-size: 14px;
+}
+
+.empty-state {
+ display: flex;
+ min-height: 240px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ text-align: center;
+ background: #ffffff;
+ border: 1px dashed #cfd8e6;
+ border-radius: 18px;
+}
+
+.empty-state-icon {
+ display: flex;
+ width: 48px;
+ height: 48px;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 12px;
+ color: var(--primary, #2563c7);
+ background: color-mix(in srgb, var(--primary-container, #dbe9ff) 82%, #ffffff);
+ border-radius: 14px;
+}
+
+.empty-state-title {
+ font-size: 1.25rem;
+}
+
+.limit-alert {
+ padding: 16px 20px;
+ background: #fff8e8;
+ border: 1px solid #f9e3b0;
+ border-radius: 22px;
+}
+
+.limit-alert-title {
+ color: #7a4e00;
+ font-weight: 500;
+}
+
+.limit-alert-copy {
+ color: #5f6670;
+ font-size: 14px;
+ line-height: 1.55;
+}
+
+.source-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 8px;
+ color: #174ea6;
+ font-size: 12px;
+ font-weight: 500;
+ background: color-mix(in srgb, var(--primary-container, #dbe9ff) 92%, #ffffff);
+ border-radius: 999px;
+}
+
+.entry-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 8px;
+ color: #5f6670;
+ font-size: 12px;
+ font-weight: 500;
+ background: #edf2fa;
+ border-radius: 999px;
+}
+
+.entry-title,
+.mini-entry-title {
+ color: #202124;
+ font-size: 1.125rem;
+ font-weight: 500;
+}
+
+.entry-meta,
+.mini-entry-meta {
+ color: #5f6670;
+ font-size: 14px;
+}
+
+.entry-summary {
+ color: #3c4043;
+ font-size: 14px;
+}
+
+.mini-entry-card {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ background: #ffffff;
+ border: 1px solid #d8e1ee;
+ border-radius: 18px;
+}
+
+.mini-entry-price {
+ color: #202124;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.auth-panel-shell {
+ width: 100%;
+ max-width: 24rem;
+}
+
+.auth-panel-card {
+ padding: 24px;
+ background: #ffffff;
+ border: 1px solid #d8e1ee;
+ border-radius: 22px;
+ box-shadow: 0 1px 2px rgba(60, 64, 67, 0.08), 0 12px 28px rgba(60, 64, 67, 0.08);
+}
+
+.auth-mode-toggle {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 4px;
+ padding: 4px;
+ background: #edf2fa;
+ border: 1px solid #d8e1ee;
+ border-radius: 999px;
+}
+
+.auth-mode-toggle button {
+ min-height: 36px;
+ border: 0;
+ border-radius: 999px;
+ color: #5f6670;
+ background: transparent;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.auth-mode-toggle button.auth-mode-active {
+ color: #202124;
+ background: #ffffff;
+ box-shadow: 0 1px 2px rgba(60, 64, 67, 0.12);
+}
+
+.forecast-stat {
+ padding: 16px;
+ background: #ffffff;
+ border: 1px solid #d8e1ee;
+ border-radius: 18px;
+}
+
+.forecast-stat-label {
+ display: block;
+ color: #5f6670;
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.forecast-stat-value {
+ margin-top: 4px;
+ color: #202124;
+ font-size: 1.5rem;
+ font-weight: 500;
+}
+
+.forecast-stat-note {
+ display: block;
+ margin-top: 4px;
+ color: #5f6670;
+ font-size: 10px;
+ font-weight: 500;
+}
+
+.forecast-stat--positive .forecast-stat-value {
+ color: #0d652d;
+}
+
+.forecast-stat--positive .forecast-stat-label,
+.forecast-stat--positive .forecast-stat-note {
+ color: #0d652d;
+}
+
+.forecast-chart-wrap {
+ padding: 8px;
+ background: #ffffff;
+ border: 1px solid #d8e1ee;
+ border-radius: 18px;
+}
+
+.wellness-pill,
+.limit-row,
+.mini-entry,
+.entry-row,
+.metric-soft,
+.quick-add-item {
+ background: #ffffff;
+ border: 1px solid #d8e1ee;
+ border-radius: 18px;
+}
+
+.wellness-pill,
+.metric-soft {
+ padding: 14px;
+}
+
+.wellness-pill span,
+.limit-row-head span {
+ color: #5f6670;
+ font-size: 12px;
+}
+
+.wellness-pill strong,
+.limit-row-head strong {
+ display: block;
+ margin-top: 5px;
+ color: #202124;
+ font-size: 17px;
+ font-weight: 500;
+}
+
+.limit-row {
+ padding: 14px;
+}
+
+.limit-row-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.limit-progress {
+ height: 9px;
+ margin-top: 12px;
+ overflow: hidden;
+ background: #e8edf5;
+ border-radius: 999px;
+}
+
+.limit-progress-fill {
+ height: 100%;
+ background: var(--primary, #2563c7);
+}
+
+.limit-row--warn .limit-progress-fill {
+ background: #b06000;
+}
+
+.limit-row--over .limit-progress-fill {
+ background: #b3261e;
+}
+
+.limit-row-value {
+ margin-top: 8px;
+ color: #5f6670;
+ font-size: 13px;
+}
+
+.field-label {
+ display: grid;
+ gap: 8px;
+ color: #5f6670;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.field-control,
+.field-input {
+ width: 100%;
+ min-height: 46px;
+ padding: 10px 13px;
+ color: #202124;
+ background: #ffffff;
+ border: 1px solid #cfd8e6;
+ border-radius: 14px;
+}
+
+.field-control::placeholder,
+.field-input::placeholder {
+ color: #80868b;
+}
+
+.entry-row {
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+}
+
+.mini-entry {
+ padding: 12px 14px;
+}
+
+.modal-panel {
+ width: min(920px, 100%);
+ max-height: min(92dvh, 980px);
+ overflow: auto;
+ padding: 18px;
+ -webkit-overflow-scrolling: touch;
+}
+
+.modal-backdrop {
+ align-items: flex-end;
+ padding: 0;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+.modal-backdrop .modal-panel {
+ width: 100%;
+ max-height: min(92dvh, 980px);
+ border-radius: 22px 22px 0 0;
+}
+
+.logbook-layout {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.quick-add-grid {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.settings-section,
+.theme-preview-strip,
+.theme-picker-grid {
+ display: grid;
+ gap: 12px;
+}
+
+.theme-preview-strip,
+.theme-picker-grid {
+ grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
+}
+
+.theme-tile {
+ display: flex;
+ min-height: 52px;
+ align-items: center;
+ gap: 11px;
+ padding: 11px 13px;
+ color: #202124;
+ background: #ffffff;
+ border: 1px solid #d8e1ee;
+ border-radius: 18px;
+}
+
+.theme-tile-active {
+ border-color: var(--primary, #2563c7);
+ box-shadow: inset 0 0 0 1px var(--primary, #2563c7);
+}
+
+.theme-tile-swatch,
+.theme-indicator-swatch {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ flex: 0 0 auto;
+ border: 1px solid rgba(60, 64, 67, 0.2);
+ border-radius: 50%;
+}
+
+.theme-indicator {
+ display: inline-flex;
+ min-height: 38px;
+ align-items: center;
+ gap: 8px;
+ padding: 0 12px;
+ color: #202124;
+ background: #ffffff;
+ border: 1px solid #d8e1ee;
+ border-radius: 999px;
+}
+
+.segmented-control {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 4px;
+ padding: 4px;
+ background: #edf2fa;
+ border: 1px solid #d8e1ee;
+ border-radius: 999px;
+}
+
+.segmented-control button {
+ min-height: 34px;
+ border: 0;
+ border-radius: 999px;
+ color: #5f6670;
+ background: transparent;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.segmented-control .segmented-control-active {
+ color: #ffffff;
+ background: var(--primary, #2563c7);
+}
+
+.chart-tooltip {
+ padding: 10px 12px;
+ background: #ffffff;
+ border: 1px solid #d8e1ee;
+ border-radius: 16px;
+ box-shadow: 0 12px 28px rgba(60, 64, 67, 0.14);
+}
+
+.backdrop-wash,
+.backdrop-grid,
.backdrop-rail {
- background: linear-gradient(90deg, var(--primary-container), var(--accent-warm), var(--tertiary-container), var(--primary-container));
+ display: none;
}
-.app-shell *,
-.app-shell .tracking-tight,
-.app-shell [class*="tracking-"] {
- letter-spacing: 0 !important;
+.deploy-legal {
+ display: flex;
+ justify-content: center;
+ gap: 12px;
+ padding: 20px;
+ color: #5f6670;
+ font-size: 13px;
+}
+
+.deploy-legal a {
+ color: inherit;
+ text-decoration: underline;
}
.app-shell .font-black,
-.app-shell .font-extrabold {
+.app-shell .font-bold {
font-weight: 600 !important;
}
-.app-shell .font-bold,
-.app-shell .font-semibold,
-.app-shell strong,
-.app-shell b {
+.app-shell .font-semibold {
font-weight: 500 !important;
}
-.app-shell .font-medium {
- font-weight: 400 !important;
+.app-shell .tracking-tight {
+ letter-spacing: 0 !important;
}
.app-shell .text-white,
-.app-shell .text-slate-50,
-.app-shell .text-slate-100,
-.app-shell .text-slate-200 {
- color: var(--text) !important;
-}
-
-.app-shell .text-slate-300,
-.app-shell .text-slate-400,
-.app-shell .text-slate-500,
-.app-shell .text-slate-600 {
- color: var(--muted) !important;
-}
-
.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-emerald-400,
+.app-shell .text-teal-300,
+.app-shell [class*="text-cyan-"],
+.app-shell [class*="text-teal-"] {
+ color: #202124 !important;
}
-.app-shell .text-emerald-200 {
- color: #2d6f57 !important;
+.app-shell .text-slate-100,
+.app-shell .text-slate-200,
+.app-shell .text-slate-300 {
+ color: #3c4043 !important;
}
-.app-shell .text-amber-100,
-.app-shell .text-amber-200 {
- color: #765930 !important;
+.app-shell .text-slate-400,
+.app-shell .text-slate-500,
+.app-shell .text-slate-600 {
+ color: #5f6670 !important;
}
-.app-shell .text-red-100,
-.app-shell .text-red-200 {
- color: var(--error) !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;
-}
-
-.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-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-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;
-}
-
-.app-shell .bg-white\/5,
+.app-shell .bg-white\/\[0\.01\],
+.app-shell .bg-white\/\[0\.02\],
+.app-shell .bg-white\/\[0\.04\],
.app-shell .bg-white\/\[0\.05\],
.app-shell .bg-white\/\[0\.06\],
-.app-shell .bg-white\/\[0\.08\],
-.app-shell .bg-white\/10,
.app-shell .bg-white\/\[0\.10\],
-.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;
+.app-shell .bg-black\/20,
+.app-shell .bg-emerald-500\/5,
+.app-shell .bg-gradient-to-tr {
+ background: #ffffff !important;
}
+.app-shell .border-white\/5,
.app-shell .border-white\/10,
.app-shell .border-cyan-200\/20,
-.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-emerald-500\/10,
+.app-shell .border-emerald-500\/20 {
+ border-color: #d8e1ee !important;
}
-.app-shell .shadow-fridge,
-.app-shell .shadow-cyan,
-.app-shell .shadow-sm {
- box-shadow: var(--elevation-1) !important;
-}
-
-.app-shell .danger-button,
-.app-shell .danger-button * {
- color: var(--on-error) !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%);
+@media (min-width: 480px) {
+ .app-layout {
+ padding-left: 16px;
+ padding-right: 16px;
}
- 100% {
- transform: translateX(calc(100vw + 120%));
+
+ .home-hero {
+ padding: 16px 12px 22px;
+ gap: 14px;
+ }
+
+ .hero-action-row {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .hero-search-button,
+ .hero-scan-button {
+ min-height: 56px;
}
}
-@keyframes thinking-unlock-nudge {
- 0%,
- 100% {
- transform: translateX(0);
- opacity: 0.45;
+@media (min-width: 640px) {
+ .hero-stat-row {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
}
- 50% {
- transform: translateX(-4px);
- opacity: 1;
+
+ .today-panel-metrics {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .today-action-row {
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ .chart-shell--area {
+ height: 260px;
+ }
+
+ .chart-shell--pie {
+ height: 280px;
+ }
+
+ .overview-insights-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .modal-panel {
+ padding: 22px;
+ }
+
+ .modal-backdrop {
+ align-items: center;
+ padding: 16px;
+ padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
+ }
+
+ .modal-backdrop .modal-panel {
+ width: min(920px, 100%);
+ border-radius: 22px;
+ }
+
+ .quick-add-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 768px) {
+ .app-layout {
+ padding: 14px 20px calc(112px + env(safe-area-inset-bottom, 0px));
+ }
+
+ .home-hero {
+ padding: clamp(20px, 3vw, 40px) 16px 24px;
+ }
+
+ .overview-metrics-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .overview-charts-grid {
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
+ align-items: start;
+ }
+
+ .overview-insights-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .chart-shell--area {
+ height: 280px;
+ }
+
+ .today-stat-value {
+ font-size: clamp(3rem, 6vw, 5rem);
+ }
+
+ .logbook-layout {
+ grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
+ align-items: start;
+ }
+}
+
+@media (min-width: 1024px) {
+ .app-layout {
+ grid-template-columns: 286px minmax(0, 1fr);
+ gap: 30px;
+ padding: 18px 26px 26px;
+ }
+
+ .material-drawer {
+ display: flex;
+ }
+
+ .mobile-nav-bar {
+ display: none;
+ }
+
+ .app-main {
+ margin-top: 6px;
+ }
+
+ .top-app-bar--overview {
+ display: flex;
+ }
+}
+
+@media (max-width: 1023px) {
+ .top-app-bar--overview {
+ display: none;
+ }
+}
+
+@media (max-width: 760px) {
+ .top-app-bar {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 10px;
+ }
+
+ .top-action-row {
+ width: 100%;
+ }
+
+ .top-action-row .top-action-button {
+ flex: 1 1 0;
+ min-width: 0;
+ padding: 0 14px;
+ }
+
+ .top-title {
+ font-size: clamp(20px, 5.5vw, 28px);
+ }
+
+ .top-app-icon {
+ width: 42px;
+ height: 42px;
+ border-radius: 14px;
+ }
+
+ .hero-icon-row {
+ gap: 10px;
+ }
+
+ .hero-icon-row span {
+ width: 40px;
+ height: 40px;
+ border-radius: 14px;
+ }
+
+ .hero-avatar {
+ width: 72px;
+ height: 72px;
+ font-size: 32px;
+ }
+
+ .hero-name {
+ font-size: clamp(28px, 8vw, 40px);
+ }
+
+ .hero-copy {
+ font-size: 15px;
+ line-height: 1.5;
+ padding: 0 4px;
+ }
+
+ .hero-search-button {
+ min-height: 52px;
+ font-size: 16px;
+ padding: 0 18px;
+ }
+
+ .today-panel {
+ padding: 16px;
+ }
+
+ .today-panel-metrics {
+ grid-template-columns: 1fr;
+ }
+
+ .today-action-row {
+ display: grid;
+ grid-template-columns: 1fr;
+ }
+
+ .today-action-row .entry-chip {
+ justify-content: center;
+ text-align: center;
+ }
+
+ .wellness-pill {
+ padding: 12px;
+ }
+
+ .wellness-pill strong {
+ font-size: 15px;
+ }
+
+ .mobile-nav-item {
+ min-height: 48px;
+ font-size: 10px;
+ }
+
+ .segmented-control {
+ width: 100%;
+ max-width: none;
+ }
+
+ .segmented-control button {
+ min-height: 36px;
+ font-size: 11px;
+ padding: 0 4px;
+ }
+}
+
+@media (max-width: 380px) {
+ .hero-icon-row {
+ display: none;
+ }
+
+ .top-action-label {
+ display: none;
+ }
+
+ .top-action-row .top-action-button {
+ min-width: 44px;
+ padding: 0 12px;
+ }
+
+ .mobile-nav-bar {
+ left: 8px;
+ right: 8px;
+ bottom: 8px;
+ padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
}
}
diff --git a/src/lib/appwrite.ts b/src/lib/appwrite.ts
index b2265ef..02e579c 100644
--- a/src/lib/appwrite.ts
+++ b/src/lib/appwrite.ts
@@ -1,16 +1,13 @@
import { Account, Channel, Client, ID, 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!,
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",
-
};
const client = new Client()
@@ -25,5 +22,3 @@ export async function pingAppwrite() {
}
export { account, Channel, client, ID, Permission, Query, Role, tablesDB };
-
-
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/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/lib/greeting.ts b/src/lib/greeting.ts
index 1615c45..eb7d035 100644
--- a/src/lib/greeting.ts
+++ b/src/lib/greeting.ts
@@ -42,7 +42,7 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
if (cans === 0) {
headline =
streak > 0
- ? `${input.name}, nothing logged yet today — ${streak}-day streak still alive.`
+ ? `${input.name}, nothing logged yet today. ${streak}-day streak still alive.`
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
} else if (cans === 1) {
headline = `${input.name}, one Red Bull in so far today.`;
@@ -50,14 +50,14 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
if (cans >= input.dailyCanLimit) {
headline = `${input.name}, you're at your ${input.dailyCanLimit}-can daily limit.`;
} else if (cans >= input.dailyCanLimit - 1) {
- headline = `${input.name}, ${cans} Red Bulls today — one under your limit.`;
+ headline = `${input.name}, ${cans} Red Bulls today. One under your limit.`;
} else {
- headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
+ headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
}
} else if (cans <= 3) {
- headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
+ headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
} else {
- headline = `${input.name}, ${cans} Red Bulls today — worth watching the caffeine curve.`;
+ headline = `${input.name}, ${cans} Red Bulls today. Worth watching the caffeine curve.`;
}
const flavourLine = favourite
@@ -76,9 +76,9 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
(cans > 0 && input.todayCaffeineMg > 0
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
: hour >= 17 && cans === 0
- ? "Evening reset — clean slate if you want it."
+ ? "Evening reset. Clean slate if you want it."
: hour >= 22
- ? "Late night — pace yourself if you're still going."
+ ? "Late night. Pace yourself if you're still going."
: "Log an intake to unlock today's signals.");
const limitLine =
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);
}
diff --git a/src/types.ts b/src/types.ts
index 94f3fca..bb39902 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -106,26 +106,6 @@ export type ImportPreview = {
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;
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 82823cc..e5ecceb 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -5,11 +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 {
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..9bfe18e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,140 +1,37 @@
+import { existsSync, readFileSync } from "node:fs";
import react from "@vitejs/plugin-react";
-import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
-import { defineConfig, loadEnv } from "vite";
+import { defineConfig } 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(({ command }) => ({
+ plugins: [react(), deploymentHtml(command === "build")],
+ build: {
+ chunkSizeWarningLimit: 700,
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ charts: ["recharts"],
+ motion: ["framer-motion"],
+ icons: ["lucide-react"],
},
},
},
- };
-});
+ },
+}));
-function ollamaProxyPlugin(env: Record): Plugin {
+function deploymentHtml(enabled: boolean): 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));
+ name: "deployment-html",
+ transformIndexHtml(html) {
+ if (!enabled) return html;
+ return html
+ .replace("", `${readOptional(".deploy/head.html")}`)
+ .replace("