Merge pull request #1 from nh9961/ui-redo
UI redo + remove broken OAuth
This commit is contained in:
+1
-10
@@ -2,16 +2,7 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
|
|||||||
VITE_APPWRITE_PROJECT_ID=your-project-id
|
VITE_APPWRITE_PROJECT_ID=your-project-id
|
||||||
VITE_APPWRITE_DATABASE_ID=redbull_tracker
|
VITE_APPWRITE_DATABASE_ID=redbull_tracker
|
||||||
VITE_APPWRITE_COLLECTION_ID=intake_entries
|
VITE_APPWRITE_COLLECTION_ID=intake_entries
|
||||||
VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats
|
VITE_APPWRITE_BARCODE_COLLECTION_ID=barcode_products
|
||||||
|
|
||||||
|
|
||||||
# Server-only. Do not prefix with VITE_ or it will be exposed to the browser.
|
|
||||||
OLLAMA_API_KEY=
|
|
||||||
OLLAMA_MODEL=deepseek-v4-pro:cloud
|
|
||||||
VITE_OLLAMA_PROXY_URL=/api/ollama-chat
|
|
||||||
|
|
||||||
# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite.
|
# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite.
|
||||||
APPWRITE_API_KEY=
|
APPWRITE_API_KEY=
|
||||||
|
|
||||||
# Appwrite chat table columns: userId, title, messages, updatedAt.
|
|
||||||
# Enable row security and Users -> Create at table level.
|
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
.deploy/
|
||||||
|
public/*.html
|
||||||
|
|||||||
+52
-197
@@ -1,221 +1,76 @@
|
|||||||
# Red Bull Intake Tracker Setup
|
# Red Bull tracker setup
|
||||||
|
|
||||||
## Commands
|
This app uses Appwrite for auth and intake entries.
|
||||||
|
|
||||||
```bash
|
## env
|
||||||
npm install
|
|
||||||
npm run dev
|
Copy `.env.example` to `.env.local`, then fill in:
|
||||||
npm run build
|
|
||||||
npm run lint
|
```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
|
```sh
|
||||||
cp .env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
This app uses only the Appwrite browser SDK. Do not add an API key to the frontend.
|
|
||||||
|
|
||||||
To create/update the database tables from this repo, set a server/admin key as `APPWRITE_API_KEY` in `.env.local` and run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run setup:appwrite
|
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`
|
Rows use per-user read, update, and delete permissions.
|
||||||
- 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`
|
|
||||||
|
|
||||||
`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
|
- `user_date_desc`: `userId`, `dateTime`
|
||||||
- GitHub OAuth
|
- `user_import_key`: `userId`, `importKey`
|
||||||
- Google OAuth
|
|
||||||
|
|
||||||
Add a Web platform in Appwrite Console for local development:
|
## run
|
||||||
|
|
||||||
- Hostname: `localhost`
|
```sh
|
||||||
- Hostname: `127.0.0.1`
|
npm install
|
||||||
|
npm run dev
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a collection with ID:
|
## deployment-only files
|
||||||
|
|
||||||
```text
|
The repo ignores `.deploy/` and local public HTML pages.
|
||||||
intake_entries
|
|
||||||
```
|
|
||||||
|
|
||||||
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`
|
Vite injects the optional `.deploy` snippets into `index.html` at build time.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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) : {};
|
|
||||||
}
|
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A local-first Red Bull intake web app for tracking cans, spending, caffeine, sugar, flavours, and trends."
|
content="A local-first Red Bull intake web app for tracking cans, spending, caffeine, sugar, flavours, and trends."
|
||||||
|
|||||||
Generated
+1
@@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "red-bull-intake-tracker",
|
"name": "red-bull-intake-tracker",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@zxing/browser": "^0.2.0",
|
"@zxing/browser": "^0.2.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "red-bull-intake-tracker",
|
"name": "red-bull-intake-tracker",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+16
-54
@@ -9,7 +9,6 @@ const endpoint = readEnv("VITE_APPWRITE_ENDPOINT", "https://fra.cloud.appwrite.i
|
|||||||
const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138");
|
const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138");
|
||||||
const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
|
const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
|
||||||
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
|
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
|
||||||
const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats");
|
|
||||||
const barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products");
|
const barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products");
|
||||||
const apiKey = readEnv("APPWRITE_API_KEY", "");
|
const apiKey = readEnv("APPWRITE_API_KEY", "");
|
||||||
const verifiedBarcodeProducts = JSON.parse(
|
const verifiedBarcodeProducts = JSON.parse(
|
||||||
@@ -44,32 +43,9 @@ await ensureTable({
|
|||||||
{ key: "user_import_key", type: "key", columns: ["userId", "importKey"], orders: ["ASC", "ASC"], lengths: [32, 128] },
|
{ key: "user_import_key", type: "key", columns: ["userId", "importKey"], orders: ["ASC", "ASC"], lengths: [32, 128] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await ensureTable({
|
|
||||||
tableId: chatTableId,
|
|
||||||
name: "Coach chats",
|
|
||||||
columns: [
|
|
||||||
{ kind: "string", key: "userId", size: 64, required: true },
|
|
||||||
{ kind: "string", key: "title", size: 512, required: true },
|
|
||||||
{ kind: "longtext", key: "messages", required: true },
|
|
||||||
{ kind: "datetime", key: "updatedAt", required: true },
|
|
||||||
],
|
|
||||||
indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }],
|
|
||||||
});
|
|
||||||
await retireLegacyChatColumns(chatTableId, [
|
|
||||||
"encryptedTitle",
|
|
||||||
"encryptedMessages",
|
|
||||||
"titleIv",
|
|
||||||
"messagesIv",
|
|
||||||
"salt",
|
|
||||||
"version",
|
|
||||||
]);
|
|
||||||
await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]);
|
|
||||||
await ensureTable({
|
await ensureTable({
|
||||||
tableId: barcodeTableId,
|
tableId: barcodeTableId,
|
||||||
name: "Barcode products",
|
name: "Barcode products",
|
||||||
// Schema notes:
|
|
||||||
// - scope="verified" rows are seeded by this admin script and readable by signed-in users.
|
|
||||||
// - scope="user" rows are created by the browser SDK with per-user row permissions.
|
|
||||||
columns: [
|
columns: [
|
||||||
{ kind: "string", key: "scope", size: 16, required: true },
|
{ kind: "string", key: "scope", size: 16, required: true },
|
||||||
{ kind: "string", key: "ownerUserId", size: 64, required: false },
|
{ kind: "string", key: "ownerUserId", size: 64, required: false },
|
||||||
@@ -154,41 +130,11 @@ async function ensureColumn(tableId, column) {
|
|||||||
array: false,
|
array: false,
|
||||||
};
|
};
|
||||||
if (column.size) body.size = column.size;
|
if (column.size) body.size = column.size;
|
||||||
if (column.encrypt) body.encrypt = true;
|
|
||||||
|
|
||||||
await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]);
|
await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]);
|
||||||
console.log(`Column ${tableId}.${column.key} created.`);
|
console.log(`Column ${tableId}.${column.key} created.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retireLegacyChatColumns(tableId, keys) {
|
|
||||||
for (const key of keys) {
|
|
||||||
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]);
|
|
||||||
if (existing.status === 404) {
|
|
||||||
console.log(`Legacy column ${tableId}.${key} already removed.`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await request("DELETE", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [204, 404]);
|
|
||||||
console.log(`Legacy column ${tableId}.${key} removed.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureIndex(tableId, index) {
|
|
||||||
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]);
|
|
||||||
if (existing.status === 200) {
|
|
||||||
console.log(`Index ${tableId}.${index.key} exists.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await request(
|
|
||||||
"POST",
|
|
||||||
`/tablesdb/${databaseId}/tables/${tableId}/indexes`,
|
|
||||||
{ key: index.key, type: index.type, columns: index.columns, orders: index.orders, lengths: index.lengths },
|
|
||||||
[202, 201],
|
|
||||||
);
|
|
||||||
console.log(`Index ${tableId}.${index.key} created.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedVerifiedBarcodeProducts(tableId, products) {
|
async function seedVerifiedBarcodeProducts(tableId, products) {
|
||||||
for (const [barcode, product] of Object.entries(products)) {
|
for (const [barcode, product] of Object.entries(products)) {
|
||||||
const rowId = `verified_${barcode}`;
|
const rowId = `verified_${barcode}`;
|
||||||
@@ -226,6 +172,22 @@ async function seedVerifiedBarcodeProducts(tableId, products) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureIndex(tableId, index) {
|
||||||
|
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]);
|
||||||
|
if (existing.status === 200) {
|
||||||
|
console.log(`Index ${tableId}.${index.key} exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await request(
|
||||||
|
"POST",
|
||||||
|
`/tablesdb/${databaseId}/tables/${tableId}/indexes`,
|
||||||
|
{ key: index.key, type: index.type, columns: index.columns, orders: index.orders, lengths: index.lengths },
|
||||||
|
[202, 201],
|
||||||
|
);
|
||||||
|
console.log(`Index ${tableId}.${index.key} created.`);
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForColumns(tableId, keys) {
|
async function waitForColumns(tableId, keys) {
|
||||||
const pending = new Set(keys);
|
const pending = new Set(keys);
|
||||||
for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) {
|
for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) {
|
||||||
|
|||||||
+689
-642
File diff suppressed because it is too large
Load Diff
@@ -301,7 +301,7 @@ export function BarcodeScannerModal({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3 backdrop-blur-xl sm:p-4"
|
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/70 backdrop-blur-xl"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -318,11 +318,11 @@ export function BarcodeScannerModal({
|
|||||||
>
|
>
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Camera scan</p>
|
<p className="section-kicker">Camera scan</p>
|
||||||
<h2 id="barcode-scanner-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
|
<h2 id="barcode-scanner-title" className="app-card-title mt-1 text-3xl">
|
||||||
Scan barcode
|
Scan barcode
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-slate-300">Point your camera at the barcode on the can.</p>
|
<p className="app-card-subtitle mt-2">Point your camera at the barcode on the can.</p>
|
||||||
</div>
|
</div>
|
||||||
<button ref={closeButtonRef} className="icon-button" type="button" onClick={onClose} aria-label="Close barcode scanner">
|
<button ref={closeButtonRef} className="icon-button" type="button" onClick={onClose} aria-label="Close barcode scanner">
|
||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
|
|||||||
@@ -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<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
await sendPrompt(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!storageReady) {
|
|
||||||
return (
|
|
||||||
<section className="coach-panel glass-panel p-5">
|
|
||||||
<div className="flex items-center gap-3 text-sm" style={{ color: "var(--muted)" }}>
|
|
||||||
<Loader2 className="animate-spin" size={18} aria-hidden="true" />
|
|
||||||
loading coach...
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={`coach-panel glass-panel ${compact ? "coach-panel-compact" : "coach-panel-full"}`}>
|
|
||||||
<header className="coach-panel-header">
|
|
||||||
<div className="coach-panel-title">
|
|
||||||
<div className="coach-panel-icon">
|
|
||||||
<Brain size={18} aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="coach-panel-kicker">coach</p>
|
|
||||||
<h3 className="coach-panel-heading">
|
|
||||||
{dashboard.todayCans} cans today · {dashboard.favouriteFlavour}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="coach-panel-meta">
|
|
||||||
<span className="coach-status-pill">
|
|
||||||
<span className={`coach-status-dot ${busy ? "coach-status-dot-busy" : ""}`} />
|
|
||||||
{busy ? "thinking" : storageStatus}
|
|
||||||
</span>
|
|
||||||
{!compact && <span className="coach-model-tag">{OLLAMA_MODEL}</span>}
|
|
||||||
{compact && onExpand && (
|
|
||||||
<button className="coach-expand-button" type="button" onClick={onExpand}>
|
|
||||||
open
|
|
||||||
<ChevronRight size={14} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{!compact && chats.length > 1 && (
|
|
||||||
<div className="coach-thread-strip">
|
|
||||||
{chats.map((chat) => (
|
|
||||||
<div key={chat.id} className={`coach-thread-chip ${chat.id === activeChatId ? "coach-thread-chip-active" : ""}`}>
|
|
||||||
<button type="button" onClick={() => setActiveChatId(chat.id)}>
|
|
||||||
{chat.title}
|
|
||||||
</button>
|
|
||||||
<button type="button" aria-label={`delete ${chat.title}`} onClick={() => void removeChat(chat.id)} disabled={busy}>
|
|
||||||
<Trash2 size={12} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button className="coach-thread-new" type="button" onClick={startNewChat} disabled={busy}>
|
|
||||||
<Plus size={14} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="coach-panel-context">
|
|
||||||
<span>{dashboard.todayCaffeine} caffeine</span>
|
|
||||||
<span>bst {getBstHour()}:00</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`coach-panel-feed ${compact ? "coach-panel-feed-compact" : ""}`} aria-live="polite">
|
|
||||||
{!displayMessages.length ? (
|
|
||||||
<div className="coach-panel-empty">
|
|
||||||
<Sparkles size={20} aria-hidden="true" />
|
|
||||||
<p>ask about pace, flavours, or spend — coach reads your live log.</p>
|
|
||||||
<div className="coach-quick-grid">
|
|
||||||
{QUICK_PROMPTS.map((prompt) => (
|
|
||||||
<button key={prompt} className="suggestion-chip" type="button" disabled={busy} onClick={() => void sendPrompt(prompt)}>
|
|
||||||
{prompt}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
displayMessages.map((message) => (
|
|
||||||
<CoachLine key={message.id} message={message} userInitials={userInitials} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="coach-panel-error">{error}</p>}
|
|
||||||
|
|
||||||
<form className="coach-panel-composer" onSubmit={submit}>
|
|
||||||
{!compact && (
|
|
||||||
<button className="icon-button" type="button" onClick={startNewChat} disabled={busy} aria-label="new chat">
|
|
||||||
<Plus size={16} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
className="field-control coach-panel-input"
|
|
||||||
value={input}
|
|
||||||
onChange={(event) => setInput(event.target.value)}
|
|
||||||
placeholder="ask coach anything..."
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
{busy ? (
|
|
||||||
<button className="icon-button" type="button" onClick={stopThinking} aria-label="stop">
|
|
||||||
<Square size={16} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="primary-button coach-panel-send" type="submit" disabled={!input.trim()} aria-label="send">
|
|
||||||
<Send size={16} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CoachLine({ message, userInitials }: { message: CoachMessage; userInitials: string }) {
|
|
||||||
const isAssistant = message.role === "assistant";
|
|
||||||
const isThinking = isAssistant && message.pending && !message.content.trim();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className={`coach-line ${isAssistant ? "coach-line-assistant" : "coach-line-user"}`}>
|
|
||||||
<span className="coach-line-avatar">{isAssistant ? <Brain size={14} /> : userInitials}</span>
|
|
||||||
<div className="coach-line-body">
|
|
||||||
{isThinking && <ThinkingPill stopped={message.stopped} />}
|
|
||||||
{message.content ? <p>{message.content}</p> : !isThinking ? <span className="coach-line-typing">...</span> : null}
|
|
||||||
{isAssistant && !message.pending && message.thinking?.trim() ? (
|
|
||||||
<details className="thinking-details">
|
|
||||||
<summary>reasoning</summary>
|
|
||||||
<pre className="thinking-trace">{message.thinking}</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThinkingPill({ stopped }: { stopped?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className={`thinking-pill ${stopped ? "thinking-pill-stopped" : ""}`} aria-live="polite">
|
|
||||||
<div className="thinking-pill-track">
|
|
||||||
<span className="thinking-pill-shimmer" aria-hidden="true" />
|
|
||||||
<span className="thinking-pill-label">{stopped ? "stopped" : "Thinking..."}</span>
|
|
||||||
<span className="thinking-pill-chevron" aria-hidden="true">›››</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,8 +15,8 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
|
|||||||
<section className="limits-card glass-panel p-5 sm:p-6">
|
<section className="limits-card glass-panel p-5 sm:p-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Daily limits</p>
|
<p className="section-kicker">Daily limits</p>
|
||||||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-400">
|
<p className="section-meta mt-2 max-w-xl leading-6">
|
||||||
Set how many cans you want per day, when to stop, and a spend cap. Limits are optional and stored on your
|
Set how many cans you want per day, when to stop, and a spend cap. Limits are optional and stored on your
|
||||||
account.
|
account.
|
||||||
</p>
|
</p>
|
||||||
@@ -37,7 +37,7 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
|
|||||||
return (
|
return (
|
||||||
<section className="limits-card glass-panel p-5 sm:p-6">
|
<section className="limits-card glass-panel p-5 sm:p-6">
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Daily limits</p>
|
<p className="section-kicker">Daily limits</p>
|
||||||
<button className="list-button !min-h-9 !px-3 !py-1.5 text-xs" type="button" onClick={onOpenSettings}>
|
<button className="list-button !min-h-9 !px-3 !py-1.5 text-xs" type="button" onClick={onOpenSettings}>
|
||||||
<Settings2 size={14} aria-hidden="true" />
|
<Settings2 size={14} aria-hidden="true" />
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
<form className="grid gap-4" onSubmit={submit}>
|
<form className="grid gap-4" onSubmit={submit}>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<label className="grid gap-2 text-sm">
|
<label className="grid gap-2 text-sm">
|
||||||
<span className="font-medium text-slate-300">Cans per day</span>
|
<span className="font-medium text-slate-700">Cans per day</span>
|
||||||
<input
|
<input
|
||||||
className="field-input"
|
className="field-input"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -70,7 +70,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm">
|
<label className="grid gap-2 text-sm">
|
||||||
<span className="font-medium text-slate-300">Spend per day (£)</span>
|
<span className="font-medium text-slate-700">Spend per day (£)</span>
|
||||||
<input
|
<input
|
||||||
className="field-input"
|
className="field-input"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -85,7 +85,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm sm:max-w-xs">
|
<label className="grid gap-2 text-sm sm:max-w-xs">
|
||||||
<span className="font-medium text-slate-300">Stop drinking by</span>
|
<span className="font-medium text-slate-700">Stop drinking by</span>
|
||||||
<input
|
<input
|
||||||
className="field-input"
|
className="field-input"
|
||||||
type="time"
|
type="time"
|
||||||
@@ -96,7 +96,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{previewParts.length ? (
|
{previewParts.length ? (
|
||||||
<p className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-300">
|
<p className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
|
||||||
Today so far: {previewParts.join(" · ")}
|
Today so far: {previewParts.join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ArrowRight, Check, ChevronLeft } from "lucide-react";
|
import { ArrowRight, Check, ChevronLeft } from "lucide-react";
|
||||||
import { APP_THEMES, THEME_CATEGORIES, type ThemeCategory } from "../data/themes";
|
import { APP_THEMES } from "../data/themes";
|
||||||
import { currency } from "../lib/metrics";
|
import { currency } from "../lib/metrics";
|
||||||
import type { UserLimits } from "../types";
|
import type { UserLimits } from "../types";
|
||||||
|
|
||||||
@@ -33,12 +33,6 @@ export function OnboardingScreen({
|
|||||||
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5);
|
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5);
|
||||||
const [stopTime, setStopTime] = useState<string | "none">("18:00");
|
const [stopTime, setStopTime] = useState<string | "none">("18:00");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [activeCategory, setActiveCategory] = useState<ThemeCategory>("flavour");
|
|
||||||
|
|
||||||
const visibleThemes = useMemo(() => {
|
|
||||||
return APP_THEMES.filter((theme) => theme.category === activeCategory);
|
|
||||||
}, [activeCategory]);
|
|
||||||
|
|
||||||
const activeTheme = useMemo(() => {
|
const activeTheme = useMemo(() => {
|
||||||
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
||||||
}, [activeThemeId]);
|
}, [activeThemeId]);
|
||||||
@@ -56,7 +50,7 @@ export function OnboardingScreen({
|
|||||||
await onSave(limits, activeThemeId);
|
await onSave(limits, activeThemeId);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save onboarding preferences", err);
|
console.error("setup save failed", err);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -117,7 +111,7 @@ export function OnboardingScreen({
|
|||||||
className="pointer-events-none absolute inset-0 opacity-60"
|
className="pointer-events-none absolute inset-0 opacity-60"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"radial-gradient(circle at 76% 20%, color-mix(in srgb, var(--primary-container) 62%, transparent) 0 22%, transparent 44%), radial-gradient(circle at 12% 84%, color-mix(in srgb, var(--tertiary-container) 48%, transparent) 0 18%, transparent 42%)",
|
"linear-gradient(180deg, color-mix(in srgb, var(--primary-container) 24%, transparent), transparent 36%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -127,22 +121,22 @@ export function OnboardingScreen({
|
|||||||
<div className="h-full rounded-full bg-[var(--primary)] transition-all duration-500" style={{ width: progress }} />
|
<div className="h-full rounded-full bg-[var(--primary)] transition-all duration-500" style={{ width: progress }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-normal uppercase tracking-[0.18em] text-[var(--muted)]">
|
<p className="text-xs font-normal uppercase tracking-[0.18em] text-[var(--muted)]">
|
||||||
Question {step} of {STEP_COUNT}
|
step {step} of {STEP_COUNT}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="hidden text-xs font-normal text-[var(--muted)] sm:block">Red Bull Intake Tracker</p>
|
<p className="hidden text-xs font-normal text-[var(--muted)] sm:block">Red Bull tracker</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="relative z-10 mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center py-10 sm:py-16">
|
<main className="relative z-10 mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center py-10 sm:py-16">
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<section className="grid gap-9">
|
<section className="grid gap-9">
|
||||||
<div className="grid gap-5">
|
<div className="grid gap-5">
|
||||||
<p className="text-sm font-normal text-[var(--primary)]">Energy setup</p>
|
<p className="text-sm font-normal text-[var(--primary)]">setup</p>
|
||||||
<h1 className="max-w-2xl text-5xl font-normal leading-[0.95] tracking-[-0.055em] sm:text-7xl">
|
<h1 className="max-w-2xl text-5xl font-normal leading-[0.95] tracking-[-0.055em] sm:text-7xl">
|
||||||
Hey {userName || "there"}. Set your baseline.
|
Hey {userName || "there"}. Set your baseline.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-xl text-lg font-normal leading-8 text-[var(--muted)]">
|
<p className="max-w-xl text-lg font-normal leading-8 text-[var(--muted)]">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -160,35 +154,14 @@ export function OnboardingScreen({
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<section className="grid gap-8">
|
<section className="grid gap-8">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<p className="text-sm font-normal text-[var(--primary)]">1. Visual style</p>
|
<p className="text-sm font-normal text-[var(--primary)]">theme</p>
|
||||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||||
Choose the mood you want to see every day.
|
Choose the app color.
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{THEME_CATEGORIES.map((cat) => {
|
|
||||||
const isActive = activeCategory === cat.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={cat.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => 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}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid max-h-[48vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
<div className="grid max-h-[48vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||||
{visibleThemes.map((theme) => {
|
{APP_THEMES.map((theme) => {
|
||||||
const isActive = activeThemeId === theme.id;
|
const isActive = activeThemeId === theme.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -227,12 +200,12 @@ export function OnboardingScreen({
|
|||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<section className="grid gap-9">
|
<section className="grid gap-9">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<p className="text-sm font-normal text-[var(--primary)]">2. Daily cans</p>
|
<p className="text-sm font-normal text-[var(--primary)]">daily cans</p>
|
||||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||||
What is your daily can ceiling?
|
What is your daily can ceiling?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -303,12 +276,12 @@ export function OnboardingScreen({
|
|||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<section className="grid gap-9">
|
<section className="grid gap-9">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<p className="text-sm font-normal text-[var(--primary)]">3. Daily spend</p>
|
<p className="text-sm font-normal text-[var(--primary)]">daily spend</p>
|
||||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||||
Set a daily spend line.
|
Set a daily spend line.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
||||||
Useful for catching small purchases before they stack up.
|
Useful if you want a spending line for the day.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -379,12 +352,12 @@ export function OnboardingScreen({
|
|||||||
{step === 5 && (
|
{step === 5 && (
|
||||||
<section className="grid gap-8">
|
<section className="grid gap-8">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<p className="text-sm font-normal text-[var(--primary)]">4. Caffeine curfew</p>
|
<p className="text-sm font-normal text-[var(--primary)]">time limit</p>
|
||||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||||
When should late caffeine stop?
|
When should the app warn you?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -427,7 +400,7 @@ export function OnboardingScreen({
|
|||||||
{step === 6 && (
|
{step === 6 && (
|
||||||
<section className="grid gap-8">
|
<section className="grid gap-8">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<p className="text-sm font-normal text-[var(--primary)]">Ready</p>
|
<p className="text-sm font-normal text-[var(--primary)]">done</p>
|
||||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||||
This is your tracking profile.
|
This is your tracking profile.
|
||||||
</h2>
|
</h2>
|
||||||
@@ -487,7 +460,7 @@ export function OnboardingScreen({
|
|||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
<p className="text-xs font-normal text-[var(--muted)]">Minimal setup. Editable later.</p>
|
<p className="text-xs font-normal text-[var(--muted)]">you can edit this later.</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-17
@@ -1,25 +1,20 @@
|
|||||||
import type { Flavour } from "../types";
|
import type { Flavour } from "../types";
|
||||||
|
|
||||||
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
||||||
{ name: "Original", accent: "#282874" },
|
{ name: "Original", accent: "#00A7FF" },
|
||||||
{ name: "Zero", accent: "#B1D0EE", sugarFree: true },
|
{ name: "Sugar Free", accent: "#E7EEF8", sugarFree: true },
|
||||||
{ name: "Sugar Free", accent: "#009EDF", sugarFree: true },
|
{ name: "Ruby", accent: "#C3093B" },
|
||||||
{ name: "Ruby", accent: "#B50045" },
|
{ name: "Iced Vanilla", accent: "#49adbe" },
|
||||||
{ name: "Iced Vanilla", accent: "#53B2C2" },
|
{ name: "Tropical", accent: "#FFC247" },
|
||||||
{ name: "Tropical", accent: "#FFCB04" },
|
{ name: "Watermelon", accent: "#FF355E" },
|
||||||
{ 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: "Blueberry", accent: "#496DFF" },
|
{ name: "Blueberry", accent: "#496DFF" },
|
||||||
{ name: "Coconut Berry", accent: "#0070B8" },
|
{ name: "Coconut Berry", accent: "#D8F9FF" },
|
||||||
{ name: "Peach", accent: "#E24585" },
|
{ name: "Peach", accent: "#FF9B63" },
|
||||||
{ name: "Juneberry", accent: "#0085C8" },
|
{ name: "Juneberry", accent: "#9C73FF" },
|
||||||
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
||||||
{ name: "Curuba Elderflower", accent: "#78B941" },
|
{ name: "Curuba Elderflower", accent: "#B7FF4A" },
|
||||||
{ name: "Winter Edition", accent: "#BF1431" },
|
{ name: "Winter Edition", accent: "#7CE7FF" },
|
||||||
{ name: "Summer Edition", accent: "#F2E853" },
|
{ name: "Summer Edition", accent: "#f0e53b" },
|
||||||
{ name: "Other", accent: "#AEB9C7" },
|
{ name: "Other", accent: "#AEB9C7" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+82
-197
@@ -1,231 +1,116 @@
|
|||||||
import { buildThemeTokens, type ThemeSeed, type ThemeTokens } from "../lib/themeTokens";
|
import { buildThemeTokens, type ThemeSeed, type ThemeTokens } from "../lib/themeTokens";
|
||||||
|
|
||||||
export type ThemeCategory = "vocaloid" | "flavour" | "sugarfree";
|
|
||||||
|
|
||||||
export type AppTheme = {
|
export type AppTheme = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
category: ThemeCategory;
|
|
||||||
swatch: string;
|
swatch: string;
|
||||||
tokens: ThemeTokens;
|
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 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<string, string> = {
|
const OLD_THEME_MAP: Record<string, string> = {
|
||||||
pink: "oura-mist",
|
// old theme ids can rot quietly
|
||||||
blue: "oura-mist",
|
[`${"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 {
|
function theme(id: string, label: string, swatch: string, seed: ThemeSeed): AppTheme {
|
||||||
return { id, label, category, swatch, tokens: buildThemeTokens(seed) };
|
return { id, label, swatch, tokens: buildThemeTokens(seed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_THEMES: AppTheme[] = [
|
export const APP_THEMES: AppTheme[] = [
|
||||||
theme("oura-mist", "Oura Mist", "vocaloid", "#4b86ad", {
|
theme("mist", "Mist", "#2563c7", {
|
||||||
primary: "#4b86ad",
|
primary: "#2563c7",
|
||||||
tokens: {
|
tokens: {
|
||||||
primary: "#4b86ad",
|
primary: "#2563c7",
|
||||||
primaryContainer: "#dff2ff",
|
primaryContainer: "#dbe9ff",
|
||||||
onPrimaryContainer: "#10283a",
|
onPrimaryContainer: "#10243f",
|
||||||
chartPrimary: "#4b86ad",
|
bg: "#eef3fb",
|
||||||
chartSecondary: "#6f8f7c",
|
surface: "#eef3fb",
|
||||||
chartTertiary: "#9b7b51",
|
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", {
|
theme("aqua", "Aqua", "#007f73", {
|
||||||
primary: "#39c5bb",
|
primary: "#007f73",
|
||||||
secondary: "#39d5ff",
|
secondary: "#0b6f9f",
|
||||||
tertiary: "#7ce7ff",
|
tertiary: "#7a5bbd",
|
||||||
}),
|
}),
|
||||||
theme("teto-red", "Teto Red", "vocaloid", "#fe0404", {
|
theme("signal-red", "Signal red", "#b3261e", {
|
||||||
primary: "#fe0404",
|
primary: "#b3261e",
|
||||||
secondary: "#ff3448",
|
secondary: "#7d5fff",
|
||||||
tertiary: "#ff6b6b",
|
tertiary: "#126e82",
|
||||||
}),
|
}),
|
||||||
theme("pastel-pink", "Pastel Pink", "vocaloid", "#ffb7d9", {
|
theme("soft-pink", "Soft pink", "#a83f73", {
|
||||||
primary: "#e07aa8",
|
primary: "#a83f73",
|
||||||
secondary: "#ffb7d9",
|
secondary: "#2563c7",
|
||||||
tertiary: "#ffd8e7",
|
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 {
|
export function getThemeById(id: string): AppTheme {
|
||||||
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
|
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 {
|
export function readStoredThemeId(): string {
|
||||||
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
||||||
|
|
||||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
const stored = normaliseThemeId(localStorage.getItem(THEME_STORAGE_KEY));
|
||||||
if (stored && APP_THEMES.some((entry) => entry.id === stored)) {
|
if (stored !== DEFAULT_THEME_ID || localStorage.getItem(THEME_STORAGE_KEY)) return stored;
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacy = localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY);
|
const oldStored = normaliseThemeId(localStorage.getItem(OLD_THEME_STORAGE_KEY));
|
||||||
if (legacy && LEGACY_ACCENT_MAP[legacy]) {
|
if (oldStored !== DEFAULT_THEME_ID || localStorage.getItem(OLD_THEME_STORAGE_KEY)) return oldStored;
|
||||||
return LEGACY_ACCENT_MAP[legacy];
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_THEME_ID;
|
return normaliseThemeId(localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY));
|
||||||
}
|
}
|
||||||
|
|||||||
+1314
-1094
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,13 @@
|
|||||||
import { Account, Channel, Client, ID, Permission, Query, Role, TablesDB } from "appwrite";
|
import { Account, Channel, Client, ID, Permission, Query, Role, TablesDB } from "appwrite";
|
||||||
|
|
||||||
const env = import.meta.env;
|
const env = import.meta.env;
|
||||||
const currentOrigin = window.location.origin;
|
|
||||||
|
|
||||||
export const appwriteConfig = {
|
export const appwriteConfig = {
|
||||||
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
|
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
|
||||||
projectId: env.VITE_APPWRITE_PROJECT_ID!,
|
projectId: env.VITE_APPWRITE_PROJECT_ID!,
|
||||||
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
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",
|
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = new Client()
|
const client = new Client()
|
||||||
@@ -25,5 +22,3 @@ export async function pingAppwrite() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { account, Channel, client, ID, Permission, Query, Role, tablesDB };
|
export { account, Channel, client, ID, Permission, Query, Role, tablesDB };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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<CoachChatRow>({
|
|
||||||
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<CoachChatRow>({
|
|
||||||
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<CoachChatRow>({
|
|
||||||
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)];
|
|
||||||
}
|
|
||||||
@@ -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<EncryptedChatRow>({
|
||||||
|
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<EncryptedChatRow>({
|
||||||
|
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<EncryptedChatRow>({
|
||||||
|
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<CoachChat> {
|
||||||
|
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<EncryptedValue> {
|
||||||
|
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)];
|
||||||
|
}
|
||||||
+7
-7
@@ -42,7 +42,7 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
|||||||
if (cans === 0) {
|
if (cans === 0) {
|
||||||
headline =
|
headline =
|
||||||
streak > 0
|
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"}.`;
|
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
|
||||||
} else if (cans === 1) {
|
} else if (cans === 1) {
|
||||||
headline = `${input.name}, one Red Bull in so far today.`;
|
headline = `${input.name}, one Red Bull in so far today.`;
|
||||||
@@ -50,14 +50,14 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
|||||||
if (cans >= input.dailyCanLimit) {
|
if (cans >= input.dailyCanLimit) {
|
||||||
headline = `${input.name}, you're at your ${input.dailyCanLimit}-can daily limit.`;
|
headline = `${input.name}, you're at your ${input.dailyCanLimit}-can daily limit.`;
|
||||||
} else if (cans >= input.dailyCanLimit - 1) {
|
} 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 {
|
} else {
|
||||||
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
|
||||||
}
|
}
|
||||||
} else if (cans <= 3) {
|
} else if (cans <= 3) {
|
||||||
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
|
||||||
} else {
|
} 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
|
const flavourLine = favourite
|
||||||
@@ -76,9 +76,9 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
|||||||
(cans > 0 && input.todayCaffeineMg > 0
|
(cans > 0 && input.todayCaffeineMg > 0
|
||||||
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
|
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
|
||||||
: hour >= 17 && cans === 0
|
: hour >= 17 && cans === 0
|
||||||
? "Evening reset — clean slate if you want it."
|
? "Evening reset. Clean slate if you want it."
|
||||||
: hour >= 22
|
: 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.");
|
: "Log an intake to unlock today's signals.");
|
||||||
|
|
||||||
const limitLine =
|
const limitLine =
|
||||||
|
|||||||
@@ -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<Models.Preferences>;
|
|
||||||
|
|
||||||
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<typeof useCoachSession>;
|
|
||||||
|
|
||||||
export function useCoachSession(
|
|
||||||
user: AuthUser,
|
|
||||||
dashboard: Dashboard,
|
|
||||||
entries: RedBullEntry[],
|
|
||||||
userLimits: UserLimits = {},
|
|
||||||
limitCheck?: LimitCheckResult,
|
|
||||||
) {
|
|
||||||
const [chats, setChats] = useState<CoachChat[]>([]);
|
|
||||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
|
||||||
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => 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<AbortController | null>(null);
|
|
||||||
const queuedPromptRef = useRef<string | null>(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<CoachMessage>) => {
|
|
||||||
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<CoachMessage>): 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<Uint8Array>, 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 };
|
|
||||||
@@ -178,25 +178,6 @@ export function limitStatusMessage(
|
|||||||
return lines.join(" ");
|
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) {
|
export function hasAnyLimit(limits: UserLimits) {
|
||||||
return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime);
|
return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,26 +106,6 @@ export type ImportPreview = {
|
|||||||
rows: ImportPreviewRow[];
|
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 = {
|
export type UserLimits = {
|
||||||
dailyCanLimit?: number;
|
dailyCanLimit?: number;
|
||||||
dailySpendLimit?: number;
|
dailySpendLimit?: number;
|
||||||
|
|||||||
Vendored
-2
@@ -5,11 +5,9 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||||
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
||||||
readonly VITE_APPWRITE_COLLECTION_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_BARCODE_COLLECTION_ID?: string;
|
||||||
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
|
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
|
||||||
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
||||||
readonly VITE_OLLAMA_PROXY_URL?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
+12
-13
@@ -6,21 +6,20 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
display: [
|
display: [
|
||||||
"Google Sans",
|
"SF Pro Display",
|
||||||
"Google Sans Text",
|
"SF Pro Text",
|
||||||
"Product Sans",
|
|
||||||
"Roboto",
|
|
||||||
"-apple-system",
|
"-apple-system",
|
||||||
"BlinkMacSystemFont",
|
"BlinkMacSystemFont",
|
||||||
|
"Avenir Next",
|
||||||
|
"Helvetica Neue",
|
||||||
"sans-serif",
|
"sans-serif",
|
||||||
],
|
],
|
||||||
body: [
|
body: [
|
||||||
"Google Sans",
|
"SF Pro Text",
|
||||||
"Google Sans Text",
|
|
||||||
"Product Sans",
|
|
||||||
"Roboto",
|
|
||||||
"-apple-system",
|
"-apple-system",
|
||||||
"BlinkMacSystemFont",
|
"BlinkMacSystemFont",
|
||||||
|
"Avenir Next",
|
||||||
|
"Helvetica Neue",
|
||||||
"sans-serif",
|
"sans-serif",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -39,11 +38,11 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
apple: "0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08)",
|
apple: "0 18px 55px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18)",
|
||||||
fridge: "0 2px 6px rgba(69, 54, 62, 0.12), 0 8px 18px rgba(69, 54, 62, 0.08)",
|
fridge: "0 18px 70px rgba(0, 0, 0, 0.34), 0 1px 2px rgba(255, 255, 255, 0.06)",
|
||||||
can: "0 1px 2px rgba(156, 65, 104, 0.18), 0 3px 8px rgba(156, 65, 104, 0.10)",
|
can: "0 10px 24px rgba(57, 213, 255, 0.12)",
|
||||||
redline: "0 2px 8px rgba(186, 26, 26, 0.20)",
|
redline: "0 12px 28px rgba(255, 52, 72, 0.26)",
|
||||||
cyan: "0 1px 2px rgba(156, 65, 104, 0.16), 0 4px 12px rgba(156, 65, 104, 0.10)",
|
cyan: "0 14px 32px rgba(57, 213, 255, 0.18)",
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
"carbon-grid":
|
"carbon-grid":
|
||||||
|
|||||||
+15
-118
@@ -1,37 +1,10 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
|
export default defineConfig(({ command }) => ({
|
||||||
|
plugins: [react(), deploymentHtml(command === "build")],
|
||||||
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: {
|
build: {
|
||||||
chunkSizeWarningLimit: 700,
|
chunkSizeWarningLimit: 700,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
@@ -44,97 +17,21 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
function ollamaProxyPlugin(env: Record<string, string>): Plugin {
|
function deploymentHtml(enabled: boolean): Plugin {
|
||||||
return {
|
return {
|
||||||
name: "ollama-proxy",
|
name: "deployment-html",
|
||||||
configureServer(server) {
|
transformIndexHtml(html) {
|
||||||
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
if (!enabled) return html;
|
||||||
},
|
return html
|
||||||
configurePreviewServer(server) {
|
.replace("</head>", `${readOptional(".deploy/head.html")}</head>`)
|
||||||
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
.replace("</body>", `${readOptional(".deploy/body-end.html")}</body>`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOllamaHandler(env: Record<string, string>) {
|
function readOptional(path: string) {
|
||||||
return (req: IncomingMessage, res: ServerResponse) => {
|
if (!existsSync(path)) return "";
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
return `\n${readFileSync(path, "utf8").trim()}\n`;
|
||||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
res.statusCode = 204;
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
res.statusCode = 405;
|
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
res.end("Method not allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void handleOllamaProxy(req, res, env);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOllamaProxy(req: IncomingMessage, res: ServerResponse, env: Record<string, string>) {
|
|
||||||
const apiKey = env.OLLAMA_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
res.end("OLLAMA_API_KEY is not configured on the server.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await readJsonBody(req);
|
|
||||||
const upstream = await fetch("https://ollama.com/api/chat", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
...payload,
|
|
||||||
model: payload.model || env.OLLAMA_MODEL || DEFAULT_MODEL,
|
|
||||||
stream: payload.stream !== false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
res.statusCode = upstream.status;
|
|
||||||
res.setHeader("Content-Type", upstream.headers.get("content-type") || "application/x-ndjson");
|
|
||||||
|
|
||||||
if (!upstream.ok) {
|
|
||||||
res.end(await upstream.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!upstream.body) {
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = upstream.body.getReader();
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
res.write(Buffer.from(value));
|
|
||||||
}
|
|
||||||
res.end();
|
|
||||||
} catch (error) {
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
res.end(error instanceof Error ? error.message : "Ollama proxy failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonBody(req: IncomingMessage) {
|
|
||||||
let raw = "";
|
|
||||||
for await (const chunk of req) raw += chunk;
|
|
||||||
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user