Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4a8bbb1df | |||
| aa1bf1b21f | |||
| 64584315e5 | |||
| 4e5fa5d42e | |||
| eaef89f484 | |||
| b57240136a | |||
| 94a10ecec4 | |||
| c22945cd4e | |||
| 23801f79d4 | |||
| 7bc92a92d7 | |||
| add2586cb1 | |||
| cbdd98e133 | |||
| ea8b10a81f | |||
| cb375adbd6 | |||
| 4c7d719e02 | |||
| 08372febfe | |||
| bd3e970286 |
+2
-11
@@ -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
+29
-39
@@ -7,7 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "red-bull-intake-tracker",
|
"name": "red-bull-intake-tracker",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@undecaf/barcode-detector-polyfill": "^0.9.23",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@zxing/browser": "^0.2.0",
|
"@zxing/browser": "^0.2.0",
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
@@ -1159,9 +1161,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1175,9 +1174,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1191,9 +1187,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1207,9 +1200,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1223,9 +1213,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1239,9 +1226,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1255,9 +1239,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1271,9 +1252,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1287,9 +1265,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1303,9 +1278,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1319,9 +1291,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1335,9 +1304,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1351,9 +1317,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1879,6 +1842,24 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@undecaf/barcode-detector-polyfill": {
|
||||||
|
"version": "0.9.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@undecaf/barcode-detector-polyfill/-/barcode-detector-polyfill-0.9.23.tgz",
|
||||||
|
"integrity": "sha512-qVr7jSUbE5a30X9dByDym2NzsqyH+MFwyFiu4QSHDQMLCImTJj/et7pEcOtGqlL4UB5J6J3d0hK4/5d4MMowYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@undecaf/zbar-wasm": "^0.9.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@undecaf/zbar-wasm": {
|
||||||
|
"version": "0.9.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@undecaf/zbar-wasm/-/zbar-wasm-0.9.16.tgz",
|
||||||
|
"integrity": "sha512-T5PcT6g+tLScGjR4WmnRErNvfKqEc3kRg2ux14wHmIDNbvNeXa0BkFK19PRK/jb6zGy5NyWtn4ko6KeNuZc/fQ==",
|
||||||
|
"license": "LGPL-2.1+",
|
||||||
|
"dependencies": {
|
||||||
|
"jschardet": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -3621,6 +3602,15 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jschardet": {
|
||||||
|
"version": "3.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.1.4.tgz",
|
||||||
|
"integrity": "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg==",
|
||||||
|
"license": "LGPL-2.1+",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.90"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
"setup:appwrite": "node scripts/setup-appwrite.mjs"
|
"setup:appwrite": "node scripts/setup-appwrite.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@undecaf/barcode-detector-polyfill": "^0.9.23",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@zxing/browser": "^0.2.0",
|
"@zxing/browser": "^0.2.0",
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
|
|||||||
+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) {
|
||||||
|
|||||||
+481
-607
File diff suppressed because it is too large
Load Diff
@@ -191,24 +191,32 @@ export function BarcodeScannerModal({
|
|||||||
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
|
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
const video = videoRef.current;
|
let frameId = 0;
|
||||||
if (!video) return undefined;
|
|
||||||
|
|
||||||
void startBarcodeScanner(video, handleScannerResult, handleScannerError)
|
const startScanner = () => {
|
||||||
.then((controller) => {
|
const video = videoRef.current;
|
||||||
if (!active) {
|
if (!video || !active) return;
|
||||||
controller.stop();
|
|
||||||
return;
|
void startBarcodeScanner(video, handleScannerResult, handleScannerError)
|
||||||
}
|
.then((controller) => {
|
||||||
controllerRef.current = controller;
|
if (!active) {
|
||||||
setScannerMode(controller.mode);
|
controller.stop();
|
||||||
setPhase("scanning");
|
return;
|
||||||
})
|
}
|
||||||
.catch((error: BarcodeScannerError) => {
|
controllerRef.current = controller;
|
||||||
if (!active) return;
|
setScannerMode(controller.mode);
|
||||||
setScannerError(error);
|
setPhase("scanning");
|
||||||
setPhase("error");
|
})
|
||||||
});
|
.catch((error: BarcodeScannerError) => {
|
||||||
|
if (!active) return;
|
||||||
|
setScannerError(error);
|
||||||
|
setPhase("error");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
frameId = window.requestAnimationFrame(() => {
|
||||||
|
window.requestAnimationFrame(startScanner);
|
||||||
|
});
|
||||||
|
|
||||||
void listBarcodeCatalog()
|
void listBarcodeCatalog()
|
||||||
.then((catalog) => {
|
.then((catalog) => {
|
||||||
@@ -224,6 +232,7 @@ export function BarcodeScannerModal({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
stopScanner();
|
stopScanner();
|
||||||
};
|
};
|
||||||
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
|
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
|
||||||
@@ -301,7 +310,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 +327,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" />
|
||||||
@@ -335,6 +344,7 @@ export function BarcodeScannerModal({
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="aspect-[3/4] w-full bg-black object-cover sm:aspect-video"
|
className="aspect-[3/4] w-full bg-black object-cover sm:aspect-video"
|
||||||
|
autoPlay
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
aria-label="Live camera preview"
|
aria-label="Live camera preview"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const TERMS_SUMMARY =
|
||||||
|
"By using track.9961.one you agree to use the tracker responsibly, keep your login secure, and accept that intake data is stored in your Appwrite account under your control. This applies only to track.9961.one.";
|
||||||
|
|
||||||
|
const PRIVACY_SUMMARY =
|
||||||
|
"track.9961.one stores intake logs and preferences in Appwrite, tied to your account. Coach chats can be encrypted client-side before upload. We do not sell your data. This policy applies only to track.9961.one.";
|
||||||
|
|
||||||
|
type LegalFootnoteProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegalFootnote({ className = "" }: LegalFootnoteProps) {
|
||||||
|
return (
|
||||||
|
<footer className={`legal-footnote ${className}`.trim()} aria-label="Legal notices for track.9961.one">
|
||||||
|
<span className="legal-footnote-site">track.9961.one</span>
|
||||||
|
<span className="legal-footnote-links">
|
||||||
|
<span className="legal-tooltip-wrap">
|
||||||
|
<button className="legal-tooltip-trigger" type="button" aria-describedby="legal-terms-tip">
|
||||||
|
Terms
|
||||||
|
</button>
|
||||||
|
<span className="legal-tooltip" id="legal-terms-tip" role="tooltip">
|
||||||
|
{TERMS_SUMMARY}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="legal-tooltip-wrap">
|
||||||
|
<button className="legal-tooltip-trigger" type="button" aria-describedby="legal-privacy-tip">
|
||||||
|
Privacy
|
||||||
|
</button>
|
||||||
|
<span className="legal-tooltip" id="legal-privacy-tip" role="tooltip">
|
||||||
|
{PRIVACY_SUMMARY}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
+1441
-1114
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
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",
|
||||||
@@ -10,7 +9,6 @@ export const appwriteConfig = {
|
|||||||
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",
|
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 +23,3 @@ export async function pingAppwrite() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { account, Channel, client, ID, Permission, Query, Role, tablesDB };
|
export { account, Channel, client, ID, Permission, Query, Role, tablesDB };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
type BarcodeDetectorConstructor = {
|
||||||
|
new (options?: { formats?: string[] }): {
|
||||||
|
detect: (source: ImageBitmapSource) => Promise<Array<{ rawValue?: string; format?: string }>>;
|
||||||
|
};
|
||||||
|
getSupportedFormats?: () => Promise<string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WindowWithBarcodeDetector = Window & {
|
||||||
|
BarcodeDetector?: BarcodeDetectorConstructor;
|
||||||
|
};
|
||||||
|
|
||||||
|
let detectorReady: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function isAppleMobileDevice() {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
return /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBarcodeDetectorPolyfill() {
|
||||||
|
const { BarcodeDetectorPolyfill } = await import("@undecaf/barcode-detector-polyfill");
|
||||||
|
return BarcodeDetectorPolyfill;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureBarcodeDetector() {
|
||||||
|
if (detectorReady) return detectorReady;
|
||||||
|
|
||||||
|
detectorReady = (async () => {
|
||||||
|
const globalWindow = window as WindowWithBarcodeDetector;
|
||||||
|
const shouldForcePolyfill = isAppleMobileDevice();
|
||||||
|
|
||||||
|
if (shouldForcePolyfill) {
|
||||||
|
globalWindow.BarcodeDetector = await loadBarcodeDetectorPolyfill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const getSupportedFormats = globalWindow.BarcodeDetector?.getSupportedFormats;
|
||||||
|
if (!getSupportedFormats) return;
|
||||||
|
await getSupportedFormats.call(globalWindow.BarcodeDetector);
|
||||||
|
} catch {
|
||||||
|
globalWindow.BarcodeDetector = await loadBarcodeDetectorPolyfill();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return detectorReady;
|
||||||
|
}
|
||||||
+152
-27
@@ -4,6 +4,7 @@ import {
|
|||||||
BrowserMultiFormatReader,
|
BrowserMultiFormatReader,
|
||||||
type IScannerControls,
|
type IScannerControls,
|
||||||
} from "@zxing/browser";
|
} from "@zxing/browser";
|
||||||
|
import { ensureBarcodeDetector, isAppleMobileDevice } from "./barcodeDetectorSupport";
|
||||||
import { normalizeBarcode } from "./barcodeLookup";
|
import { normalizeBarcode } from "./barcodeLookup";
|
||||||
|
|
||||||
export type BarcodeScannerErrorCode =
|
export type BarcodeScannerErrorCode =
|
||||||
@@ -54,7 +55,7 @@ const ZXING_FORMATS = [
|
|||||||
BarcodeFormat.UPC_A,
|
BarcodeFormat.UPC_A,
|
||||||
BarcodeFormat.UPC_E,
|
BarcodeFormat.UPC_E,
|
||||||
];
|
];
|
||||||
const SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
const PREFERRED_SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
||||||
video: {
|
video: {
|
||||||
facingMode: { ideal: "environment" },
|
facingMode: { ideal: "environment" },
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
@@ -62,6 +63,8 @@ const SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
|||||||
},
|
},
|
||||||
audio: false,
|
audio: false,
|
||||||
};
|
};
|
||||||
|
const IOS_NATIVE_SCAN_INTERVAL_MS = 150;
|
||||||
|
const VIDEO_READY_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export async function startBarcodeScanner(
|
export async function startBarcodeScanner(
|
||||||
videoElement: HTMLVideoElement,
|
videoElement: HTMLVideoElement,
|
||||||
@@ -72,6 +75,8 @@ export async function startBarcodeScanner(
|
|||||||
throw toScannerError(new Error("Camera access is not supported in this browser."));
|
throw toScannerError(new Error("Camera access is not supported in this browser."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureBarcodeDetector();
|
||||||
|
|
||||||
if (await supportsNativeBarcodeDetector()) {
|
if (await supportsNativeBarcodeDetector()) {
|
||||||
try {
|
try {
|
||||||
return await startNativeBarcodeScanner(videoElement, onResult);
|
return await startNativeBarcodeScanner(videoElement, onResult);
|
||||||
@@ -121,15 +126,15 @@ function startNativeBarcodeScanner(
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let animationFrame = 0;
|
let animationFrame = 0;
|
||||||
|
let scanTimeout = 0;
|
||||||
|
let scanning = false;
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia(SCAN_CONSTRAINTS);
|
stream = await getCameraStream();
|
||||||
videoElement.srcObject = stream;
|
prepareVideoElement(videoElement, stream);
|
||||||
videoElement.setAttribute("playsinline", "true");
|
await waitForVideoReady(videoElement);
|
||||||
videoElement.muted = true;
|
|
||||||
await videoElement.play();
|
|
||||||
|
|
||||||
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
||||||
if (!Detector) {
|
if (!Detector) {
|
||||||
@@ -140,13 +145,15 @@ function startNativeBarcodeScanner(
|
|||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
window.cancelAnimationFrame(animationFrame);
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
window.clearTimeout(scanTimeout);
|
||||||
stopVideoStream(videoElement);
|
stopVideoStream(videoElement);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scan = async () => {
|
const scan = async () => {
|
||||||
if (stopped) return;
|
if (stopped || scanning) return;
|
||||||
|
scanning = true;
|
||||||
try {
|
try {
|
||||||
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
if (isVideoFrameReady(videoElement)) {
|
||||||
const barcodes = await detector.detect(videoElement);
|
const barcodes = await detector.detect(videoElement);
|
||||||
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
|
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
|
||||||
if (barcode?.rawValue) {
|
if (barcode?.rawValue) {
|
||||||
@@ -156,12 +163,32 @@ function startNativeBarcodeScanner(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep scanning; transient frame errors are common on mobile Safari.
|
||||||
} finally {
|
} finally {
|
||||||
if (!stopped) animationFrame = window.requestAnimationFrame(() => void scan());
|
scanning = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
animationFrame = window.requestAnimationFrame(() => void scan());
|
const scheduleNextScan = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
if (isAppleMobileDevice()) {
|
||||||
|
scanTimeout = window.setTimeout(() => {
|
||||||
|
void scan().finally(() => {
|
||||||
|
if (!stopped) scheduleNextScan();
|
||||||
|
});
|
||||||
|
}, IOS_NATIVE_SCAN_INTERVAL_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animationFrame = window.requestAnimationFrame(() => {
|
||||||
|
void scan().finally(() => {
|
||||||
|
if (!stopped) scheduleNextScan();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleNextScan();
|
||||||
|
|
||||||
resolve({ mode: "native", stop });
|
resolve({ mode: "native", stop });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (stream) stream.getTracks().forEach((track) => track.stop());
|
if (stream) stream.getTracks().forEach((track) => track.stop());
|
||||||
@@ -178,26 +205,28 @@ async function startZxingBarcodeScanner(
|
|||||||
onResult: (result: BarcodeScanResult) => void,
|
onResult: (result: BarcodeScanResult) => void,
|
||||||
onError: (error: BarcodeScannerError) => void,
|
onError: (error: BarcodeScannerError) => void,
|
||||||
): Promise<BarcodeScannerController> {
|
): Promise<BarcodeScannerController> {
|
||||||
const reader = new BrowserMultiFormatReader();
|
const reader = new BrowserMultiFormatReader(undefined, {
|
||||||
|
delayBetweenScanAttempts: isAppleMobileDevice() ? 150 : 500,
|
||||||
|
});
|
||||||
reader.possibleFormats = ZXING_FORMATS;
|
reader.possibleFormats = ZXING_FORMATS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controls = await reader.decodeFromConstraints(
|
const stream = await getCameraStream();
|
||||||
SCAN_CONSTRAINTS,
|
prepareVideoElement(videoElement, stream);
|
||||||
videoElement,
|
await waitForVideoReady(videoElement);
|
||||||
(result, error) => {
|
|
||||||
if (result) {
|
const controls = await reader.decodeFromStream(stream, videoElement, (result, error) => {
|
||||||
onResult({
|
if (result) {
|
||||||
value: normalizeBarcode(result.getText()),
|
onResult({
|
||||||
format: BarcodeFormat[result.getBarcodeFormat()] ?? "unknown",
|
value: normalizeBarcode(result.getText()),
|
||||||
});
|
format: BarcodeFormat[result.getBarcodeFormat()] ?? "unknown",
|
||||||
return;
|
});
|
||||||
}
|
return;
|
||||||
if (error && !/not.?found/i.test(error.name) && !/not.?found/i.test(error.message)) {
|
}
|
||||||
onError(toScannerError(error));
|
if (error && !/not.?found/i.test(error.name) && !/not.?found/i.test(error.message)) {
|
||||||
}
|
onError(toScannerError(error));
|
||||||
},
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: "zxing",
|
mode: "zxing",
|
||||||
@@ -229,6 +258,102 @@ async function supportsNativeBarcodeDetector() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getCameraStream() {
|
||||||
|
const attempts: MediaStreamConstraints[] = [
|
||||||
|
PREFERRED_SCAN_CONSTRAINTS,
|
||||||
|
{ video: { facingMode: { ideal: "environment" } }, audio: false },
|
||||||
|
{ video: { facingMode: "environment" }, audio: false },
|
||||||
|
{ video: true, audio: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const constraints of attempts) {
|
||||||
|
try {
|
||||||
|
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (isCameraAccessError(error) && !(error instanceof DOMException && error.name === "OverconstrainedError")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error("Could not access the camera.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareVideoElement(videoElement: HTMLVideoElement, stream: MediaStream) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
videoElement.setAttribute("playsinline", "true");
|
||||||
|
videoElement.setAttribute("webkit-playsinline", "true");
|
||||||
|
videoElement.setAttribute("autoplay", "true");
|
||||||
|
videoElement.muted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoFrameReady(videoElement: HTMLVideoElement) {
|
||||||
|
return videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && videoElement.videoWidth > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForVideoReady(videoElement: HTMLVideoElement) {
|
||||||
|
if (isVideoFrameReady(videoElement)) {
|
||||||
|
await playVideoElement(videoElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const settle = (action: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryReady = () => {
|
||||||
|
if (!isVideoFrameReady(videoElement)) return false;
|
||||||
|
settle(() => {
|
||||||
|
void playVideoElement(videoElement).then(resolve).catch(reject);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReady = () => {
|
||||||
|
tryReady();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
settle(() => reject(new Error("Camera preview failed to start.")));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
videoElement.removeEventListener("loadedmetadata", onReady);
|
||||||
|
videoElement.removeEventListener("loadeddata", onReady);
|
||||||
|
videoElement.removeEventListener("error", onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
settle(() => reject(new Error("Camera preview timed out.")));
|
||||||
|
}, VIDEO_READY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
videoElement.addEventListener("loadedmetadata", onReady);
|
||||||
|
videoElement.addEventListener("loadeddata", onReady);
|
||||||
|
videoElement.addEventListener("error", onError, { once: true });
|
||||||
|
|
||||||
|
tryReady();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playVideoElement(videoElement: HTMLVideoElement) {
|
||||||
|
try {
|
||||||
|
await videoElement.play();
|
||||||
|
} catch (error) {
|
||||||
|
if (videoElement.paused) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isCameraAccessError(error: unknown) {
|
function isCameraAccessError(error: unknown) {
|
||||||
if (!(error instanceof DOMException)) return false;
|
if (!(error instanceof DOMException)) return false;
|
||||||
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);
|
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -106,6 +106,23 @@ export type ImportPreview = {
|
|||||||
rows: ImportPreviewRow[];
|
rows: ImportPreviewRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserLimits = {
|
||||||
|
dailyCanLimit?: number;
|
||||||
|
dailySpendLimit?: number;
|
||||||
|
stopTime?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LimitViolation = "cans" | "spend" | "stopTime";
|
||||||
|
|
||||||
|
export type LimitCheckResult = {
|
||||||
|
violations: LimitViolation[];
|
||||||
|
projectedCans: number;
|
||||||
|
projectedSpend: number;
|
||||||
|
todayCans: number;
|
||||||
|
todaySpend: number;
|
||||||
|
pastStopTime: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChatRole = "user" | "assistant";
|
export type ChatRole = "user" | "assistant";
|
||||||
|
|
||||||
export type CoachMessage = {
|
export type CoachMessage = {
|
||||||
@@ -125,20 +142,3 @@ export type CoachChat = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserLimits = {
|
|
||||||
dailyCanLimit?: number;
|
|
||||||
dailySpendLimit?: number;
|
|
||||||
stopTime?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LimitViolation = "cans" | "spend" | "stopTime";
|
|
||||||
|
|
||||||
export type LimitCheckResult = {
|
|
||||||
violations: LimitViolation[];
|
|
||||||
projectedCans: number;
|
|
||||||
projectedSpend: number;
|
|
||||||
todayCans: number;
|
|
||||||
todaySpend: number;
|
|
||||||
pastStopTime: boolean;
|
|
||||||
};
|
|
||||||
|
|||||||
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":
|
||||||
|
|||||||
+24
-127
@@ -1,140 +1,37 @@
|
|||||||
|
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 }) => {
|
build: {
|
||||||
const env = loadEnv(mode, process.cwd(), "");
|
chunkSizeWarningLimit: 700,
|
||||||
const ollamaProxy = {
|
rollupOptions: {
|
||||||
target: "https://ollama.com",
|
output: {
|
||||||
changeOrigin: true,
|
manualChunks: {
|
||||||
rewrite: () => "/api/chat",
|
charts: ["recharts"],
|
||||||
configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) {
|
motion: ["framer-motion"],
|
||||||
proxy.on("proxyReq", (proxyReq) => {
|
icons: ["lucide-react"],
|
||||||
if (env.OLLAMA_API_KEY) {
|
|
||||||
proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [react(), ollamaProxyPlugin(env)],
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/api/ollama-chat": ollamaProxy,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
proxy: {
|
|
||||||
"/api/ollama-chat": ollamaProxy,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
chunkSizeWarningLimit: 700,
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks: {
|
|
||||||
charts: ["recharts"],
|
|
||||||
motion: ["framer-motion"],
|
|
||||||
icons: ["lucide-react"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
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