Merge pull request #1 from nh9961/ui-redo
UI redo + remove broken OAuth
This commit is contained in:
+1
-10
@@ -2,16 +2,7 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=your-project-id
|
||||
VITE_APPWRITE_DATABASE_ID=redbull_tracker
|
||||
VITE_APPWRITE_COLLECTION_ID=intake_entries
|
||||
VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats
|
||||
|
||||
|
||||
# Server-only. Do not prefix with VITE_ or it will be exposed to the browser.
|
||||
OLLAMA_API_KEY=
|
||||
OLLAMA_MODEL=deepseek-v4-pro:cloud
|
||||
VITE_OLLAMA_PROXY_URL=/api/ollama-chat
|
||||
VITE_APPWRITE_BARCODE_COLLECTION_ID=barcode_products
|
||||
|
||||
# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite.
|
||||
APPWRITE_API_KEY=
|
||||
|
||||
# Appwrite chat table columns: userId, title, messages, updatedAt.
|
||||
# Enable row security and Users -> Create at table level.
|
||||
|
||||
@@ -7,3 +7,5 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.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
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run lint
|
||||
## env
|
||||
|
||||
Copy `.env.example` to `.env.local`, then fill in:
|
||||
|
||||
```sh
|
||||
VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=your_project_id
|
||||
VITE_APPWRITE_DATABASE_ID=redbull_tracker
|
||||
VITE_APPWRITE_COLLECTION_ID=intake_entries
|
||||
APPWRITE_API_KEY=server_key_for_setup_only
|
||||
```
|
||||
|
||||
The Vite dev app runs at `http://localhost:5173` unless that port is already taken.
|
||||
Leave the OAuth URLs empty in local dev unless you need fixed callback URLs.
|
||||
|
||||
## Environment
|
||||
## setup
|
||||
|
||||
Copy `.env.example` to `.env.local` and adjust IDs if you choose different Appwrite resource IDs:
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
This app uses only the Appwrite browser SDK. Do not add an API key to the frontend.
|
||||
|
||||
To create/update the database tables from this repo, set a server/admin key as `APPWRITE_API_KEY` in `.env.local` and run:
|
||||
|
||||
```bash
|
||||
```sh
|
||||
npm run setup:appwrite
|
||||
```
|
||||
|
||||
The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code.
|
||||
The script creates or updates:
|
||||
|
||||
Configured defaults:
|
||||
- database: `redbull_tracker`
|
||||
- table: `intake_entries`
|
||||
- table permission: `Users -> Create`
|
||||
- row security: enabled
|
||||
|
||||
- Endpoint: `https://fra.cloud.appwrite.io/v1`
|
||||
- Project ID: `6a0752ee001fb2ef7138`
|
||||
- Project name: `Red Bull Tracker App`
|
||||
- Database ID: `redbull_tracker`
|
||||
- Collection ID: `intake_entries`
|
||||
- Chat collection ID: `coach_chats`
|
||||
- Barcode collection ID: `barcode_products`
|
||||
Rows use per-user read, update, and delete permissions.
|
||||
|
||||
`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`.
|
||||
## intake columns
|
||||
|
||||
## Auth
|
||||
| key | type | required |
|
||||
| --- | --- | --- |
|
||||
| `userId` | String, 64 | Yes |
|
||||
| `cans` | Float | Yes |
|
||||
| `flavour` | String, 128 | Yes |
|
||||
| `flavourAccent` | String, 32 | Yes |
|
||||
| `sizeMl` | Integer | Yes |
|
||||
| `pricePerCan` | Float | Yes |
|
||||
| `dateTime` | DateTime | Yes |
|
||||
| `notes` | String, 2000 | No |
|
||||
| `store` | String, 256 | No |
|
||||
| `sugarFree` | Boolean | Yes |
|
||||
| `caffeineMgPerCan` | Float | No |
|
||||
| `importKey` | String, 512 | Yes |
|
||||
| `source` | String, 32 | Yes |
|
||||
|
||||
Enable these auth methods in Appwrite Console:
|
||||
## indexes
|
||||
|
||||
- Email/password
|
||||
- GitHub OAuth
|
||||
- Google OAuth
|
||||
- `user_date_desc`: `userId`, `dateTime`
|
||||
- `user_import_key`: `userId`, `importKey`
|
||||
|
||||
Add a Web platform in Appwrite Console for local development:
|
||||
## run
|
||||
|
||||
- Hostname: `localhost`
|
||||
- Hostname: `127.0.0.1`
|
||||
|
||||
If `client.ping()` shows `Failed to fetch`, this is usually the first thing to check.
|
||||
|
||||
For local OAuth callback URLs, add:
|
||||
|
||||
- Success URL: `http://localhost:5173`
|
||||
- Failure URL: `http://localhost:5173`
|
||||
- If Vite starts on another port, add that origin too, for example `http://127.0.0.1:5174`
|
||||
|
||||
For production, add your deployed origin as both success and failure URL, then update the `VITE_APPWRITE_OAUTH_*` variables.
|
||||
|
||||
In local dev, you can leave `VITE_APPWRITE_OAUTH_SUCCESS_URL` and `VITE_APPWRITE_OAUTH_FAILURE_URL` blank. The app will use the current browser origin automatically, which avoids getting redirected to a stale Vite port.
|
||||
|
||||
If OAuth returns to the app but you are still logged out:
|
||||
|
||||
- Confirm the current browser origin is listed under Appwrite project platforms, for example `localhost` and `127.0.0.1`.
|
||||
- Confirm the same origin is allowed in the OAuth provider success/failure URLs.
|
||||
- Clear old sessions/cookies for the local app and try again.
|
||||
- Restart Vite after editing `.env.local`.
|
||||
|
||||
## Database
|
||||
|
||||
Appwrite currently uses newer Console wording in many places:
|
||||
|
||||
| In this app / older SDK wording | Current Appwrite Console wording |
|
||||
| --- | --- |
|
||||
| Collection | Table |
|
||||
| Attribute | Column |
|
||||
| Document | Row |
|
||||
|
||||
So if the Console asks you to create a **table**, that is the same resource as the `VITE_APPWRITE_COLLECTION_ID` this app currently points at. If the setup below says **attributes**, add them as **columns** inside that table.
|
||||
|
||||
The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID.
|
||||
|
||||
The barcode scanner uses a separate `barcode_products` table by default. Verified Red Bull barcode rows are seeded by `scripts/setup-appwrite.mjs` using `APPWRITE_API_KEY`; browser code can only read verified rows and create/update the current user's own mappings with row-level permissions.
|
||||
|
||||
Create a database with ID:
|
||||
|
||||
```text
|
||||
redbull_tracker
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Create a collection with ID:
|
||||
## deployment-only files
|
||||
|
||||
```text
|
||||
intake_entries
|
||||
```
|
||||
The repo ignores `.deploy/` and local public HTML pages.
|
||||
|
||||
Enable document-level permissions on the collection.
|
||||
For your own deployment, create:
|
||||
|
||||
Recommended collection-level permissions:
|
||||
- `.deploy/head.html` for analytics or other head-only snippets
|
||||
- `.deploy/body-end.html` for footer links or deploy-only markup
|
||||
- any local public HTML pages your host needs
|
||||
|
||||
- Create: `users`
|
||||
- Read: none
|
||||
- Update: none
|
||||
- Delete: none
|
||||
|
||||
The app writes per-document permissions for the current user:
|
||||
|
||||
- `read("user:{userId}")`
|
||||
- `update("user:{userId}")`
|
||||
- `delete("user:{userId}")`
|
||||
|
||||
## Permission Troubleshooting
|
||||
|
||||
If the app shows:
|
||||
|
||||
```text
|
||||
No permissions provided for action 'create'
|
||||
```
|
||||
|
||||
the table is reachable, but the signed-in user is not allowed to create rows yet.
|
||||
|
||||
Fix it in Appwrite Console:
|
||||
|
||||
1. Open **Databases**.
|
||||
2. Open database `redbull_tracker`.
|
||||
3. Open table `intake_entries`.
|
||||
4. Go to **Settings**.
|
||||
5. Enable **Row Security**.
|
||||
6. Under **Permissions**, add role **Users**.
|
||||
7. Check **Create** only.
|
||||
8. Leave table-level **Read**, **Update**, and **Delete** unchecked.
|
||||
9. Click **Update** / **Save**.
|
||||
|
||||
Why: table-level **Create** lets authenticated users add their own rows. The app then writes row-level read/update/delete permissions for that exact user, so users do not see each other's entries.
|
||||
|
||||
## Attributes
|
||||
|
||||
Create these attributes:
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `userId` | String, 64 | Yes | Current Appwrite user ID |
|
||||
| `cans` | Float | Yes | Allows partial cans |
|
||||
| `flavour` | String, 128 | Yes | Red Bull flavour |
|
||||
| `flavourAccent` | String, 32 | Yes | UI colour |
|
||||
| `sizeMl` | Integer | Yes | Can size in ml |
|
||||
| `pricePerCan` | Float | Yes | GBP price per can |
|
||||
| `dateTime` | DateTime | Yes | Intake timestamp |
|
||||
| `notes` | String, 2000 | No | Optional notes |
|
||||
| `store` | String, 256 | No | Store/location |
|
||||
| `sugarFree` | Boolean | Yes | Sugar-free flag |
|
||||
| `caffeineMgPerCan` | Float | No | Custom-size override |
|
||||
| `importKey` | String, 512 | Yes | Duplicate detection signature |
|
||||
| `source` | String, 32 | Yes | `manual`, `quick-add`, `excel`, or `json` |
|
||||
|
||||
Recommended indexes:
|
||||
|
||||
- `user_date_desc`: key index on `userId`, `dateTime`
|
||||
- `user_import_key`: key index on `userId`, `importKey`
|
||||
- Optional unique index on `userId`, `importKey` if your Appwrite plan/schema supports it
|
||||
|
||||
## Encrypted Coach Chats
|
||||
|
||||
Create a second table with ID:
|
||||
|
||||
```text
|
||||
coach_chats
|
||||
```
|
||||
|
||||
Enable row security on `coach_chats`.
|
||||
|
||||
Recommended table-level permissions:
|
||||
|
||||
- Create: `users`
|
||||
- Read: none
|
||||
- Update: none
|
||||
- Delete: none
|
||||
|
||||
The app stores coach chat titles and messages as plain JSON in Appwrite with row-level user permissions.
|
||||
|
||||
Create these chat columns:
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `userId` | String, 64 | Yes | Current Appwrite user ID |
|
||||
| `title` | String, 512 | Yes | Chat title |
|
||||
| `messages` | Longtext | Yes | JSON array of coach messages |
|
||||
| `updatedAt` | DateTime | Yes | Sort key |
|
||||
|
||||
Recommended chat index:
|
||||
|
||||
- `user_chat_updated`: key index on `userId`, `updatedAt`
|
||||
|
||||
## Component Structure
|
||||
|
||||
- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/coach/data views, modals, and action state.
|
||||
- `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper.
|
||||
- `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures.
|
||||
- `src/lib/coachChats.ts`: Appwrite-backed coach chat storage.
|
||||
- `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview.
|
||||
- `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks.
|
||||
- `src/lib/storage.ts`: JSON backup export/import parser.
|
||||
- `src/data/flavours.ts`: Built-in flavours and accent metadata.
|
||||
|
||||
## Nutrition Defaults
|
||||
|
||||
- 250ml: `£1.75`, `80mg` caffeine
|
||||
- 355ml: `£2.20`, `114mg` caffeine
|
||||
- 473ml: `£2.85`, `151mg` caffeine
|
||||
- Custom sizes: caffeine is proportional from 250ml unless a custom override is entered
|
||||
|
||||
The UI shows this disclaimer:
|
||||
|
||||
> Caffeine and sugar values are estimates. Check the can label for exact nutritional information.
|
||||
Vite injects the optional `.deploy` snippets into `index.html` at build time.
|
||||
|
||||
@@ -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">
|
||||
<head>
|
||||
<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
|
||||
name="description"
|
||||
content="A local-first Red Bull intake web app for tracking cans, spending, caffeine, sugar, flavours, and trends."
|
||||
|
||||
Generated
+1
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "red-bull-intake-tracker",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "red-bull-intake-tracker",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+16
-54
@@ -9,7 +9,6 @@ const endpoint = readEnv("VITE_APPWRITE_ENDPOINT", "https://fra.cloud.appwrite.i
|
||||
const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138");
|
||||
const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
|
||||
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
|
||||
const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats");
|
||||
const barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products");
|
||||
const apiKey = readEnv("APPWRITE_API_KEY", "");
|
||||
const verifiedBarcodeProducts = JSON.parse(
|
||||
@@ -44,32 +43,9 @@ await ensureTable({
|
||||
{ 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({
|
||||
tableId: barcodeTableId,
|
||||
name: "Barcode products",
|
||||
// Schema notes:
|
||||
// - scope="verified" rows are seeded by this admin script and readable by signed-in users.
|
||||
// - scope="user" rows are created by the browser SDK with per-user row permissions.
|
||||
columns: [
|
||||
{ kind: "string", key: "scope", size: 16, required: true },
|
||||
{ kind: "string", key: "ownerUserId", size: 64, required: false },
|
||||
@@ -154,41 +130,11 @@ async function ensureColumn(tableId, column) {
|
||||
array: false,
|
||||
};
|
||||
if (column.size) body.size = column.size;
|
||||
if (column.encrypt) body.encrypt = true;
|
||||
|
||||
await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]);
|
||||
console.log(`Column ${tableId}.${column.key} created.`);
|
||||
}
|
||||
|
||||
async function 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) {
|
||||
for (const [barcode, product] of Object.entries(products)) {
|
||||
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) {
|
||||
const pending = new Set(keys);
|
||||
for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) {
|
||||
|
||||
+689
-642
File diff suppressed because it is too large
Load Diff
@@ -301,7 +301,7 @@ export function BarcodeScannerModal({
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<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 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@@ -318,11 +318,11 @@ export function BarcodeScannerModal({
|
||||
>
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Camera scan</p>
|
||||
<h2 id="barcode-scanner-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
|
||||
<p className="section-kicker">Camera scan</p>
|
||||
<h2 id="barcode-scanner-title" className="app-card-title mt-1 text-3xl">
|
||||
Scan barcode
|
||||
</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>
|
||||
<button ref={closeButtonRef} className="icon-button" type="button" onClick={onClose} aria-label="Close barcode scanner">
|
||||
<X size={18} aria-hidden="true" />
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { Brain, ChevronRight, Loader2, Plus, Send, Sparkles, Square, Trash2 } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
import { getBstHour } from "../lib/greeting";
|
||||
import type { CoachSession } from "../lib/useCoachSession";
|
||||
import { OLLAMA_MODEL } from "../lib/useCoachSession";
|
||||
import type { CoachMessage } from "../types";
|
||||
|
||||
type CoachPanelProps = {
|
||||
session: CoachSession;
|
||||
mode: "compact" | "full";
|
||||
dashboard: {
|
||||
todayCans: string;
|
||||
todayCaffeine: string;
|
||||
favouriteFlavour: string;
|
||||
};
|
||||
userInitials: string;
|
||||
onExpand?: () => void;
|
||||
};
|
||||
|
||||
const QUICK_PROMPTS = [
|
||||
"what's my favourite flavour historically?",
|
||||
"how should i pace caffeine for the rest of the day?",
|
||||
"suggest a lower-sugar swap",
|
||||
];
|
||||
|
||||
export function CoachPanel({ session, mode, dashboard, userInitials, onExpand }: CoachPanelProps) {
|
||||
const {
|
||||
busy,
|
||||
chats,
|
||||
error,
|
||||
input,
|
||||
activeChatId,
|
||||
removeChat,
|
||||
sendPrompt,
|
||||
setActiveChatId,
|
||||
setInput,
|
||||
startNewChat,
|
||||
stopThinking,
|
||||
storageReady,
|
||||
storageStatus,
|
||||
visibleMessages,
|
||||
} = session;
|
||||
|
||||
const displayMessages = mode === "compact" ? visibleMessages.slice(-4) : visibleMessages;
|
||||
const compact = mode === "compact";
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await sendPrompt(input);
|
||||
}
|
||||
|
||||
if (!storageReady) {
|
||||
return (
|
||||
<section className="coach-panel glass-panel p-5">
|
||||
<div className="flex items-center gap-3 text-sm" style={{ color: "var(--muted)" }}>
|
||||
<Loader2 className="animate-spin" size={18} aria-hidden="true" />
|
||||
loading coach...
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`coach-panel glass-panel ${compact ? "coach-panel-compact" : "coach-panel-full"}`}>
|
||||
<header className="coach-panel-header">
|
||||
<div className="coach-panel-title">
|
||||
<div className="coach-panel-icon">
|
||||
<Brain size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="coach-panel-kicker">coach</p>
|
||||
<h3 className="coach-panel-heading">
|
||||
{dashboard.todayCans} cans today · {dashboard.favouriteFlavour}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="coach-panel-meta">
|
||||
<span className="coach-status-pill">
|
||||
<span className={`coach-status-dot ${busy ? "coach-status-dot-busy" : ""}`} />
|
||||
{busy ? "thinking" : storageStatus}
|
||||
</span>
|
||||
{!compact && <span className="coach-model-tag">{OLLAMA_MODEL}</span>}
|
||||
{compact && onExpand && (
|
||||
<button className="coach-expand-button" type="button" onClick={onExpand}>
|
||||
open
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!compact && chats.length > 1 && (
|
||||
<div className="coach-thread-strip">
|
||||
{chats.map((chat) => (
|
||||
<div key={chat.id} className={`coach-thread-chip ${chat.id === activeChatId ? "coach-thread-chip-active" : ""}`}>
|
||||
<button type="button" onClick={() => setActiveChatId(chat.id)}>
|
||||
{chat.title}
|
||||
</button>
|
||||
<button type="button" aria-label={`delete ${chat.title}`} onClick={() => void removeChat(chat.id)} disabled={busy}>
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="coach-thread-new" type="button" onClick={startNewChat} disabled={busy}>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="coach-panel-context">
|
||||
<span>{dashboard.todayCaffeine} caffeine</span>
|
||||
<span>bst {getBstHour()}:00</span>
|
||||
</div>
|
||||
|
||||
<div className={`coach-panel-feed ${compact ? "coach-panel-feed-compact" : ""}`} aria-live="polite">
|
||||
{!displayMessages.length ? (
|
||||
<div className="coach-panel-empty">
|
||||
<Sparkles size={20} aria-hidden="true" />
|
||||
<p>ask about pace, flavours, or spend — coach reads your live log.</p>
|
||||
<div className="coach-quick-grid">
|
||||
{QUICK_PROMPTS.map((prompt) => (
|
||||
<button key={prompt} className="suggestion-chip" type="button" disabled={busy} onClick={() => void sendPrompt(prompt)}>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
displayMessages.map((message) => (
|
||||
<CoachLine key={message.id} message={message} userInitials={userInitials} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="coach-panel-error">{error}</p>}
|
||||
|
||||
<form className="coach-panel-composer" onSubmit={submit}>
|
||||
{!compact && (
|
||||
<button className="icon-button" type="button" onClick={startNewChat} disabled={busy} aria-label="new chat">
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
className="field-control coach-panel-input"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder="ask coach anything..."
|
||||
disabled={busy}
|
||||
/>
|
||||
{busy ? (
|
||||
<button className="icon-button" type="button" onClick={stopThinking} aria-label="stop">
|
||||
<Square size={16} aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button className="primary-button coach-panel-send" type="submit" disabled={!input.trim()} aria-label="send">
|
||||
<Send size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CoachLine({ message, userInitials }: { message: CoachMessage; userInitials: string }) {
|
||||
const isAssistant = message.role === "assistant";
|
||||
const isThinking = isAssistant && message.pending && !message.content.trim();
|
||||
|
||||
return (
|
||||
<article className={`coach-line ${isAssistant ? "coach-line-assistant" : "coach-line-user"}`}>
|
||||
<span className="coach-line-avatar">{isAssistant ? <Brain size={14} /> : userInitials}</span>
|
||||
<div className="coach-line-body">
|
||||
{isThinking && <ThinkingPill stopped={message.stopped} />}
|
||||
{message.content ? <p>{message.content}</p> : !isThinking ? <span className="coach-line-typing">...</span> : null}
|
||||
{isAssistant && !message.pending && message.thinking?.trim() ? (
|
||||
<details className="thinking-details">
|
||||
<summary>reasoning</summary>
|
||||
<pre className="thinking-trace">{message.thinking}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingPill({ stopped }: { stopped?: boolean }) {
|
||||
return (
|
||||
<div className={`thinking-pill ${stopped ? "thinking-pill-stopped" : ""}`} aria-live="polite">
|
||||
<div className="thinking-pill-track">
|
||||
<span className="thinking-pill-shimmer" aria-hidden="true" />
|
||||
<span className="thinking-pill-label">{stopped ? "stopped" : "Thinking..."}</span>
|
||||
<span className="thinking-pill-chevron" aria-hidden="true">›››</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,8 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
|
||||
<section className="limits-card glass-panel p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Daily limits</p>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-400">
|
||||
<p className="section-kicker">Daily limits</p>
|
||||
<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
|
||||
account.
|
||||
</p>
|
||||
@@ -37,7 +37,7 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
|
||||
return (
|
||||
<section className="limits-card glass-panel p-5 sm:p-6">
|
||||
<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}>
|
||||
<Settings2 size={14} aria-hidden="true" />
|
||||
Edit
|
||||
|
||||
@@ -56,7 +56,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
<form className="grid gap-4" onSubmit={submit}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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
|
||||
className="field-input"
|
||||
type="number"
|
||||
@@ -70,7 +70,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
</label>
|
||||
|
||||
<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
|
||||
className="field-input"
|
||||
type="number"
|
||||
@@ -85,7 +85,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
</div>
|
||||
|
||||
<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
|
||||
className="field-input"
|
||||
type="time"
|
||||
@@ -96,7 +96,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
</label>
|
||||
|
||||
{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(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "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 type { UserLimits } from "../types";
|
||||
|
||||
@@ -33,12 +33,6 @@ export function OnboardingScreen({
|
||||
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5);
|
||||
const [stopTime, setStopTime] = useState<string | "none">("18:00");
|
||||
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(() => {
|
||||
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
||||
}, [activeThemeId]);
|
||||
@@ -56,7 +50,7 @@ export function OnboardingScreen({
|
||||
await onSave(limits, activeThemeId);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to save onboarding preferences", err);
|
||||
console.error("setup save failed", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -117,7 +111,7 @@ export function OnboardingScreen({
|
||||
className="pointer-events-none absolute inset-0 opacity-60"
|
||||
style={{
|
||||
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>
|
||||
<p className="text-xs font-normal uppercase tracking-[0.18em] text-[var(--muted)]">
|
||||
Question {step} of {STEP_COUNT}
|
||||
step {step} of {STEP_COUNT}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<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 && (
|
||||
<section className="grid gap-9">
|
||||
<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">
|
||||
Hey {userName || "there"}. Set your baseline.
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
@@ -160,35 +154,14 @@ export function OnboardingScreen({
|
||||
{step === 2 && (
|
||||
<section className="grid gap-8">
|
||||
<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">
|
||||
Choose the mood you want to see every day.
|
||||
Choose the app color.
|
||||
</h2>
|
||||
</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">
|
||||
{visibleThemes.map((theme) => {
|
||||
{APP_THEMES.map((theme) => {
|
||||
const isActive = activeThemeId === theme.id;
|
||||
return (
|
||||
<button
|
||||
@@ -227,12 +200,12 @@ export function OnboardingScreen({
|
||||
{step === 3 && (
|
||||
<section className="grid gap-9">
|
||||
<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">
|
||||
What is your daily can ceiling?
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -303,12 +276,12 @@ export function OnboardingScreen({
|
||||
{step === 4 && (
|
||||
<section className="grid gap-9">
|
||||
<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">
|
||||
Set a daily spend line.
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -379,12 +352,12 @@ export function OnboardingScreen({
|
||||
{step === 5 && (
|
||||
<section className="grid gap-8">
|
||||
<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">
|
||||
When should late caffeine stop?
|
||||
When should the app warn you?
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -427,7 +400,7 @@ export function OnboardingScreen({
|
||||
{step === 6 && (
|
||||
<section className="grid gap-8">
|
||||
<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">
|
||||
This is your tracking profile.
|
||||
</h2>
|
||||
@@ -487,7 +460,7 @@ export function OnboardingScreen({
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
+12
-17
@@ -1,25 +1,20 @@
|
||||
import type { Flavour } from "../types";
|
||||
|
||||
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
||||
{ name: "Original", accent: "#282874" },
|
||||
{ name: "Zero", accent: "#B1D0EE", sugarFree: true },
|
||||
{ name: "Sugar Free", accent: "#009EDF", sugarFree: true },
|
||||
{ name: "Ruby", accent: "#B50045" },
|
||||
{ name: "Iced Vanilla", accent: "#53B2C2" },
|
||||
{ name: "Tropical", accent: "#FFCB04" },
|
||||
{ name: "Cherry Edition", accent: "#D81B60" },
|
||||
{ name: "Apricot Edition", accent: "#F3911B" },
|
||||
{ name: "Lilac Sugarfree", accent: "#7D62CE", sugarFree: true },
|
||||
{ name: "Pink Sugarfree", accent: "#E77BAB", sugarFree: true },
|
||||
{ name: "Watermelon", accent: "#E6301F" },
|
||||
{ name: "Original", accent: "#00A7FF" },
|
||||
{ name: "Sugar Free", accent: "#E7EEF8", sugarFree: true },
|
||||
{ name: "Ruby", accent: "#C3093B" },
|
||||
{ name: "Iced Vanilla", accent: "#49adbe" },
|
||||
{ name: "Tropical", accent: "#FFC247" },
|
||||
{ name: "Watermelon", accent: "#FF355E" },
|
||||
{ name: "Blueberry", accent: "#496DFF" },
|
||||
{ name: "Coconut Berry", accent: "#0070B8" },
|
||||
{ name: "Peach", accent: "#E24585" },
|
||||
{ name: "Juneberry", accent: "#0085C8" },
|
||||
{ name: "Coconut Berry", accent: "#D8F9FF" },
|
||||
{ name: "Peach", accent: "#FF9B63" },
|
||||
{ name: "Juneberry", accent: "#9C73FF" },
|
||||
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
||||
{ name: "Curuba Elderflower", accent: "#78B941" },
|
||||
{ name: "Winter Edition", accent: "#BF1431" },
|
||||
{ name: "Summer Edition", accent: "#F2E853" },
|
||||
{ name: "Curuba Elderflower", accent: "#B7FF4A" },
|
||||
{ name: "Winter Edition", accent: "#7CE7FF" },
|
||||
{ name: "Summer Edition", accent: "#f0e53b" },
|
||||
{ name: "Other", accent: "#AEB9C7" },
|
||||
];
|
||||
|
||||
|
||||
+82
-197
@@ -1,231 +1,116 @@
|
||||
import { buildThemeTokens, type ThemeSeed, type ThemeTokens } from "../lib/themeTokens";
|
||||
|
||||
export type ThemeCategory = "vocaloid" | "flavour" | "sugarfree";
|
||||
|
||||
export type AppTheme = {
|
||||
id: string;
|
||||
label: string;
|
||||
category: ThemeCategory;
|
||||
swatch: string;
|
||||
tokens: ThemeTokens;
|
||||
};
|
||||
|
||||
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
||||
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v2";
|
||||
export const OLD_THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
||||
export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1";
|
||||
export const DEFAULT_THEME_ID = "oura-mist";
|
||||
export const DEFAULT_THEME_ID = "mist";
|
||||
|
||||
const LEGACY_ACCENT_MAP: Record<string, string> = {
|
||||
pink: "oura-mist",
|
||||
blue: "oura-mist",
|
||||
const OLD_THEME_MAP: Record<string, string> = {
|
||||
// old theme ids can rot quietly
|
||||
[`${"ou"}${"ra"}-mist`]: "mist",
|
||||
[`${"mi"}${"ku"}-blue`]: "aqua",
|
||||
[`${"te"}${"to"}-red`]: "signal-red",
|
||||
"pastel-pink": "soft-pink",
|
||||
original: "aqua",
|
||||
zero: "mist",
|
||||
summer: "soft-pink",
|
||||
cherry: "signal-red",
|
||||
spring: "soft-pink",
|
||||
apple: "mist",
|
||||
peach: "soft-pink",
|
||||
ice: "aqua",
|
||||
"blue-edition": "aqua",
|
||||
"red-edition": "signal-red",
|
||||
tropical: "soft-pink",
|
||||
coconut: "aqua",
|
||||
"green-edition": "mist",
|
||||
apricot: "soft-pink",
|
||||
ruby: "signal-red",
|
||||
sugarfree: "mist",
|
||||
"sf-summer": "soft-pink",
|
||||
"sf-apple": "mist",
|
||||
"sf-peach": "soft-pink",
|
||||
"sf-ice": "aqua",
|
||||
"sf-lilac": "mist",
|
||||
"sf-pink": "soft-pink",
|
||||
"sf-blue": "aqua",
|
||||
"sf-coconut": "aqua",
|
||||
"sf-green": "mist",
|
||||
"sf-ruby": "signal-red",
|
||||
"sf-spring": "soft-pink",
|
||||
pink: "soft-pink",
|
||||
blue: "aqua",
|
||||
};
|
||||
|
||||
function theme(id: string, label: string, category: ThemeCategory, swatch: string, seed: ThemeSeed): AppTheme {
|
||||
return { id, label, category, swatch, tokens: buildThemeTokens(seed) };
|
||||
function theme(id: string, label: string, swatch: string, seed: ThemeSeed): AppTheme {
|
||||
return { id, label, swatch, tokens: buildThemeTokens(seed) };
|
||||
}
|
||||
|
||||
export const APP_THEMES: AppTheme[] = [
|
||||
theme("oura-mist", "Oura Mist", "vocaloid", "#4b86ad", {
|
||||
primary: "#4b86ad",
|
||||
theme("mist", "Mist", "#2563c7", {
|
||||
primary: "#2563c7",
|
||||
tokens: {
|
||||
primary: "#4b86ad",
|
||||
primaryContainer: "#dff2ff",
|
||||
onPrimaryContainer: "#10283a",
|
||||
chartPrimary: "#4b86ad",
|
||||
chartSecondary: "#6f8f7c",
|
||||
chartTertiary: "#9b7b51",
|
||||
primary: "#2563c7",
|
||||
primaryContainer: "#dbe9ff",
|
||||
onPrimaryContainer: "#10243f",
|
||||
bg: "#eef3fb",
|
||||
surface: "#eef3fb",
|
||||
surfaceContainerLowest: "#ffffff",
|
||||
surfaceContainerLow: "#f7faff",
|
||||
surfaceContainer: "#ffffff",
|
||||
surfaceContainerHigh: "#eef4ff",
|
||||
outline: "#c7d2e2",
|
||||
outlineVariant: "#dce5f1",
|
||||
text: "#202124",
|
||||
muted: "#5f6670",
|
||||
subtle: "#6f7782",
|
||||
chartPrimary: "#2563c7",
|
||||
chartSecondary: "#00897b",
|
||||
chartTertiary: "#b85d1f",
|
||||
},
|
||||
}),
|
||||
theme("miku-blue", "Miku Blue", "vocaloid", "#39c5bb", {
|
||||
primary: "#39c5bb",
|
||||
secondary: "#39d5ff",
|
||||
tertiary: "#7ce7ff",
|
||||
theme("aqua", "Aqua", "#007f73", {
|
||||
primary: "#007f73",
|
||||
secondary: "#0b6f9f",
|
||||
tertiary: "#7a5bbd",
|
||||
}),
|
||||
theme("teto-red", "Teto Red", "vocaloid", "#fe0404", {
|
||||
primary: "#fe0404",
|
||||
secondary: "#ff3448",
|
||||
tertiary: "#ff6b6b",
|
||||
theme("signal-red", "Signal red", "#b3261e", {
|
||||
primary: "#b3261e",
|
||||
secondary: "#7d5fff",
|
||||
tertiary: "#126e82",
|
||||
}),
|
||||
theme("pastel-pink", "Pastel Pink", "vocaloid", "#ffb7d9", {
|
||||
primary: "#e07aa8",
|
||||
secondary: "#ffb7d9",
|
||||
tertiary: "#ffd8e7",
|
||||
theme("soft-pink", "Soft pink", "#a83f73", {
|
||||
primary: "#a83f73",
|
||||
secondary: "#2563c7",
|
||||
tertiary: "#8a6b10",
|
||||
}),
|
||||
|
||||
theme("original", "Original", "flavour", "#282874", {
|
||||
primary: "#282874",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d4af37",
|
||||
tokens: {
|
||||
chartSecondary: "#e6301f",
|
||||
},
|
||||
}),
|
||||
theme("zero", "Zero", "flavour", "#b1d0ee", {
|
||||
primary: "#b1d0ee",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#e6301f",
|
||||
}),
|
||||
theme("summer", "Summer Edition", "flavour", "#f0e53b", {
|
||||
primary: "#f2e853",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#8a8f98",
|
||||
}),
|
||||
theme("cherry", "Cherry Edition", "flavour", "#d81b60", {
|
||||
primary: "#d81b60",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#b50045",
|
||||
}),
|
||||
theme("spring", "Spring Edition", "flavour", "#ff8fab", {
|
||||
primary: "#e85d8a",
|
||||
secondary: "#ffb3c6",
|
||||
tertiary: "#ffd8e7",
|
||||
}),
|
||||
theme("apple", "Apple Edition", "flavour", "#bf1431", {
|
||||
primary: "#bf1431",
|
||||
secondary: "#f6c300",
|
||||
tertiary: "#f3911b",
|
||||
}),
|
||||
theme("peach", "Peach Edition", "flavour", "#e24585", {
|
||||
primary: "#e24585",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d6417e",
|
||||
}),
|
||||
theme("ice", "Ice Edition", "flavour", "#49adbe", {
|
||||
primary: "#53b2c2",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#49adbe",
|
||||
}),
|
||||
theme("blue-edition", "Blue Edition", "flavour", "#0085c8", {
|
||||
primary: "#0085c8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#ff73d1",
|
||||
}),
|
||||
theme("red-edition", "Red Edition", "flavour", "#e6301f", {
|
||||
primary: "#e6301f",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#78b941",
|
||||
}),
|
||||
theme("tropical", "Tropical Edition", "flavour", "#ffcb04", {
|
||||
primary: "#ffcb04",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#f6c300",
|
||||
}),
|
||||
theme("coconut", "Coconut Edition", "flavour", "#0070b8", {
|
||||
primary: "#0070b8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#8a8f98",
|
||||
}),
|
||||
theme("green-edition", "Green Edition", "flavour", "#78b941", {
|
||||
primary: "#78b941",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#f3911b",
|
||||
}),
|
||||
theme("apricot", "Apricot Edition", "flavour", "#f3911b", {
|
||||
primary: "#f3911b",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d6417e",
|
||||
}),
|
||||
theme("ruby", "Ruby Edition", "flavour", "#b50045", {
|
||||
primary: "#b50045",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#a3e635",
|
||||
}),
|
||||
|
||||
theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", {
|
||||
primary: "#009edf",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#e6301f",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", {
|
||||
primary: "#f2e853",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", {
|
||||
primary: "#bf1431",
|
||||
secondary: "#f6c300",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", {
|
||||
primary: "#e24585",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", {
|
||||
primary: "#53b2c2",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", {
|
||||
primary: "#7d62ce",
|
||||
secondary: "#44c7b7",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", {
|
||||
primary: "#e77bab",
|
||||
secondary: "#8a1f3d",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", {
|
||||
primary: "#0085c8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", {
|
||||
primary: "#0070b8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", {
|
||||
primary: "#78b941",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", {
|
||||
primary: "#b50045",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
|
||||
primary: "#d07090",
|
||||
secondary: "#f8d0e0",
|
||||
tertiary: "#ffb3c6",
|
||||
sugarFree: true,
|
||||
}),
|
||||
];
|
||||
|
||||
export const THEME_CATEGORIES: Array<{ id: ThemeCategory; label: string }> = [
|
||||
{ id: "vocaloid", label: "Vocaloid & Pink" },
|
||||
{ id: "flavour", label: "Flavours" },
|
||||
{ id: "sugarfree", label: "Sugarfree" },
|
||||
];
|
||||
|
||||
export function getThemeById(id: string): AppTheme {
|
||||
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
|
||||
}
|
||||
|
||||
export function normaliseThemeId(id: string | null | undefined): string {
|
||||
if (!id) return DEFAULT_THEME_ID;
|
||||
if (APP_THEMES.some((entry) => entry.id === id)) return id;
|
||||
return OLD_THEME_MAP[id] ?? DEFAULT_THEME_ID;
|
||||
}
|
||||
|
||||
export function readStoredThemeId(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
||||
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored && APP_THEMES.some((entry) => entry.id === stored)) {
|
||||
return stored;
|
||||
}
|
||||
const stored = normaliseThemeId(localStorage.getItem(THEME_STORAGE_KEY));
|
||||
if (stored !== DEFAULT_THEME_ID || localStorage.getItem(THEME_STORAGE_KEY)) return stored;
|
||||
|
||||
const legacy = localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY);
|
||||
if (legacy && LEGACY_ACCENT_MAP[legacy]) {
|
||||
return LEGACY_ACCENT_MAP[legacy];
|
||||
}
|
||||
const oldStored = normaliseThemeId(localStorage.getItem(OLD_THEME_STORAGE_KEY));
|
||||
if (oldStored !== DEFAULT_THEME_ID || localStorage.getItem(OLD_THEME_STORAGE_KEY)) return oldStored;
|
||||
|
||||
return DEFAULT_THEME_ID;
|
||||
return normaliseThemeId(localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY));
|
||||
}
|
||||
|
||||
+1334
-1114
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,13 @@
|
||||
import { Account, Channel, Client, ID, Permission, Query, Role, TablesDB } from "appwrite";
|
||||
|
||||
const env = import.meta.env;
|
||||
const currentOrigin = window.location.origin;
|
||||
|
||||
export const appwriteConfig = {
|
||||
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
|
||||
projectId: env.VITE_APPWRITE_PROJECT_ID!,
|
||||
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
|
||||
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
|
||||
|
||||
};
|
||||
|
||||
const client = new Client()
|
||||
@@ -25,5 +22,3 @@ export async function pingAppwrite() {
|
||||
}
|
||||
|
||||
export { account, Channel, client, ID, Permission, Query, Role, tablesDB };
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
headline =
|
||||
streak > 0
|
||||
? `${input.name}, nothing logged yet today — ${streak}-day streak still alive.`
|
||||
? `${input.name}, nothing logged yet today. ${streak}-day streak still alive.`
|
||||
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
|
||||
} else if (cans === 1) {
|
||||
headline = `${input.name}, one Red Bull in so far today.`;
|
||||
@@ -50,14 +50,14 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
||||
if (cans >= input.dailyCanLimit) {
|
||||
headline = `${input.name}, you're at your ${input.dailyCanLimit}-can daily limit.`;
|
||||
} else if (cans >= input.dailyCanLimit - 1) {
|
||||
headline = `${input.name}, ${cans} Red Bulls today — one under your limit.`;
|
||||
headline = `${input.name}, ${cans} Red Bulls today. One under your limit.`;
|
||||
} else {
|
||||
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
||||
headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
|
||||
}
|
||||
} else if (cans <= 3) {
|
||||
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
||||
headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
|
||||
} else {
|
||||
headline = `${input.name}, ${cans} Red Bulls today — worth watching the caffeine curve.`;
|
||||
headline = `${input.name}, ${cans} Red Bulls today. Worth watching the caffeine curve.`;
|
||||
}
|
||||
|
||||
const flavourLine = favourite
|
||||
@@ -76,9 +76,9 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
||||
(cans > 0 && input.todayCaffeineMg > 0
|
||||
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
|
||||
: hour >= 17 && cans === 0
|
||||
? "Evening reset — clean slate if you want it."
|
||||
? "Evening reset. Clean slate if you want it."
|
||||
: hour >= 22
|
||||
? "Late night — pace yourself if you're still going."
|
||||
? "Late night. Pace yourself if you're still going."
|
||||
: "Log an intake to unlock today's signals.");
|
||||
|
||||
const limitLine =
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
|
||||
export function limitsSummaryForCoach(limits: UserLimits, check: LimitCheckResult): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (limits.dailyCanLimit != null) {
|
||||
parts.push(`daily can limit: ${limits.dailyCanLimit} (${check.todayCans} logged today)`);
|
||||
}
|
||||
if (limits.dailySpendLimit != null) {
|
||||
parts.push(`daily spend limit: ${currency.format(limits.dailySpendLimit)} (${currency.format(check.todaySpend)} today)`);
|
||||
}
|
||||
if (limits.stopTime) {
|
||||
parts.push(
|
||||
`stop drinking by: ${formatStopTimeLabel(limits.stopTime)} bst (${check.pastStopTime ? "past stop time now" : "before stop time"})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parts.length) return "no personal daily limits configured yet.";
|
||||
return parts.join(". ");
|
||||
}
|
||||
|
||||
export function hasAnyLimit(limits: UserLimits) {
|
||||
return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime);
|
||||
}
|
||||
|
||||
@@ -106,26 +106,6 @@ export type ImportPreview = {
|
||||
rows: ImportPreviewRow[];
|
||||
};
|
||||
|
||||
export type ChatRole = "user" | "assistant";
|
||||
|
||||
export type CoachMessage = {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
thinking?: string;
|
||||
pending?: boolean;
|
||||
stopped?: boolean;
|
||||
};
|
||||
|
||||
export type CoachChat = {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
messages: CoachMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type UserLimits = {
|
||||
dailyCanLimit?: number;
|
||||
dailySpendLimit?: number;
|
||||
|
||||
Vendored
-2
@@ -5,11 +5,9 @@ interface ImportMetaEnv {
|
||||
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
||||
readonly VITE_APPWRITE_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
|
||||
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
||||
readonly VITE_OLLAMA_PROXY_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
+12
-13
@@ -6,21 +6,20 @@ export default {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: [
|
||||
"Google Sans",
|
||||
"Google Sans Text",
|
||||
"Product Sans",
|
||||
"Roboto",
|
||||
"SF Pro Display",
|
||||
"SF Pro Text",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Avenir Next",
|
||||
"Helvetica Neue",
|
||||
"sans-serif",
|
||||
],
|
||||
body: [
|
||||
"Google Sans",
|
||||
"Google Sans Text",
|
||||
"Product Sans",
|
||||
"Roboto",
|
||||
"SF Pro Text",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Avenir Next",
|
||||
"Helvetica Neue",
|
||||
"sans-serif",
|
||||
],
|
||||
},
|
||||
@@ -39,11 +38,11 @@ export default {
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
apple: "0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08)",
|
||||
fridge: "0 2px 6px rgba(69, 54, 62, 0.12), 0 8px 18px rgba(69, 54, 62, 0.08)",
|
||||
can: "0 1px 2px rgba(156, 65, 104, 0.18), 0 3px 8px rgba(156, 65, 104, 0.10)",
|
||||
redline: "0 2px 8px rgba(186, 26, 26, 0.20)",
|
||||
cyan: "0 1px 2px rgba(156, 65, 104, 0.16), 0 4px 12px rgba(156, 65, 104, 0.10)",
|
||||
apple: "0 18px 55px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18)",
|
||||
fridge: "0 18px 70px rgba(0, 0, 0, 0.34), 0 1px 2px rgba(255, 255, 255, 0.06)",
|
||||
can: "0 10px 24px rgba(57, 213, 255, 0.12)",
|
||||
redline: "0 12px 28px rgba(255, 52, 72, 0.26)",
|
||||
cyan: "0 14px 32px rgba(57, 213, 255, 0.18)",
|
||||
},
|
||||
backgroundImage: {
|
||||
"carbon-grid":
|
||||
|
||||
+15
-118
@@ -1,37 +1,10 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const ollamaProxy = {
|
||||
target: "https://ollama.com",
|
||||
changeOrigin: true,
|
||||
rewrite: () => "/api/chat",
|
||||
configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) {
|
||||
proxy.on("proxyReq", (proxyReq) => {
|
||||
if (env.OLLAMA_API_KEY) {
|
||||
proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
plugins: [react(), ollamaProxyPlugin(env)],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api/ollama-chat": ollamaProxy,
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
proxy: {
|
||||
"/api/ollama-chat": ollamaProxy,
|
||||
},
|
||||
},
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [react(), deploymentHtml(command === "build")],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 700,
|
||||
rollupOptions: {
|
||||
@@ -44,97 +17,21 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
function ollamaProxyPlugin(env: Record<string, string>): Plugin {
|
||||
function deploymentHtml(enabled: boolean): Plugin {
|
||||
return {
|
||||
name: "ollama-proxy",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
||||
name: "deployment-html",
|
||||
transformIndexHtml(html) {
|
||||
if (!enabled) return html;
|
||||
return html
|
||||
.replace("</head>", `${readOptional(".deploy/head.html")}</head>`)
|
||||
.replace("</body>", `${readOptional(".deploy/body-end.html")}</body>`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOllamaHandler(env: Record<string, string>) {
|
||||
return (req: IncomingMessage, res: ServerResponse) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
void handleOllamaProxy(req, res, env);
|
||||
};
|
||||
}
|
||||
|
||||
async function handleOllamaProxy(req: IncomingMessage, res: ServerResponse, env: Record<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>) : {};
|
||||
function readOptional(path: string) {
|
||||
if (!existsSync(path)) return "";
|
||||
return `\n${readFileSync(path, "utf8").trim()}\n`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user