intial commit

This commit is contained in:
Ned Halksworth
2026-05-15 21:36:13 +01:00
committed by Ned
parent 012b900716
commit bd3e970286
12 changed files with 566 additions and 2457 deletions
+5 -13
View File
@@ -1,17 +1,9 @@
VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your-project-id
VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138
VITE_APPWRITE_DATABASE_ID=redbull_tracker
VITE_APPWRITE_COLLECTION_ID=intake_entries
VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats
# Server-only. Do not prefix with VITE_ or it will be exposed to the browser.
OLLAMA_API_KEY=
OLLAMA_MODEL=deepseek-v4-pro:cloud
VITE_OLLAMA_PROXY_URL=/api/ollama-chat
# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite.
APPWRITE_API_KEY=
# Appwrite chat table columns: userId, title, messages, updatedAt.
# Enable row security and Users -> Create at table level.
# Optional. Leave blank in local dev so the app uses the current Vite origin,
# including fallback ports like http://127.0.0.1:5174.
VITE_APPWRITE_OAUTH_SUCCESS_URL=
VITE_APPWRITE_OAUTH_FAILURE_URL=
+1 -46
View File
@@ -21,14 +21,6 @@ cp .env.example .env.local
This app uses only the Appwrite browser SDK. Do not add an API key to the frontend.
To create/update the database tables from this repo, set a server/admin key as `APPWRITE_API_KEY` in `.env.local` and run:
```bash
npm run setup:appwrite
```
The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code.
Configured defaults:
- Endpoint: `https://fra.cloud.appwrite.io/v1`
@@ -36,8 +28,6 @@ Configured defaults:
- Project name: `Red Bull Tracker App`
- Database ID: `redbull_tracker`
- Collection ID: `intake_entries`
- Chat collection ID: `coach_chats`
- Barcode collection ID: `barcode_products`
`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`.
@@ -87,8 +77,6 @@ So if the Console asks you to create a **table**, that is the same resource as t
The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID.
The barcode scanner uses a separate `barcode_products` table by default. Verified Red Bull barcode rows are seeded by `scripts/setup-appwrite.mjs` using `APPWRITE_API_KEY`; browser code can only read verified rows and create/update the current user's own mappings with row-level permissions.
Create a database with ID:
```text
@@ -166,44 +154,11 @@ Recommended indexes:
- `user_import_key`: key index on `userId`, `importKey`
- Optional unique index on `userId`, `importKey` if your Appwrite plan/schema supports it
## Encrypted Coach Chats
Create a second table with ID:
```text
coach_chats
```
Enable row security on `coach_chats`.
Recommended table-level permissions:
- Create: `users`
- Read: none
- Update: none
- Delete: none
The app stores coach chat titles and messages as plain JSON in Appwrite with row-level user permissions.
Create these chat columns:
| Key | Type | Required | Notes |
| --- | --- | --- | --- |
| `userId` | String, 64 | Yes | Current Appwrite user ID |
| `title` | String, 512 | Yes | Chat title |
| `messages` | Longtext | Yes | JSON array of coach messages |
| `updatedAt` | DateTime | Yes | Sort key |
Recommended chat index:
- `user_chat_updated`: key index on `userId`, `updatedAt`
## Component Structure
- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/coach/data views, modals, and action state.
- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/data views, modals, and action state.
- `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper.
- `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures.
- `src/lib/coachChats.ts`: Appwrite-backed coach chat storage.
- `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview.
- `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks.
- `src/lib/storage.ts`: JSON backup export/import parser.
-46
View File
@@ -9,7 +9,6 @@
"version": "1.0.0",
"dependencies": {
"@vitejs/plugin-react": "^4.3.4",
"@zxing/browser": "^0.2.0",
"appwrite": "^25.0.0",
"exceljs": "^4.4.0",
"framer-motion": "^11.18.2",
@@ -1899,41 +1898,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@zxing/browser": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.0.tgz",
"integrity": "sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==",
"license": "MIT",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.22.0"
}
},
"node_modules/@zxing/library": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz",
"integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.3.1"
},
"engines": {
"node": ">= 24.0.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -5115,16 +5079,6 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+1 -3
View File
@@ -7,12 +7,10 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"setup:appwrite": "node scripts/setup-appwrite.mjs"
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.4",
"@zxing/browser": "^0.2.0",
"appwrite": "^25.0.0",
"exceljs": "^4.4.0",
"framer-motion": "^11.18.2",
+295 -1003
View File
File diff suppressed because it is too large Load Diff
+12 -17
View File
@@ -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" },
];
+128 -1002
View File
File diff suppressed because it is too large Load Diff
+17 -6
View File
@@ -1,16 +1,15 @@
import { Account, Channel, Client, ID, Permission, Query, Role, TablesDB } from "appwrite";
import { Account, Channel, Client, ID, OAuthProvider, Permission, Query, Role, TablesDB } from "appwrite";
const env = import.meta.env;
const currentOrigin = window.location.origin;
export const appwriteConfig = {
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
projectId: env.VITE_APPWRITE_PROJECT_ID!,
projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138",
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
};
const client = new Client()
@@ -24,6 +23,18 @@ export async function pingAppwrite() {
return client.ping();
}
export { account, Channel, client, ID, Permission, Query, Role, tablesDB };
export { account, Channel, client, ID, OAuthProvider, Permission, Query, Role, tablesDB };
function resolveOAuthUrl(value?: string) {
if (!value) return currentOrigin;
const configured = new URL(value, currentOrigin);
const current = new URL(currentOrigin);
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
if (env.DEV && localHosts.has(configured.hostname) && localHosts.has(current.hostname)) {
return currentOrigin;
}
return configured.toString().replace(/\/$/, "");
}
-88
View File
@@ -34,57 +34,6 @@ export type EntryDraft = Omit<
source?: RedBullEntry["source"];
};
export type BarcodeFormatName = "ean-13" | "ean-8" | "upc-a" | "upc-e" | "unknown";
export type BarcodeProductDraft = {
flavourName: string;
sizeMl: number;
pricePerCan: number;
sugarFree?: boolean;
caffeineMgPerCan?: number;
};
export type ResolvedBarcodeProduct = BarcodeProductDraft & {
flavourAccent: string;
source: "built-in" | "user";
};
export type BarcodeSeedProduct = BarcodeProductDraft & {
verifiedBy: string;
sourceName?: string;
sourceUrl?: string;
notes?: string;
variant?: string;
};
export type UserBarcodeMapping = BarcodeProductDraft & {
barcode: string;
createdAt: string;
updatedAt: string;
};
export type BarcodeLookupCatalog = {
verifiedProducts?: Record<string, BarcodeSeedProduct>;
userMappings?: UserBarcodeMapping[];
};
export type BarcodeLookupResult =
| {
status: "known" | "user";
barcode: string;
product: ResolvedBarcodeProduct;
}
| {
status: "partial";
barcode: string;
product: BarcodeProductDraft;
reason: string;
}
| {
status: "unknown";
barcode: string;
};
export type Filters = {
flavour: string;
dateRange: DateFilter;
@@ -105,40 +54,3 @@ export type ImportPreview = {
fileName: string;
rows: ImportPreviewRow[];
};
export type ChatRole = "user" | "assistant";
export type CoachMessage = {
id: string;
role: ChatRole;
content: string;
thinking?: string;
pending?: boolean;
stopped?: boolean;
};
export type CoachChat = {
id: string;
userId: string;
title: string;
messages: CoachMessage[];
createdAt: string;
updatedAt: string;
};
export type UserLimits = {
dailyCanLimit?: number;
dailySpendLimit?: number;
stopTime?: string;
};
export type LimitViolation = "cans" | "spend" | "stopTime";
export type LimitCheckResult = {
violations: LimitViolation[];
projectedCans: number;
projectedSpend: number;
todayCans: number;
todaySpend: number;
pastStopTime: boolean;
};
-3
View File
@@ -5,11 +5,8 @@ interface ImportMetaEnv {
readonly VITE_APPWRITE_PROJECT_ID?: string;
readonly VITE_APPWRITE_DATABASE_ID?: string;
readonly VITE_APPWRITE_COLLECTION_ID?: string;
readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string;
readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string;
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
readonly VITE_OLLAMA_PROXY_URL?: string;
}
interface ImportMeta {
+12 -13
View File
@@ -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":
+3 -125
View File
@@ -1,37 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
import { defineConfig, loadEnv } from "vite";
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const ollamaProxy = {
target: "https://ollama.com",
changeOrigin: true,
rewrite: () => "/api/chat",
configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) {
proxy.on("proxyReq", (proxyReq) => {
if (env.OLLAMA_API_KEY) {
proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`);
}
});
},
};
return {
plugins: [react(), ollamaProxyPlugin(env)],
server: {
proxy: {
"/api/ollama-chat": ollamaProxy,
},
},
preview: {
proxy: {
"/api/ollama-chat": ollamaProxy,
},
},
export default defineConfig({
plugins: [react()],
build: {
chunkSizeWarningLimit: 700,
rollupOptions: {
@@ -44,97 +15,4 @@ export default defineConfig(({ mode }) => {
},
},
},
};
});
function ollamaProxyPlugin(env: Record<string, string>): Plugin {
return {
name: "ollama-proxy",
configureServer(server) {
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
},
configurePreviewServer(server) {
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
},
};
}
function createOllamaHandler(env: Record<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>) : {};
}