Compare commits
6 Commits
main
..
e3ba9bab6b
| Author | SHA1 | Date | |
|---|---|---|---|
| e3ba9bab6b | |||
| b4e0615e77 | |||
| e067a3638c | |||
| de6ce0c350 | |||
| 94c906cc59 | |||
| a9a35cc751 |
+15
-2
@@ -1,8 +1,21 @@
|
|||||||
VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
|
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_DATABASE_ID=redbull_tracker
|
||||||
VITE_APPWRITE_COLLECTION_ID=intake_entries
|
VITE_APPWRITE_COLLECTION_ID=intake_entries
|
||||||
VITE_APPWRITE_BARCODE_COLLECTION_ID=barcode_products
|
VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# Server-only. Do not prefix with VITE_ or it will be exposed to the browser.
|
||||||
|
OLLAMA_API_KEY=
|
||||||
|
OLLAMA_MODEL=deepseek-v4-pro:cloud
|
||||||
|
VITE_OLLAMA_PROXY_URL=/api/ollama-chat
|
||||||
|
|
||||||
# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite.
|
# Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite.
|
||||||
APPWRITE_API_KEY=
|
APPWRITE_API_KEY=
|
||||||
|
|
||||||
|
# Appwrite chat table columns: userId, title, messages, updatedAt.
|
||||||
|
# Enable row security and Users -> Create at table level.
|
||||||
|
|||||||
@@ -7,5 +7,3 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
.deploy/
|
|
||||||
public/*.html
|
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
# Project Overview
|
|
||||||
|
|
||||||
Red Bull Intake Tracker is a premium web-based tracking dashboard designed for tracking caffeine
|
|
||||||
and beverage consumption, with a strong focus on Red Bull products. Built using React, Vite, and
|
|
||||||
TypeScript, it allows users to record intake (amount, flavour, size, price, timestamp, and location),
|
|
||||||
monitor daily spending and caffeine limits, view structured trends and streaks, import/export
|
|
||||||
data via styled Excel or JSON formats, and engage in real-time encrypted-compatible dialogue with an
|
|
||||||
AI wellness coach powered by a serverless Ollama proxy endpoint. Synchronized dynamically with
|
|
||||||
Appwrite Cloud databases using secure row-level document permissions, it delivers a highly reactive,
|
|
||||||
personalized, and privacy-first self-tracking experience.
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
|
|
||||||
- `api/` – Contains serverless backend handlers, including the API gateway proxy for Ollama chat endpoints.
|
|
||||||
- `dist/` – Contains static HTML, JavaScript, and CSS bundle files output by the production build process.
|
|
||||||
- `node_modules/` – Stores third-party library dependencies and packages managed by npm.
|
|
||||||
- `scripts/` – Houses automation scripts, including database schema configuration tools for Appwrite.
|
|
||||||
- `src/` – Contains client-side React source code, components, utility models, and stylesheets.
|
|
||||||
- `src/components/` – Reusable UI panel elements, forms, and splash screen wrappers.
|
|
||||||
- `src/data/` – Static configurations, including theme lists and built-in flavours mapping.
|
|
||||||
- `src/lib/` – Business logic engines for calculations, file parsers, and Appwrite client connections.
|
|
||||||
|
|
||||||
## Build & Development Commands
|
|
||||||
|
|
||||||
Use the following shell-ready commands to install dependencies, run the application, lint the code,
|
|
||||||
and manage the cloud database.
|
|
||||||
|
|
||||||
### Dependency Installation
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local Development Server
|
|
||||||
Starts a local development server at `http://localhost:5173`.
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Build & Bundling
|
|
||||||
Performs TypeScript diagnostic type-checks and compiles the application into the `dist/` directory.
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Preview
|
|
||||||
Runs a local web server to preview the built production bundle.
|
|
||||||
```bash
|
|
||||||
npm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Linting
|
|
||||||
Runs ESLint over TypeScript files to identify syntax issues and code style warnings.
|
|
||||||
```bash
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Appwrite Cloud Database Setup
|
|
||||||
Automatically provisions the databases, tables, columns, and indexes on the configured Appwrite instance.
|
|
||||||
```bash
|
|
||||||
npm run setup:appwrite
|
|
||||||
```
|
|
||||||
|
|
||||||
### Automated Testing
|
|
||||||
> TODO: Add automated test suite command (e.g., `npm run test` using Vitest or Jest).
|
|
||||||
|
|
||||||
### Application Deployment
|
|
||||||
> TODO: Add production deployment pipeline command (e.g., Vercel, Netlify, or Docker deploy).
|
|
||||||
|
|
||||||
## Code Style & Conventions
|
|
||||||
|
|
||||||
- **Language**: TypeScript is strictly required for all UI components and logic scripts; JavaScript
|
|
||||||
is limited to serverless handlers and build scripting.
|
|
||||||
- **Strict Checks**: TypeScript's `strict` compiler option is enabled; avoid using `any` and ensure
|
|
||||||
all parameters and return values are explicitly typed.
|
|
||||||
- **Formatting**: Code should be formatted with 2-space indentation, trailing commas where supported,
|
|
||||||
and double quotes for JSX/TSX properties.
|
|
||||||
- **Linting**: Rules are governed by ESLint (`eslint.config.js`), extending the standard TypeScript
|
|
||||||
and React Hooks rulesets.
|
|
||||||
- **Naming Conventions**:
|
|
||||||
- React components and files use PascalCase (e.g., `CoachPanel.tsx`).
|
|
||||||
- Business logic, utilities, and helper hooks use camelCase (e.g., `appwriteEntries.ts`, `useCoachSession.ts`).
|
|
||||||
- Constants and static metadata arrays use UPPER_SNAKE_CASE (e.g., `BUILT_IN_FLAVOURS`).
|
|
||||||
- **Imports**: Prefer explicit ES module imports (`import { ... } from "..."`). Avoid wildcards.
|
|
||||||
- **Commit Messages**:
|
|
||||||
- > TODO: Define commit message guidelines and templates (e.g., Conventional Commits).
|
|
||||||
|
|
||||||
## Architecture Notes
|
|
||||||
|
|
||||||
### Flow Architecture Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph Frontend [Vite React Client]
|
|
||||||
App[App.tsx - Core State & Shell]
|
|
||||||
Components[Onboarding, CoachPanel, Limits, etc.]
|
|
||||||
LibMetrics[metrics.ts & userLimits.ts]
|
|
||||||
LibIO[excel.ts & storage.ts]
|
|
||||||
LibTheme[themeTokens.ts & themes.ts]
|
|
||||||
HookCoach[useCoachSession.ts]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph BackendAPI [API & Proxies]
|
|
||||||
ViteProxy[Vite Dev Server Middleware]
|
|
||||||
VercelHandler[api/ollama-chat.js Serverless Function]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph External [External Services]
|
|
||||||
AppwriteCloud[Appwrite Cloud / TablesDB]
|
|
||||||
OllamaAPI[Ollama Cloud API]
|
|
||||||
end
|
|
||||||
|
|
||||||
App --> Components
|
|
||||||
App --> LibMetrics
|
|
||||||
App --> LibIO
|
|
||||||
App --> LibTheme
|
|
||||||
App --> HookCoach
|
|
||||||
|
|
||||||
HookCoach -- "/api/ollama-chat (Local)" --> ViteProxy
|
|
||||||
HookCoach -- "/api/ollama-chat (Prod)" --> VercelHandler
|
|
||||||
|
|
||||||
ViteProxy -- "Headers Auth" --> OllamaAPI
|
|
||||||
VercelHandler -- "Headers Auth" --> OllamaAPI
|
|
||||||
|
|
||||||
App --> AppwriteCloud
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Roles & Data Flow
|
|
||||||
|
|
||||||
1. **State Orchestration (`src/App.tsx`)**:
|
|
||||||
Acts as the monolithic hub of the frontend. It manages user authentication states, currently
|
|
||||||
selected views, active database operations, modals, onboarding triggers, and theme settings.
|
|
||||||
|
|
||||||
2. **Metrics & Limits (`src/lib/metrics.ts`, `src/lib/userLimits.ts`)**:
|
|
||||||
Process raw intakes to extract total cans, spendings, caffeine absorption, hydration estimates,
|
|
||||||
streaks, and coordinate warnings when user limits are breached or bedtime approaches.
|
|
||||||
|
|
||||||
3. **External Data Codecs (`src/lib/excel.ts`, `src/lib/storage.ts`)**:
|
|
||||||
Implement styled spreadsheet formatting with `exceljs`, data sanity validation, duplicate-aware
|
|
||||||
import preview engines, and local JSON backup/restore modules.
|
|
||||||
|
|
||||||
4. **Dynamic Styling (`src/lib/themeTokens.ts`, `src/data/themes.ts`)**:
|
|
||||||
Computes CSS tokens dynamically from a selection of Vocaloid or beverage-themed configurations,
|
|
||||||
writing variables into the document root for real-time visual styling modifications.
|
|
||||||
|
|
||||||
5. **Cloud Synced State (`src/lib/appwrite.ts`, `src/lib/appwriteEntries.ts`, `src/lib/coachChats.ts`)**:
|
|
||||||
Establishes client tunnels to Appwrite's serverless TablesDB backend, running row-secured
|
|
||||||
CRUD actions bound strictly to the current `userId`.
|
|
||||||
|
|
||||||
6. **AI Coach Chatbot (`src/lib/useCoachSession.ts`, `api/ollama-chat.js`)**:
|
|
||||||
A state-machine custom hook that pipelines user questions, injects historical intake aggregates,
|
|
||||||
and streams responses from DeepSeek through server-side Ollama proxy tunnels.
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
The repository does not currently feature automated test files. Testing is executed manually.
|
|
||||||
|
|
||||||
### Unit & Integration Testing
|
|
||||||
- > TODO: Configure unit and integration tests (e.g., Vitest + React Testing Library) to validate
|
|
||||||
metrics computations, limits checks, and file importing codecs.
|
|
||||||
|
|
||||||
### End-to-End (E2E) Testing
|
|
||||||
- > TODO: Introduce E2E test suites (e.g., Playwright or Cypress) to cover authentication paths,
|
|
||||||
entry additions, theme switches, and chatbot conversation loops.
|
|
||||||
|
|
||||||
### Continuous Integration (CI)
|
|
||||||
- > TODO: Establish a GitHub Actions workflow pipeline to run linters, type checks, and tests on
|
|
||||||
every branch commit or pull request.
|
|
||||||
|
|
||||||
## Security & Compliance
|
|
||||||
|
|
||||||
- **Authentication**: Delegated entirely to Appwrite's built-in OAuth/Email-password protocols.
|
|
||||||
No user passwords or direct login credentials are saved inside the application state.
|
|
||||||
- **Client Security**: Client-side application calls only the Appwrite browser SDK. No administrative
|
|
||||||
or server-level API keys are ever shipped or exposed to the client.
|
|
||||||
- **Database Row Security**: All Appwrite tables have `Row Security` enabled. Users are granted
|
|
||||||
`create` permissions on the table level, but read, update, and delete actions require explicit
|
|
||||||
document permissions matching the user's specific ID (`user:{userId}`).
|
|
||||||
- **LLM API Security**: To avoid key exposure, the client connects to the proxy path `/api/ollama-chat`.
|
|
||||||
The secret `OLLAMA_API_KEY` is maintained exclusively in secure server-side environment variables.
|
|
||||||
- **Dependency Auditing**:
|
|
||||||
- > TODO: Add automated dependency checking (e.g., `npm audit` or Dependabot) in the CI pipeline.
|
|
||||||
- **Software Licensing**:
|
|
||||||
- > TODO: Add a standard LICENSE file (e.g., MIT, Apache-2.0) to explicitly detail terms of reuse.
|
|
||||||
|
|
||||||
## Agent Guardrails
|
|
||||||
|
|
||||||
- **Environment File Preservation**: Never edit, modify, or commit variables directly inside
|
|
||||||
`.env.local` or `.env` templates unless explicitly instructed by the user.
|
|
||||||
- **Credential Safety**: Never add, write, or hardcode API keys, access secrets, project keys, or
|
|
||||||
personal tokens into the codebase or configuration files.
|
|
||||||
- **Safe Directory Boundaries**: Do not add, write, or alter files inside administrative, system, or
|
|
||||||
auto-generated directories like `.git`, `.gemini`, `dist`, or `node_modules`.
|
|
||||||
- **Monolithic State Warnings**: `src/App.tsx` contains the core layout and view engine. Exercise
|
|
||||||
extreme care when making adjustments to prevent breaking the view transitions, auth hooks, or modals.
|
|
||||||
- **Database Alignment Rules**: Always verify that any changes to DB record structures or types are
|
|
||||||
mirrored across `src/types.ts`, Appwrite modules (`src/lib/appwriteEntries.ts`, `src/lib/coachChats.ts`),
|
|
||||||
and the migration runner `scripts/setup-appwrite.mjs`.
|
|
||||||
|
|
||||||
## Extensibility Hooks
|
|
||||||
|
|
||||||
- **Flavour Extensions**: New built-in Red Bull flavours, accent colors, and sugar-free rules can be
|
|
||||||
easily appended to the `BUILT_IN_FLAVOURS` array in `src/data/flavours.ts`.
|
|
||||||
- **UI Custom Themes**: Additional visual themes, including Vocaloid, seasonal, or custom branding,
|
|
||||||
can be integrated by adding definitions to the `APP_THEMES` array in `src/data/themes.ts`.
|
|
||||||
- **Proxy Endpoint Rerouting**: The Ollama upstream proxy route in `vite.config.ts` and
|
|
||||||
`api/ollama-chat.js` can be adjusted to point to alternative LLM hosts or local server instances.
|
|
||||||
- **Configurable Environment Parameters**:
|
|
||||||
- `VITE_APPWRITE_ENDPOINT` – Base URL for the Appwrite API server.
|
|
||||||
- `VITE_APPWRITE_PROJECT_ID` – The Appwrite project instance identifier.
|
|
||||||
- `VITE_APPWRITE_DATABASE_ID` – TablesDB target database identifier.
|
|
||||||
- `VITE_APPWRITE_COLLECTION_ID` – Table ID containing intake documents.
|
|
||||||
- `VITE_APPWRITE_CHAT_COLLECTION_ID` – Table ID storing coach chatbot threads.
|
|
||||||
- `OLLAMA_API_KEY` – Administrative bearer authorization token for Ollama endpoints.
|
|
||||||
- `OLLAMA_MODEL` – Upstream model identifier (default: `deepseek-v4-pro:cloud`).
|
|
||||||
|
|
||||||
## Further Reading
|
|
||||||
|
|
||||||
- [Appwrite Platform Documentation](file:///Users/ned/Documents/GitHub/Red%20Bull%20Tracking%20System/APPWRITE_SETUP.md) – Detailed guide on database configuration, attributes, indexes, and row permissions.
|
|
||||||
- [Appwrite Admin Schema Migrations](file:///Users/ned/Documents/GitHub/Red%20Bull%20Tracking%20System/scripts/setup-appwrite.mjs) – Automated table creation and attributes loader script.
|
|
||||||
- > TODO: Put architecture decision records (ADRs) or technical whitepapers under a dedicated `/docs` directory.
|
|
||||||
+194
-52
@@ -1,76 +1,218 @@
|
|||||||
# Red Bull tracker setup
|
# Red Bull Intake Tracker Setup
|
||||||
|
|
||||||
This app uses Appwrite for auth and intake entries.
|
## Commands
|
||||||
|
|
||||||
## env
|
```bash
|
||||||
|
npm install
|
||||||
Copy `.env.example` to `.env.local`, then fill in:
|
npm run dev
|
||||||
|
npm run build
|
||||||
```sh
|
npm run lint
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave the OAuth URLs empty in local dev unless you need fixed callback URLs.
|
The Vite dev app runs at `http://localhost:5173` unless that port is already taken.
|
||||||
|
|
||||||
## setup
|
## Environment
|
||||||
|
|
||||||
Run:
|
Copy `.env.example` to `.env.local` and adjust IDs if you choose different Appwrite resource IDs:
|
||||||
|
|
||||||
```sh
|
```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
|
||||||
npm run setup:appwrite
|
npm run setup:appwrite
|
||||||
```
|
```
|
||||||
|
|
||||||
The script creates or updates:
|
The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code.
|
||||||
|
|
||||||
- database: `redbull_tracker`
|
Configured defaults:
|
||||||
- table: `intake_entries`
|
|
||||||
- table permission: `Users -> Create`
|
|
||||||
- row security: enabled
|
|
||||||
|
|
||||||
Rows use per-user read, update, and delete permissions.
|
- 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`
|
||||||
|
|
||||||
## intake columns
|
`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`.
|
||||||
|
|
||||||
| key | type | required |
|
## Auth
|
||||||
| --- | --- | --- |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## indexes
|
Enable these auth methods in Appwrite Console:
|
||||||
|
|
||||||
- `user_date_desc`: `userId`, `dateTime`
|
- Email/password
|
||||||
- `user_import_key`: `userId`, `importKey`
|
- GitHub OAuth
|
||||||
|
- Google OAuth
|
||||||
|
|
||||||
## run
|
Add a Web platform in Appwrite Console for local development:
|
||||||
|
|
||||||
```sh
|
- Hostname: `localhost`
|
||||||
npm install
|
- Hostname: `127.0.0.1`
|
||||||
npm run dev
|
|
||||||
|
If `client.ping()` shows `Failed to fetch`, this is usually the first thing to check.
|
||||||
|
|
||||||
|
For local OAuth callback URLs, add:
|
||||||
|
|
||||||
|
- Success URL: `http://localhost:5173`
|
||||||
|
- Failure URL: `http://localhost:5173`
|
||||||
|
- If Vite starts on another port, add that origin too, for example `http://127.0.0.1:5174`
|
||||||
|
|
||||||
|
For production, add your deployed origin as both success and failure URL, then update the `VITE_APPWRITE_OAUTH_*` variables.
|
||||||
|
|
||||||
|
In local dev, you can leave `VITE_APPWRITE_OAUTH_SUCCESS_URL` and `VITE_APPWRITE_OAUTH_FAILURE_URL` blank. The app will use the current browser origin automatically, which avoids getting redirected to a stale Vite port.
|
||||||
|
|
||||||
|
If OAuth returns to the app but you are still logged out:
|
||||||
|
|
||||||
|
- Confirm the current browser origin is listed under Appwrite project platforms, for example `localhost` and `127.0.0.1`.
|
||||||
|
- Confirm the same origin is allowed in the OAuth provider success/failure URLs.
|
||||||
|
- Clear old sessions/cookies for the local app and try again.
|
||||||
|
- Restart Vite after editing `.env.local`.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
Appwrite currently uses newer Console wording in many places:
|
||||||
|
|
||||||
|
| In this app / older SDK wording | Current Appwrite Console wording |
|
||||||
|
| --- | --- |
|
||||||
|
| Collection | Table |
|
||||||
|
| Attribute | Column |
|
||||||
|
| Document | Row |
|
||||||
|
|
||||||
|
So if the Console asks you to create a **table**, that is the same resource as the `VITE_APPWRITE_COLLECTION_ID` this app currently points at. If the setup below says **attributes**, add them as **columns** inside that table.
|
||||||
|
|
||||||
|
The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID.
|
||||||
|
|
||||||
|
Create a database with ID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
redbull_tracker
|
||||||
```
|
```
|
||||||
|
|
||||||
## deployment-only files
|
Create a collection with ID:
|
||||||
|
|
||||||
The repo ignores `.deploy/` and local public HTML pages.
|
```text
|
||||||
|
intake_entries
|
||||||
|
```
|
||||||
|
|
||||||
For your own deployment, create:
|
Enable document-level permissions on the collection.
|
||||||
|
|
||||||
- `.deploy/head.html` for analytics or other head-only snippets
|
Recommended collection-level permissions:
|
||||||
- `.deploy/body-end.html` for footer links or deploy-only markup
|
|
||||||
- any local public HTML pages your host needs
|
|
||||||
|
|
||||||
Vite injects the optional `.deploy` snippets into `index.html` at build time.
|
- 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.
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
# Project Overview
|
|
||||||
|
|
||||||
Red Bull Intake Tracker is a premium web-based tracking dashboard designed for tracking caffeine
|
|
||||||
and beverage consumption, with a strong focus on Red Bull products. Built using React, Vite, and
|
|
||||||
TypeScript, it allows users to record intake (amount, flavour, size, price, timestamp, and location),
|
|
||||||
monitor daily spending and caffeine limits, view structured trends and streaks, import/export
|
|
||||||
data via styled Excel or JSON formats, and engage in real-time encrypted-compatible dialogue with an
|
|
||||||
AI wellness coach powered by a serverless Ollama proxy endpoint. Synchronized dynamically with
|
|
||||||
Appwrite Cloud databases using secure row-level document permissions, it delivers a highly reactive,
|
|
||||||
personalized, and privacy-first self-tracking experience.
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
|
|
||||||
- `api/` – Contains serverless backend handlers, including the API gateway proxy for Ollama chat endpoints.
|
|
||||||
- `dist/` – Contains static HTML, JavaScript, and CSS bundle files output by the production build process.
|
|
||||||
- `node_modules/` – Stores third-party library dependencies and packages managed by npm.
|
|
||||||
- `scripts/` – Houses automation scripts, including database schema configuration tools for Appwrite.
|
|
||||||
- `src/` – Contains client-side React source code, components, utility models, and stylesheets.
|
|
||||||
- `src/components/` – Reusable UI panel elements, forms, and splash screen wrappers.
|
|
||||||
- `src/data/` – Static configurations, including theme lists and built-in flavours mapping.
|
|
||||||
- `src/lib/` – Business logic engines for calculations, file parsers, and Appwrite client connections.
|
|
||||||
|
|
||||||
## Build & Development Commands
|
|
||||||
|
|
||||||
Use the following shell-ready commands to install dependencies, run the application, lint the code,
|
|
||||||
and manage the cloud database.
|
|
||||||
|
|
||||||
### Dependency Installation
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local Development Server
|
|
||||||
Starts a local development server at `http://localhost:5173`.
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Build & Bundling
|
|
||||||
Performs TypeScript diagnostic type-checks and compiles the application into the `dist/` directory.
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Preview
|
|
||||||
Runs a local web server to preview the built production bundle.
|
|
||||||
```bash
|
|
||||||
npm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Linting
|
|
||||||
Runs ESLint over TypeScript files to identify syntax issues and code style warnings.
|
|
||||||
```bash
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Appwrite Cloud Database Setup
|
|
||||||
Automatically provisions the databases, tables, columns, and indexes on the configured Appwrite instance.
|
|
||||||
```bash
|
|
||||||
npm run setup:appwrite
|
|
||||||
```
|
|
||||||
|
|
||||||
### Automated Testing
|
|
||||||
> TODO: Add automated test suite command (e.g., `npm run test` using Vitest or Jest).
|
|
||||||
|
|
||||||
### Application Deployment
|
|
||||||
> TODO: Add production deployment pipeline command (e.g., Vercel, Netlify, or Docker deploy).
|
|
||||||
|
|
||||||
## Code Style & Conventions
|
|
||||||
|
|
||||||
- **Language**: TypeScript is strictly required for all UI components and logic scripts; JavaScript
|
|
||||||
is limited to serverless handlers and build scripting.
|
|
||||||
- **Strict Checks**: TypeScript's `strict` compiler option is enabled; avoid using `any` and ensure
|
|
||||||
all parameters and return values are explicitly typed.
|
|
||||||
- **Formatting**: Code should be formatted with 2-space indentation, trailing commas where supported,
|
|
||||||
and double quotes for JSX/TSX properties.
|
|
||||||
- **Linting**: Rules are governed by ESLint (`eslint.config.js`), extending the standard TypeScript
|
|
||||||
and React Hooks rulesets.
|
|
||||||
- **Naming Conventions**:
|
|
||||||
- React components and files use PascalCase (e.g., `CoachPanel.tsx`).
|
|
||||||
- Business logic, utilities, and helper hooks use camelCase (e.g., `appwriteEntries.ts`, `useCoachSession.ts`).
|
|
||||||
- Constants and static metadata arrays use UPPER_SNAKE_CASE (e.g., `BUILT_IN_FLAVOURS`).
|
|
||||||
- **Imports**: Prefer explicit ES module imports (`import { ... } from "..."`). Avoid wildcards.
|
|
||||||
- **Commit Messages**:
|
|
||||||
- > TODO: Define commit message guidelines and templates (e.g., Conventional Commits).
|
|
||||||
|
|
||||||
## Architecture Notes
|
|
||||||
|
|
||||||
### Flow Architecture Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph Frontend [Vite React Client]
|
|
||||||
App[App.tsx - Core State & Shell]
|
|
||||||
Components[Onboarding, CoachPanel, Limits, etc.]
|
|
||||||
LibMetrics[metrics.ts & userLimits.ts]
|
|
||||||
LibIO[excel.ts & storage.ts]
|
|
||||||
LibTheme[themeTokens.ts & themes.ts]
|
|
||||||
HookCoach[useCoachSession.ts]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph BackendAPI [API & Proxies]
|
|
||||||
ViteProxy[Vite Dev Server Middleware]
|
|
||||||
VercelHandler[api/ollama-chat.js Serverless Function]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph External [External Services]
|
|
||||||
AppwriteCloud[Appwrite Cloud / TablesDB]
|
|
||||||
OllamaAPI[Ollama Cloud API]
|
|
||||||
end
|
|
||||||
|
|
||||||
App --> Components
|
|
||||||
App --> LibMetrics
|
|
||||||
App --> LibIO
|
|
||||||
App --> LibTheme
|
|
||||||
App --> HookCoach
|
|
||||||
|
|
||||||
HookCoach -- "/api/ollama-chat (Local)" --> ViteProxy
|
|
||||||
HookCoach -- "/api/ollama-chat (Prod)" --> VercelHandler
|
|
||||||
|
|
||||||
ViteProxy -- "Headers Auth" --> OllamaAPI
|
|
||||||
VercelHandler -- "Headers Auth" --> OllamaAPI
|
|
||||||
|
|
||||||
App --> AppwriteCloud
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Roles & Data Flow
|
|
||||||
|
|
||||||
1. **State Orchestration (`src/App.tsx`)**:
|
|
||||||
Acts as the monolithic hub of the frontend. It manages user authentication states, currently
|
|
||||||
selected views, active database operations, modals, onboarding triggers, and theme settings.
|
|
||||||
|
|
||||||
2. **Metrics & Limits (`src/lib/metrics.ts`, `src/lib/userLimits.ts`)**:
|
|
||||||
Process raw intakes to extract total cans, spendings, caffeine absorption, hydration estimates,
|
|
||||||
streaks, and coordinate warnings when user limits are breached or bedtime approaches.
|
|
||||||
|
|
||||||
3. **External Data Codecs (`src/lib/excel.ts`, `src/lib/storage.ts`)**:
|
|
||||||
Implement styled spreadsheet formatting with `exceljs`, data sanity validation, duplicate-aware
|
|
||||||
import preview engines, and local JSON backup/restore modules.
|
|
||||||
|
|
||||||
4. **Dynamic Styling (`src/lib/themeTokens.ts`, `src/data/themes.ts`)**:
|
|
||||||
Computes CSS tokens dynamically from a selection of Vocaloid or beverage-themed configurations,
|
|
||||||
writing variables into the document root for real-time visual styling modifications.
|
|
||||||
|
|
||||||
5. **Cloud Synced State (`src/lib/appwrite.ts`, `src/lib/appwriteEntries.ts`, `src/lib/coachChats.ts`)**:
|
|
||||||
Establishes client tunnels to Appwrite's serverless TablesDB backend, running row-secured
|
|
||||||
CRUD actions bound strictly to the current `userId`.
|
|
||||||
|
|
||||||
6. **AI Coach Chatbot (`src/lib/useCoachSession.ts`, `api/ollama-chat.js`)**:
|
|
||||||
A state-machine custom hook that pipelines user questions, injects historical intake aggregates,
|
|
||||||
and streams responses from DeepSeek through server-side Ollama proxy tunnels.
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
The repository does not currently feature automated test files. Testing is executed manually.
|
|
||||||
|
|
||||||
### Unit & Integration Testing
|
|
||||||
- > TODO: Configure unit and integration tests (e.g., Vitest + React Testing Library) to validate
|
|
||||||
metrics computations, limits checks, and file importing codecs.
|
|
||||||
|
|
||||||
### End-to-End (E2E) Testing
|
|
||||||
- > TODO: Introduce E2E test suites (e.g., Playwright or Cypress) to cover authentication paths,
|
|
||||||
entry additions, theme switches, and chatbot conversation loops.
|
|
||||||
|
|
||||||
### Continuous Integration (CI)
|
|
||||||
- > TODO: Establish a GitHub Actions workflow pipeline to run linters, type checks, and tests on
|
|
||||||
every branch commit or pull request.
|
|
||||||
|
|
||||||
## Security & Compliance
|
|
||||||
|
|
||||||
- **Authentication**: Delegated entirely to Appwrite's built-in OAuth/Email-password protocols.
|
|
||||||
No user passwords or direct login credentials are saved inside the application state.
|
|
||||||
- **Client Security**: Client-side application calls only the Appwrite browser SDK. No administrative
|
|
||||||
or server-level API keys are ever shipped or exposed to the client.
|
|
||||||
- **Database Row Security**: All Appwrite tables have `Row Security` enabled. Users are granted
|
|
||||||
`create` permissions on the table level, but read, update, and delete actions require explicit
|
|
||||||
document permissions matching the user's specific ID (`user:{userId}`).
|
|
||||||
- **LLM API Security**: To avoid key exposure, the client connects to the proxy path `/api/ollama-chat`.
|
|
||||||
The secret `OLLAMA_API_KEY` is maintained exclusively in secure server-side environment variables.
|
|
||||||
- **Dependency Auditing**:
|
|
||||||
- > TODO: Add automated dependency checking (e.g., `npm audit` or Dependabot) in the CI pipeline.
|
|
||||||
- **Software Licensing**:
|
|
||||||
- > TODO: Add a standard LICENSE file (e.g., MIT, Apache-2.0) to explicitly detail terms of reuse.
|
|
||||||
|
|
||||||
## Agent Guardrails
|
|
||||||
|
|
||||||
- **Environment File Preservation**: Never edit, modify, or commit variables directly inside
|
|
||||||
`.env.local` or `.env` templates unless explicitly instructed by the user.
|
|
||||||
- **Credential Safety**: Never add, write, or hardcode API keys, access secrets, project keys, or
|
|
||||||
personal tokens into the codebase or configuration files.
|
|
||||||
- **Safe Directory Boundaries**: Do not add, write, or alter files inside administrative, system, or
|
|
||||||
auto-generated directories like `.git`, `.gemini`, `dist`, or `node_modules`.
|
|
||||||
- **Monolithic State Warnings**: `src/App.tsx` contains the core layout and view engine. Exercise
|
|
||||||
extreme care when making adjustments to prevent breaking the view transitions, auth hooks, or modals.
|
|
||||||
- **Database Alignment Rules**: Always verify that any changes to DB record structures or types are
|
|
||||||
mirrored across `src/types.ts`, Appwrite modules (`src/lib/appwriteEntries.ts`, `src/lib/coachChats.ts`),
|
|
||||||
and the migration runner `scripts/setup-appwrite.mjs`.
|
|
||||||
|
|
||||||
## Extensibility Hooks
|
|
||||||
|
|
||||||
- **Flavour Extensions**: New built-in Red Bull flavours, accent colors, and sugar-free rules can be
|
|
||||||
easily appended to the `BUILT_IN_FLAVOURS` array in `src/data/flavours.ts`.
|
|
||||||
- **UI Custom Themes**: Additional visual themes, including Vocaloid, seasonal, or custom branding,
|
|
||||||
can be integrated by adding definitions to the `APP_THEMES` array in `src/data/themes.ts`.
|
|
||||||
- **Proxy Endpoint Rerouting**: The Ollama upstream proxy route in `vite.config.ts` and
|
|
||||||
`api/ollama-chat.js` can be adjusted to point to alternative LLM hosts or local server instances.
|
|
||||||
- **Configurable Environment Parameters**:
|
|
||||||
- `VITE_APPWRITE_ENDPOINT` – Base URL for the Appwrite API server.
|
|
||||||
- `VITE_APPWRITE_PROJECT_ID` – The Appwrite project instance identifier.
|
|
||||||
- `VITE_APPWRITE_DATABASE_ID` – TablesDB target database identifier.
|
|
||||||
- `VITE_APPWRITE_COLLECTION_ID` – Table ID containing intake documents.
|
|
||||||
- `VITE_APPWRITE_CHAT_COLLECTION_ID` – Table ID storing coach chatbot threads.
|
|
||||||
- `OLLAMA_API_KEY` – Administrative bearer authorization token for Ollama endpoints.
|
|
||||||
- `OLLAMA_MODEL` – Upstream model identifier (default: `deepseek-v4-pro:cloud`).
|
|
||||||
|
|
||||||
## Further Reading
|
|
||||||
|
|
||||||
- [Appwrite Platform Documentation](file:///Users/ned/Documents/GitHub/Red%20Bull%20Tracking%20System/APPWRITE_SETUP.md) – Detailed guide on database configuration, attributes, indexes, and row permissions.
|
|
||||||
- [Appwrite Admin Schema Migrations](file:///Users/ned/Documents/GitHub/Red%20Bull%20Tracking%20System/scripts/setup-appwrite.mjs) – Automated table creation and attributes loader script.
|
|
||||||
- > TODO: Put architecture decision records (ADRs) or technical whitepapers under a dedicated `/docs` directory.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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,134 +0,0 @@
|
|||||||
# 🐂 Red Bull Intake Tracker
|
|
||||||
|
|
||||||
Track your Red Bull consumption with per-can logging, barcode scanning, spending insights, and a coach. Built with React, Appwrite, and Material You theming.
|
|
||||||
|
|
||||||
   
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Quick logging** — tap a flavour, pick a size, done. Cans are tracked with timestamp, price, and store
|
|
||||||
- **Barcode scanning** — scan any Red Bull can (EAN-13/EAN-8/UPC-A) and it auto-fills flavour, size, and caffeine. 475+ verified barcodes built in, with user overrides
|
|
||||||
- **20 built-in flavours** — Original, Zero, Ruby, Tropical, Dragon Fruit, and more, each with its own accent colour
|
|
||||||
- **Daily limits** — set max cans/day, max spend/day, and a cut-off time. Get warned when you're about to breach
|
|
||||||
- **Charts & analytics** — intake over time, flavour breakdown (pie chart), spending trends, caffeine metrics
|
|
||||||
- **Import** — bulk import from Excel (.xlsx) or JSON, with duplicate detection and row-level error preview
|
|
||||||
- **Export** — download your data as Excel or JSON anytime
|
|
||||||
- **Material You theming** — every flavour gets its own dynamic colour palette. Dark mode included
|
|
||||||
- **Onboarding flow** — guided setup for new users with limit preferences
|
|
||||||
- **Appwrite auth** — email/password login, row-level security per user
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Tech |
|
|
||||||
|-------|------|
|
|
||||||
| Frontend | React 18, TypeScript, Vite |
|
|
||||||
| Styling | Tailwind CSS, Framer Motion |
|
|
||||||
| Charts | Recharts |
|
|
||||||
| Backend | Appwrite Cloud (auth, database, storage) |
|
|
||||||
| Barcode | @zxing/browser |
|
|
||||||
| Import/Export | ExcelJS |
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- An [Appwrite Cloud](https://cloud.appwrite.io) project (free tier works)
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
1. Clone and install:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/nh9961/Red-Bull-Tracker.git
|
|
||||||
cd Red-Bull-Tracker
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Copy the environment template:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Fill in your Appwrite credentials in `.env.local` (see [Appwrite Setup](APPWRITE_SETUP.md) for full instructions)
|
|
||||||
|
|
||||||
4. Create the database and collections:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APPWRITE_API_KEY=your-admin-key npm run setup:appwrite
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Start the dev server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The app runs at `http://localhost:5173`.
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Required | Description |
|
|
||||||
|----------|----------|-------------|
|
|
||||||
| `VITE_APPWRITE_ENDPOINT` | Yes | Appwrite endpoint (e.g. `https://fra.cloud.appwrite.io/v1`) |
|
|
||||||
| `VITE_APPWRITE_PROJECT_ID` | Yes | Your Appwrite project ID |
|
|
||||||
| `VITE_APPWRITE_DATABASE_ID` | Yes | Database ID (default: `redbull_tracker`) |
|
|
||||||
| `VITE_APPWRITE_COLLECTION_ID` | Yes | Intake entries collection ID |
|
|
||||||
| `VITE_OLLAMA_PROXY_URL` | No | Proxy endpoint |
|
|
||||||
| `OLLAMA_API_KEY` | No | Server-side API key |
|
|
||||||
| `OLLAMA_MODEL` | No | Model for coach |
|
|
||||||
| `APPWRITE_API_KEY` | No | Admin key for `setup:appwrite` script only |
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── App.tsx # Main app shell, routing, layout
|
|
||||||
├── components/
|
|
||||||
│ ├── BarcodeScannerModal.tsx # Camera barcode scanner
|
|
||||||
│ ├── BarcodeProductPreview.tsx
|
|
||||||
│ ├── CoachPanel.tsx # Coach chat UI
|
|
||||||
│ ├── DailyLimitsCard.tsx # Limit status & warnings
|
|
||||||
│ ├── LimitsSettingsForm.tsx
|
|
||||||
│ └── OnboardingScreen.tsx
|
|
||||||
├── data/
|
|
||||||
│ ├── flavours.ts # 20 built-in flavour definitions
|
|
||||||
│ ├── themes.ts # Material You theme tokens per flavour
|
|
||||||
│ ├── barcodes.ts
|
|
||||||
│ └── verified-barcodes.json # 475+ verified product barcodes
|
|
||||||
├── lib/
|
|
||||||
│ ├── appwrite.ts # Appwrite client init
|
|
||||||
│ ├── appwriteEntries.ts # CRUD for intake entries
|
|
||||||
│ ├── appwriteBarcodes.ts # Barcode product storage
|
|
||||||
│ ├── barcodeLookup.ts # Multi-source barcode resolution
|
|
||||||
│ ├── barcodeScanner.ts # @zxing scanner wrapper
|
|
||||||
│ ├── userBarcodeMappings.ts # Per-user barcode overrides
|
|
||||||
│ ├── coachChats.ts # Chat persistence
|
|
||||||
│ ├── useCoachSession.ts # Chat hook
|
|
||||||
│ ├── userLimits.ts # Daily limit logic
|
|
||||||
│ ├── metrics.ts # Computed stats & charts
|
|
||||||
│ ├── excel.ts # Excel import/export
|
|
||||||
│ ├── storage.ts # Local storage helpers
|
|
||||||
│ ├── themeTokens.ts # Dynamic theme generation
|
|
||||||
│ └── greeting.ts
|
|
||||||
└── types.ts # All TypeScript types
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `npm run dev` | Start Vite dev server |
|
|
||||||
| `npm run build` | Type-check and build for production |
|
|
||||||
| `npm run preview` | Preview production build |
|
|
||||||
| `npm run lint` | ESLint check |
|
|
||||||
| `npm run setup:appwrite` | Create/update Appwrite database resources |
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Built by [Ned Halksworth](https://github.com/nh9961)
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/* global Buffer, fetch, process */
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.end("Method not allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.OLLAMA_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end("OLLAMA_API_KEY is not configured on the server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await readJson(req);
|
||||||
|
const upstream = await fetch("https://ollama.com/api/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
model: payload.model || process.env.OLLAMA_MODEL || DEFAULT_MODEL,
|
||||||
|
stream: payload.stream !== false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.statusCode = upstream.status;
|
||||||
|
res.setHeader("Content-Type", upstream.headers.get("content-type") || "application/x-ndjson");
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
res.end(await upstream.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstream.body) {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = upstream.body.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
res.write(Buffer.from(value));
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(error instanceof Error ? error.message : "Ollama proxy failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(req) {
|
||||||
|
if (req.body && typeof req.body === "object") return req.body;
|
||||||
|
if (typeof req.body === "string") return JSON.parse(req.body || "{}");
|
||||||
|
|
||||||
|
let raw = "";
|
||||||
|
for await (const chunk of req) raw += chunk;
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A local-first Red Bull intake web app for tracking cans, spending, caffeine, sugar, flavours, and trends."
|
content="A local-first Red Bull intake web app for tracking cans, spending, caffeine, sugar, flavours, and trends."
|
||||||
|
|||||||
Generated
+39
-75
@@ -7,11 +7,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "red-bull-intake-tracker",
|
"name": "red-bull-intake-tracker",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@undecaf/barcode-detector-polyfill": "^0.9.23",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@zxing/browser": "^0.2.0",
|
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
@@ -1161,6 +1158,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1174,6 +1174,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1187,6 +1190,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1200,6 +1206,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1213,6 +1222,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1226,6 +1238,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1239,6 +1254,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1252,6 +1270,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1265,6 +1286,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1278,6 +1302,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1291,6 +1318,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1304,6 +1334,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1317,6 +1350,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1842,24 +1878,6 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@undecaf/barcode-detector-polyfill": {
|
|
||||||
"version": "0.9.23",
|
|
||||||
"resolved": "https://registry.npmjs.org/@undecaf/barcode-detector-polyfill/-/barcode-detector-polyfill-0.9.23.tgz",
|
|
||||||
"integrity": "sha512-qVr7jSUbE5a30X9dByDym2NzsqyH+MFwyFiu4QSHDQMLCImTJj/et7pEcOtGqlL4UB5J6J3d0hK4/5d4MMowYA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@undecaf/zbar-wasm": "^0.9.16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@undecaf/zbar-wasm": {
|
|
||||||
"version": "0.9.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/@undecaf/zbar-wasm/-/zbar-wasm-0.9.16.tgz",
|
|
||||||
"integrity": "sha512-T5PcT6g+tLScGjR4WmnRErNvfKqEc3kRg2ux14wHmIDNbvNeXa0BkFK19PRK/jb6zGy5NyWtn4ko6KeNuZc/fQ==",
|
|
||||||
"license": "LGPL-2.1+",
|
|
||||||
"dependencies": {
|
|
||||||
"jschardet": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1880,41 +1898,6 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -3602,15 +3585,6 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jschardet": {
|
|
||||||
"version": "3.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.1.4.tgz",
|
|
||||||
"integrity": "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg==",
|
|
||||||
"license": "LGPL-2.1+",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.1.90"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
@@ -5105,16 +5079,6 @@
|
|||||||
"typescript": ">=4.8.4"
|
"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": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"name": "red-bull-intake-tracker",
|
"name": "red-bull-intake-tracker",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -12,9 +11,7 @@
|
|||||||
"setup:appwrite": "node scripts/setup-appwrite.mjs"
|
"setup:appwrite": "node scripts/setup-appwrite.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@undecaf/barcode-detector-polyfill": "^0.9.23",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@zxing/browser": "^0.2.0",
|
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
|
|||||||
+24
-56
@@ -1,7 +1,6 @@
|
|||||||
/* global console, fetch, process, setTimeout */
|
/* global console, fetch, process, setTimeout */
|
||||||
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { URL } from "node:url";
|
|
||||||
|
|
||||||
const env = loadEnvFiles([".env", ".env.local"]);
|
const env = loadEnvFiles([".env", ".env.local"]);
|
||||||
|
|
||||||
@@ -9,11 +8,8 @@ const endpoint = readEnv("VITE_APPWRITE_ENDPOINT", "https://fra.cloud.appwrite.i
|
|||||||
const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138");
|
const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138");
|
||||||
const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
|
const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
|
||||||
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
|
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
|
||||||
const barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products");
|
const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats");
|
||||||
const apiKey = readEnv("APPWRITE_API_KEY", "");
|
const apiKey = readEnv("APPWRITE_API_KEY", "");
|
||||||
const verifiedBarcodeProducts = JSON.parse(
|
|
||||||
readFileSync(new URL("../src/data/verified-barcodes.json", import.meta.url), "utf8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_.");
|
throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_.");
|
||||||
@@ -44,30 +40,25 @@ await ensureTable({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
await ensureTable({
|
await ensureTable({
|
||||||
tableId: barcodeTableId,
|
tableId: chatTableId,
|
||||||
name: "Barcode products",
|
name: "Coach chats",
|
||||||
columns: [
|
columns: [
|
||||||
{ kind: "string", key: "scope", size: 16, required: true },
|
{ kind: "string", key: "userId", size: 64, required: true },
|
||||||
{ kind: "string", key: "ownerUserId", size: 64, required: false },
|
{ kind: "string", key: "title", size: 512, required: true },
|
||||||
{ kind: "string", key: "barcode", size: 32, required: true },
|
{ kind: "longtext", key: "messages", required: true },
|
||||||
{ kind: "string", key: "flavourName", size: 128, required: true },
|
{ kind: "datetime", key: "updatedAt", required: true },
|
||||||
{ kind: "integer", key: "sizeMl", required: true },
|
|
||||||
{ kind: "float", key: "pricePerCan", required: true },
|
|
||||||
{ kind: "boolean", key: "sugarFree", required: true },
|
|
||||||
{ kind: "float", key: "caffeineMgPerCan", required: false },
|
|
||||||
{ kind: "string", key: "verifiedBy", size: 512, required: false },
|
|
||||||
{ kind: "string", key: "sourceName", size: 512, required: false },
|
|
||||||
{ kind: "string", key: "sourceUrl", size: 2048, required: false },
|
|
||||||
{ kind: "string", key: "variant", size: 64, required: false },
|
|
||||||
{ kind: "string", key: "notes", size: 2000, required: false },
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
{ key: "barcode", type: "key", columns: ["barcode"], orders: ["ASC"], lengths: [32] },
|
|
||||||
{ key: "scope_barcode", type: "key", columns: ["scope", "barcode"], orders: ["ASC", "ASC"], lengths: [16, 32] },
|
|
||||||
{ key: "user_barcode", type: "key", columns: ["ownerUserId", "barcode"], orders: ["ASC", "ASC"], lengths: [64, 32] },
|
|
||||||
],
|
],
|
||||||
|
indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }],
|
||||||
});
|
});
|
||||||
await seedVerifiedBarcodeProducts(barcodeTableId, verifiedBarcodeProducts);
|
await retireLegacyChatColumns(chatTableId, [
|
||||||
|
"encryptedTitle",
|
||||||
|
"encryptedMessages",
|
||||||
|
"titleIv",
|
||||||
|
"messagesIv",
|
||||||
|
"salt",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]);
|
||||||
|
|
||||||
console.log("Appwrite database and tables ready.");
|
console.log("Appwrite database and tables ready.");
|
||||||
|
|
||||||
@@ -130,45 +121,22 @@ async function ensureColumn(tableId, column) {
|
|||||||
array: false,
|
array: false,
|
||||||
};
|
};
|
||||||
if (column.size) body.size = column.size;
|
if (column.size) body.size = column.size;
|
||||||
|
if (column.encrypt) body.encrypt = true;
|
||||||
|
|
||||||
await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]);
|
await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]);
|
||||||
console.log(`Column ${tableId}.${column.key} created.`);
|
console.log(`Column ${tableId}.${column.key} created.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedVerifiedBarcodeProducts(tableId, products) {
|
async function retireLegacyChatColumns(tableId, keys) {
|
||||||
for (const [barcode, product] of Object.entries(products)) {
|
for (const key of keys) {
|
||||||
const rowId = `verified_${barcode}`;
|
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]);
|
||||||
const data = {
|
|
||||||
scope: "verified",
|
|
||||||
ownerUserId: "",
|
|
||||||
barcode,
|
|
||||||
flavourName: product.flavourName,
|
|
||||||
sizeMl: product.sizeMl,
|
|
||||||
pricePerCan: product.pricePerCan,
|
|
||||||
sugarFree: Boolean(product.sugarFree),
|
|
||||||
caffeineMgPerCan: product.caffeineMgPerCan,
|
|
||||||
verifiedBy: product.verifiedBy ?? "",
|
|
||||||
sourceName: product.sourceName ?? "",
|
|
||||||
sourceUrl: product.sourceUrl ?? "",
|
|
||||||
variant: product.variant ?? "",
|
|
||||||
notes: product.notes ?? "",
|
|
||||||
};
|
|
||||||
const path = `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`;
|
|
||||||
const existing = await request("GET", path, undefined, [200, 404]);
|
|
||||||
|
|
||||||
if (existing.status === 404) {
|
if (existing.status === 404) {
|
||||||
await request(
|
console.log(`Legacy column ${tableId}.${key} already removed.`);
|
||||||
"POST",
|
|
||||||
`/tablesdb/${databaseId}/tables/${tableId}/rows`,
|
|
||||||
{ rowId, data, permissions: ['read("users")'] },
|
|
||||||
[201],
|
|
||||||
);
|
|
||||||
console.log(`Verified barcode ${barcode} seeded.`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await request("PUT", path, { data, permissions: ['read("users")'] }, [200]);
|
await request("DELETE", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [204, 404]);
|
||||||
console.log(`Verified barcode ${barcode} updated.`);
|
console.log(`Legacy column ${tableId}.${key} removed.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+591
-914
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
|||||||
import { Edit3, Plus, X } from "lucide-react";
|
|
||||||
import { currency, wholeNumber } from "../lib/metrics";
|
|
||||||
import { productCaffeineMg } from "../lib/barcodeLookup";
|
|
||||||
import type { ResolvedBarcodeProduct } from "../types";
|
|
||||||
|
|
||||||
export function BarcodeProductPreview({
|
|
||||||
barcode,
|
|
||||||
busy,
|
|
||||||
product,
|
|
||||||
onAddNow,
|
|
||||||
onCancel,
|
|
||||||
onEdit,
|
|
||||||
}: {
|
|
||||||
barcode: string;
|
|
||||||
busy: boolean;
|
|
||||||
product: ResolvedBarcodeProduct;
|
|
||||||
onAddNow: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
}) {
|
|
||||||
const caffeineMg = productCaffeineMg(product);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className="rounded-3xl border border-cyan-200/20 bg-cyan-200/10 p-4 shadow-sm"
|
|
||||||
aria-labelledby="barcode-product-title"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span
|
|
||||||
className="mt-1 h-4 w-4 shrink-0 rounded-full shadow-sm"
|
|
||||||
style={{ backgroundColor: product.flavourAccent }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-100">Barcode matched</p>
|
|
||||||
<h3 id="barcode-product-title" className="mt-1 text-xl font-semibold tracking-tight text-white">
|
|
||||||
Found: Red Bull {product.flavourName}, {product.sizeMl}ml, {currency.format(product.pricePerCan)},{" "}
|
|
||||||
{wholeNumber.format(caffeineMg)}mg caffeine
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 break-all text-sm text-slate-300">Barcode {barcode}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
|
||||||
<button className="primary-button justify-center" type="button" onClick={onAddNow} disabled={busy}>
|
|
||||||
<Plus size={17} aria-hidden="true" />
|
|
||||||
Add now
|
|
||||||
</button>
|
|
||||||
<button className="secondary-button justify-center" type="button" onClick={onEdit} disabled={busy}>
|
|
||||||
<Edit3 size={17} aria-hidden="true" />
|
|
||||||
Edit before adding
|
|
||||||
</button>
|
|
||||||
<button className="secondary-button justify-center" type="button" onClick={onCancel} disabled={busy}>
|
|
||||||
<X size={17} aria-hidden="true" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react";
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
|
||||||
import {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type FormEvent,
|
|
||||||
} from "react";
|
|
||||||
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, flavourMeta } from "../data/flavours";
|
|
||||||
import {
|
|
||||||
barcodeProductToEntryDraft,
|
|
||||||
lookupBarcode,
|
|
||||||
normalizeBarcode,
|
|
||||||
productCaffeineMg,
|
|
||||||
resolveProduct,
|
|
||||||
} from "../lib/barcodeLookup";
|
|
||||||
import {
|
|
||||||
scannerErrorMessage,
|
|
||||||
startBarcodeScanner,
|
|
||||||
stopVideoStream,
|
|
||||||
type BarcodeScannerController,
|
|
||||||
type BarcodeScannerError,
|
|
||||||
type BarcodeScanResult,
|
|
||||||
} from "../lib/barcodeScanner";
|
|
||||||
import { listBarcodeCatalog, upsertCloudUserBarcodeMapping } from "../lib/appwriteBarcodes";
|
|
||||||
import { caffeinePerCan, currency, defaultPriceForSize, wholeNumber } from "../lib/metrics";
|
|
||||||
import {
|
|
||||||
loadUserBarcodeMappings,
|
|
||||||
upsertUserBarcodeMapping,
|
|
||||||
} from "../lib/userBarcodeMappings";
|
|
||||||
import type {
|
|
||||||
BarcodeLookupCatalog,
|
|
||||||
BarcodeProductDraft,
|
|
||||||
EntryDraft,
|
|
||||||
Flavour,
|
|
||||||
ResolvedBarcodeProduct,
|
|
||||||
UserBarcodeMapping,
|
|
||||||
} from "../types";
|
|
||||||
import { BarcodeProductPreview } from "./BarcodeProductPreview";
|
|
||||||
|
|
||||||
type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error";
|
|
||||||
|
|
||||||
export function BarcodeScannerModal({
|
|
||||||
busy,
|
|
||||||
flavours,
|
|
||||||
open,
|
|
||||||
userId,
|
|
||||||
onAddNow,
|
|
||||||
onClose,
|
|
||||||
onEditBeforeAdding,
|
|
||||||
}: {
|
|
||||||
busy: boolean;
|
|
||||||
flavours: Flavour[];
|
|
||||||
open: boolean;
|
|
||||||
userId: string;
|
|
||||||
onAddNow: (draft: EntryDraft) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onEditBeforeAdding: (draft: EntryDraft) => void;
|
|
||||||
}) {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const controllerRef = useRef<BarcodeScannerController | null>(null);
|
|
||||||
const barcodeCatalogRef = useRef<BarcodeLookupCatalog>({});
|
|
||||||
const lastScanRef = useRef<{ value: string; at: number } | null>(null);
|
|
||||||
const [phase, setPhase] = useState<ScannerPhase>("idle");
|
|
||||||
const [barcode, setBarcode] = useState("");
|
|
||||||
const [scannerMode, setScannerMode] = useState<BarcodeScannerController["mode"] | null>(null);
|
|
||||||
const [scannerError, setScannerError] = useState<BarcodeScannerError | null>(null);
|
|
||||||
const [product, setProduct] = useState<ResolvedBarcodeProduct | null>(null);
|
|
||||||
const [typedBarcode, setTypedBarcode] = useState("");
|
|
||||||
const [manualMessage, setManualMessage] = useState("");
|
|
||||||
const [selectedFlavour, setSelectedFlavour] = useState(DEFAULT_FLAVOUR.name);
|
|
||||||
const [sizePreset, setSizePreset] = useState("250");
|
|
||||||
const [customSize, setCustomSize] = useState("250");
|
|
||||||
const [pricePerCan, setPricePerCan] = useState(defaultPriceForSize(250).toFixed(2));
|
|
||||||
const [sugarFree, setSugarFree] = useState(Boolean(DEFAULT_FLAVOUR.sugarFree));
|
|
||||||
const [caffeineOverride, setCaffeineOverride] = useState("");
|
|
||||||
const [saveMapping, setSaveMapping] = useState(true);
|
|
||||||
const [mappingSaving, setMappingSaving] = useState(false);
|
|
||||||
|
|
||||||
const activeBarcode = barcode || normalizeBarcode(typedBarcode);
|
|
||||||
const numericSize = Math.max(1, sizePreset === "custom" ? Number(customSize) || 250 : Number(sizePreset));
|
|
||||||
const manualProduct = useMemo(
|
|
||||||
(): BarcodeProductDraft => ({
|
|
||||||
flavourName: selectedFlavour,
|
|
||||||
sizeMl: numericSize,
|
|
||||||
pricePerCan: Math.max(0, Number(pricePerCan) || 0),
|
|
||||||
sugarFree: sugarFree || Boolean(flavourMeta(selectedFlavour).sugarFree),
|
|
||||||
caffeineMgPerCan: caffeineOverride.trim() ? Math.max(0, Number(caffeineOverride) || 0) : undefined,
|
|
||||||
}),
|
|
||||||
[caffeineOverride, numericSize, pricePerCan, selectedFlavour, sugarFree],
|
|
||||||
);
|
|
||||||
const manualCaffeine = productCaffeineMg(manualProduct);
|
|
||||||
|
|
||||||
const stopScanner = useCallback(() => {
|
|
||||||
controllerRef.current?.stop();
|
|
||||||
controllerRef.current = null;
|
|
||||||
stopVideoStream(videoRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyManualDefaults = useCallback((draft?: BarcodeProductDraft) => {
|
|
||||||
const flavour = draft?.flavourName && BUILT_IN_FLAVOURS.some((item) => item.name === draft.flavourName)
|
|
||||||
? draft.flavourName
|
|
||||||
: DEFAULT_FLAVOUR.name;
|
|
||||||
const size = draft?.sizeMl ?? 250;
|
|
||||||
const isStandardSize = size === 250 || size === 355 || size === 473;
|
|
||||||
const meta = flavourMeta(flavour);
|
|
||||||
|
|
||||||
setSelectedFlavour(flavour);
|
|
||||||
setSizePreset(isStandardSize ? size.toString() : "custom");
|
|
||||||
setCustomSize(size.toString());
|
|
||||||
setPricePerCan((draft?.pricePerCan ?? defaultPriceForSize(size)).toFixed(2));
|
|
||||||
setSugarFree(draft?.sugarFree ?? Boolean(meta.sugarFree));
|
|
||||||
setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? "");
|
|
||||||
setSaveMapping(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resolveBarcodeValue = useCallback(
|
|
||||||
(rawValue: string) => {
|
|
||||||
const normalized = normalizeBarcode(rawValue);
|
|
||||||
if (!normalized) {
|
|
||||||
setScannerError({ code: "unsupported", message: scannerErrorMessage("unsupported") });
|
|
||||||
setPhase("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookup = lookupBarcode(normalized, barcodeCatalogRef.current);
|
|
||||||
setBarcode(normalized);
|
|
||||||
setTypedBarcode(normalized);
|
|
||||||
stopScanner();
|
|
||||||
|
|
||||||
if (lookup.status === "known" || lookup.status === "user") {
|
|
||||||
setProduct(lookup.product);
|
|
||||||
setManualMessage("");
|
|
||||||
setPhase("found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProduct(null);
|
|
||||||
applyManualDefaults(lookup.status === "partial" ? lookup.product : undefined);
|
|
||||||
setManualMessage(
|
|
||||||
lookup.status === "partial"
|
|
||||||
? lookup.reason
|
|
||||||
: "Barcode found, but this product is not mapped yet. Add the drink details once and future scans can reuse them.",
|
|
||||||
);
|
|
||||||
setPhase("manual");
|
|
||||||
},
|
|
||||||
[applyManualDefaults, stopScanner],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleScannerResult = useCallback(
|
|
||||||
(result: BarcodeScanResult) => {
|
|
||||||
const normalized = normalizeBarcode(result.value);
|
|
||||||
const lastScan = lastScanRef.current;
|
|
||||||
const now = Date.now();
|
|
||||||
if (!normalized || (lastScan?.value === normalized && now - lastScan.at < 1_500)) return;
|
|
||||||
lastScanRef.current = { value: normalized, at: now };
|
|
||||||
resolveBarcodeValue(normalized);
|
|
||||||
},
|
|
||||||
[resolveBarcodeValue],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleScannerError = useCallback(
|
|
||||||
(error: BarcodeScannerError) => {
|
|
||||||
stopScanner();
|
|
||||||
setScannerError(error);
|
|
||||||
setPhase("error");
|
|
||||||
},
|
|
||||||
[stopScanner],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
stopScanner();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localMappings = loadUserBarcodeMappings(userId);
|
|
||||||
barcodeCatalogRef.current = { userMappings: localMappings };
|
|
||||||
lastScanRef.current = null;
|
|
||||||
setPhase("starting");
|
|
||||||
setScannerError(null);
|
|
||||||
setBarcode("");
|
|
||||||
setTypedBarcode("");
|
|
||||||
setProduct(null);
|
|
||||||
setManualMessage("");
|
|
||||||
setMappingSaving(false);
|
|
||||||
applyManualDefaults();
|
|
||||||
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
let frameId = 0;
|
|
||||||
|
|
||||||
const startScanner = () => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video || !active) return;
|
|
||||||
|
|
||||||
void startBarcodeScanner(video, handleScannerResult, handleScannerError)
|
|
||||||
.then((controller) => {
|
|
||||||
if (!active) {
|
|
||||||
controller.stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controllerRef.current = controller;
|
|
||||||
setScannerMode(controller.mode);
|
|
||||||
setPhase("scanning");
|
|
||||||
})
|
|
||||||
.catch((error: BarcodeScannerError) => {
|
|
||||||
if (!active) return;
|
|
||||||
setScannerError(error);
|
|
||||||
setPhase("error");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
frameId = window.requestAnimationFrame(() => {
|
|
||||||
window.requestAnimationFrame(startScanner);
|
|
||||||
});
|
|
||||||
|
|
||||||
void listBarcodeCatalog()
|
|
||||||
.then((catalog) => {
|
|
||||||
if (!active) return;
|
|
||||||
barcodeCatalogRef.current = {
|
|
||||||
verifiedProducts: hasVerifiedProducts(catalog) ? catalog.verifiedProducts : undefined,
|
|
||||||
userMappings: mergeUserMappings(localMappings, catalog.userMappings ?? []),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
barcodeCatalogRef.current = { userMappings: localMappings };
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
window.cancelAnimationFrame(frameId);
|
|
||||||
stopScanner();
|
|
||||||
};
|
|
||||||
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return undefined;
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [onClose, open]);
|
|
||||||
|
|
||||||
function submitTypedBarcode(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
resolveBarcodeValue(typedBarcode);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveManualProduct(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
const normalized = normalizeBarcode(activeBarcode);
|
|
||||||
if (!normalized) {
|
|
||||||
setManualMessage("Enter the barcode number before saving a mapping.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMappingSaving(true);
|
|
||||||
try {
|
|
||||||
let mapping: UserBarcodeMapping | null = null;
|
|
||||||
let savedMessage = "";
|
|
||||||
if (saveMapping) {
|
|
||||||
try {
|
|
||||||
mapping = await upsertCloudUserBarcodeMapping(userId, normalized, manualProduct);
|
|
||||||
upsertUserBarcodeMapping(userId, normalized, manualProduct);
|
|
||||||
savedMessage = "Saved to Appwrite and cached locally for future scans.";
|
|
||||||
} catch {
|
|
||||||
mapping = upsertUserBarcodeMapping(userId, normalized, manualProduct);
|
|
||||||
savedMessage = "Saved locally for future scans on this device. Appwrite barcode sync is not available yet.";
|
|
||||||
}
|
|
||||||
barcodeCatalogRef.current = {
|
|
||||||
...barcodeCatalogRef.current,
|
|
||||||
userMappings: mergeUserMappings(
|
|
||||||
loadUserBarcodeMappings(userId),
|
|
||||||
mapping ? [mapping] : [],
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setBarcode(normalized);
|
|
||||||
setTypedBarcode(normalized);
|
|
||||||
setProduct(resolveProduct(manualProduct, mapping ? "user" : "built-in"));
|
|
||||||
setManualMessage(savedMessage);
|
|
||||||
setPhase("found");
|
|
||||||
} finally {
|
|
||||||
setMappingSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addProductNow(nextProduct: ResolvedBarcodeProduct) {
|
|
||||||
onAddNow(barcodeProductToEntryDraft(nextProduct, activeBarcode));
|
|
||||||
}
|
|
||||||
|
|
||||||
function editProductBeforeAdding(nextProduct: ResolvedBarcodeProduct) {
|
|
||||||
onEditBeforeAdding(barcodeProductToEntryDraft(nextProduct, activeBarcode));
|
|
||||||
}
|
|
||||||
|
|
||||||
const scannerStatus =
|
|
||||||
phase === "starting"
|
|
||||||
? "Starting camera..."
|
|
||||||
: phase === "scanning"
|
|
||||||
? `Scanning${scannerMode ? ` with ${scannerMode === "native" ? "native detector" : "ZXing fallback"}` : ""}...`
|
|
||||||
: "Scanner paused";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{open && (
|
|
||||||
<motion.div
|
|
||||||
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 }}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="barcode-scanner-title"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="modal-panel max-w-4xl"
|
|
||||||
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
|
||||||
transition={{ duration: 0.22 }}
|
|
||||||
>
|
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<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="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" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
|
||||||
<section className="grid gap-3">
|
|
||||||
<div className="relative overflow-hidden rounded-3xl border border-cyan-200/20 bg-black shadow-2xl">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
className="aspect-[3/4] w-full bg-black object-cover sm:aspect-video"
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
aria-label="Live camera preview"
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="h-28 w-[78%] max-w-sm rounded-2xl border-2 border-cyan-200/90 shadow-[0_0_0_999px_rgba(0,0,0,0.28),0_0_32px_rgba(125,231,255,0.35)]" />
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-x-4 bottom-4 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/60 px-3 py-2 text-sm text-white backdrop-blur">
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
{phase === "starting" ? (
|
|
||||||
<Loader2 className="animate-spin text-cyan-100" size={16} aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<ScanLine className="text-cyan-100" size={16} aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
{scannerStatus}
|
|
||||||
</span>
|
|
||||||
<span className="hidden text-xs text-slate-300 sm:inline">EAN/UPC</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="rounded-3xl border border-white/10 bg-white/[0.05] p-3" onSubmit={submitTypedBarcode}>
|
|
||||||
<label className="field-label">
|
|
||||||
Type barcode instead
|
|
||||||
<span className="flex flex-col gap-2 sm:flex-row">
|
|
||||||
<input
|
|
||||||
className="field-control"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
placeholder="EAN or UPC number"
|
|
||||||
value={typedBarcode}
|
|
||||||
onChange={(event) => setTypedBarcode(event.target.value)}
|
|
||||||
/>
|
|
||||||
<button className="secondary-button shrink-0 justify-center" type="submit">
|
|
||||||
<Keyboard size={17} aria-hidden="true" />
|
|
||||||
Lookup
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid content-start gap-3">
|
|
||||||
{phase === "starting" || phase === "scanning" ? (
|
|
||||||
<div className="rounded-3xl border border-white/10 bg-white/[0.05] p-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-cyan-200/20 bg-cyan-200/10 text-cyan-100">
|
|
||||||
<Camera size={22} aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-4 text-lg font-semibold text-white">Searching for a retail barcode</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-300">
|
|
||||||
Hold the can steady inside the frame. The camera will stop automatically after a match.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{phase === "error" && (
|
|
||||||
<div className="rounded-3xl border border-amber-300/30 bg-amber-300/10 p-4 text-amber-50">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertTriangle className="mt-0.5 shrink-0" size={20} aria-hidden="true" />
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-white">Scanner unavailable</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6">{scannerError?.message ?? scannerErrorMessage("unknown")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === "manual" && (
|
|
||||||
<form className="rounded-3xl border border-white/10 bg-white/[0.05] p-4" onSubmit={saveManualProduct}>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-100">Unknown barcode</p>
|
|
||||||
<h3 className="mt-1 break-all text-xl font-semibold text-white">{activeBarcode || "No barcode entered"}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-300">{manualMessage}</p>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
||||||
<label className="field-label">
|
|
||||||
Flavour
|
|
||||||
<select
|
|
||||||
className="field-control"
|
|
||||||
value={selectedFlavour}
|
|
||||||
onChange={(event) => {
|
|
||||||
const flavour = event.target.value;
|
|
||||||
setSelectedFlavour(flavour);
|
|
||||||
setSugarFree(Boolean(flavourMeta(flavour).sugarFree));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flavours.map((flavour) => (
|
|
||||||
<option key={flavour.name} value={flavour.name}>
|
|
||||||
{flavour.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field-label">
|
|
||||||
Can size
|
|
||||||
<select
|
|
||||||
className="field-control"
|
|
||||||
value={sizePreset}
|
|
||||||
onChange={(event) => {
|
|
||||||
const next = event.target.value;
|
|
||||||
setSizePreset(next);
|
|
||||||
if (next !== "custom") {
|
|
||||||
const size = Number(next);
|
|
||||||
setCustomSize(next);
|
|
||||||
setPricePerCan(defaultPriceForSize(size).toFixed(2));
|
|
||||||
setCaffeineOverride("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="250">250ml</option>
|
|
||||||
<option value="355">355ml</option>
|
|
||||||
<option value="473">473ml</option>
|
|
||||||
<option value="custom">Custom</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{sizePreset === "custom" && (
|
|
||||||
<>
|
|
||||||
<label className="field-label">
|
|
||||||
Custom size in ml
|
|
||||||
<input className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label className="field-label">
|
|
||||||
Caffeine mg/can
|
|
||||||
<input
|
|
||||||
className="field-control"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
type="number"
|
|
||||||
value={caffeineOverride}
|
|
||||||
onChange={(event) => setCaffeineOverride(event.target.value)}
|
|
||||||
placeholder={wholeNumber.format(caffeinePerCan(numericSize))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="field-label">
|
|
||||||
Price
|
|
||||||
<input className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-cyan-200/20 bg-cyan-200/10 px-3 py-3 text-sm text-cyan-50">
|
|
||||||
Estimated caffeine: {wholeNumber.format(manualCaffeine)}mg
|
|
||||||
<br />
|
|
||||||
Price: {currency.format(manualProduct.pricePerCan)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-3 text-sm text-slate-200 sm:col-span-2">
|
|
||||||
<input className="h-4 w-4" type="checkbox" checked={sugarFree} onChange={(event) => setSugarFree(event.target.checked)} />
|
|
||||||
Count this product as sugar-free / zero sugar
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-3 text-sm text-slate-200 sm:col-span-2">
|
|
||||||
<input className="h-4 w-4" type="checkbox" checked={saveMapping} onChange={(event) => setSaveMapping(event.target.checked)} />
|
|
||||||
Save this barcode mapping locally for future scans
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
||||||
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="primary-button justify-center" type="submit" disabled={mappingSaving}>
|
|
||||||
{mappingSaving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : null}
|
|
||||||
Save mapping preview
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === "found" && product && (
|
|
||||||
<BarcodeProductPreview
|
|
||||||
barcode={activeBarcode}
|
|
||||||
busy={busy}
|
|
||||||
product={product}
|
|
||||||
onAddNow={() => addProductNow(product)}
|
|
||||||
onCancel={onClose}
|
|
||||||
onEdit={() => editProductBeforeAdding(product)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasVerifiedProducts(catalog: BarcodeLookupCatalog) {
|
|
||||||
return Object.keys(catalog.verifiedProducts ?? {}).length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeUserMappings(
|
|
||||||
localMappings: UserBarcodeMapping[],
|
|
||||||
cloudMappings: UserBarcodeMapping[],
|
|
||||||
) {
|
|
||||||
const byBarcode = new Map<string, UserBarcodeMapping>();
|
|
||||||
localMappings.forEach((mapping) => byBarcode.set(mapping.barcode, mapping));
|
|
||||||
cloudMappings.forEach((mapping) => byBarcode.set(mapping.barcode, mapping));
|
|
||||||
return [...byBarcode.values()];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { Settings2 } from "lucide-react";
|
|
||||||
import type { LimitCheckResult, UserLimits } from "../types";
|
|
||||||
import { currency } from "../lib/metrics";
|
|
||||||
import { formatStopTimeLabel, hasAnyLimit, limitProgress } from "../lib/userLimits";
|
|
||||||
|
|
||||||
type DailyLimitsCardProps = {
|
|
||||||
limits: UserLimits;
|
|
||||||
check: LimitCheckResult;
|
|
||||||
onOpenSettings: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCardProps) {
|
|
||||||
if (!hasAnyLimit(limits)) {
|
|
||||||
return (
|
|
||||||
<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="section-kicker">Daily limits</p>
|
|
||||||
<p className="section-meta mt-2 max-w-xl leading-6">
|
|
||||||
Set your usual can size and daily ceiling. Spend is calculated automatically. Limits are optional and stored
|
|
||||||
on your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}>
|
|
||||||
<Settings2 size={17} aria-hidden="true" />
|
|
||||||
Set limits
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const canOver = check.violations.includes("cans");
|
|
||||||
const spendOver = check.violations.includes("spend");
|
|
||||||
const stopActive = limits.stopTime && check.pastStopTime;
|
|
||||||
|
|
||||||
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="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
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{limits.dailyCanLimit != null ? (
|
|
||||||
<LimitRow
|
|
||||||
label="Cans today"
|
|
||||||
value={`${check.todayCans.toFixed(1)} / ${limits.dailyCanLimit}`}
|
|
||||||
progress={limitProgress(check.todayCans, limits.dailyCanLimit)}
|
|
||||||
state={canOver ? "over" : check.todayCans >= limits.dailyCanLimit * 0.75 ? "warn" : "ok"}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{limits.dailySpendLimit != null ? (
|
|
||||||
<LimitRow
|
|
||||||
label="Spend today"
|
|
||||||
value={`${currency.format(check.todaySpend)} / ${currency.format(limits.dailySpendLimit)}`}
|
|
||||||
progress={limitProgress(check.todaySpend, limits.dailySpendLimit)}
|
|
||||||
state={spendOver ? "over" : check.todaySpend >= limits.dailySpendLimit * 0.75 ? "warn" : "ok"}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{limits.stopTime ? (
|
|
||||||
<div className={`limit-row limit-row--${stopActive ? "over" : "ok"}`}>
|
|
||||||
<div className="limit-row-head">
|
|
||||||
<span>Stop by</span>
|
|
||||||
<strong>{formatStopTimeLabel(limits.stopTime)}</strong>
|
|
||||||
</div>
|
|
||||||
<p className="limit-row-value">
|
|
||||||
{stopActive ? "Past your stop time" : "Still within your window"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LimitRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
progress,
|
|
||||||
state,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
progress: number;
|
|
||||||
state: "ok" | "warn" | "over";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={`limit-row limit-row--${state}`}>
|
|
||||||
<div className="limit-row-head">
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="limit-progress" aria-hidden="true">
|
|
||||||
<div className="limit-progress-fill" style={{ width: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
const TERMS_SUMMARY =
|
|
||||||
"By using track.9961.one you agree to use the tracker responsibly, keep your login secure, and accept that intake data is stored in your Appwrite account under your control. This applies only to track.9961.one.";
|
|
||||||
|
|
||||||
const PRIVACY_SUMMARY =
|
|
||||||
"track.9961.one stores intake logs and preferences in Appwrite, tied to your account. Coach chats can be encrypted client-side before upload. We do not sell your data. This policy applies only to track.9961.one.";
|
|
||||||
|
|
||||||
type LegalFootnoteProps = {
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LegalFootnote({ className = "" }: LegalFootnoteProps) {
|
|
||||||
return (
|
|
||||||
<footer className={`legal-footnote ${className}`.trim()} aria-label="Legal notices for track.9961.one">
|
|
||||||
<span className="legal-footnote-site">track.9961.one</span>
|
|
||||||
<span className="legal-footnote-links">
|
|
||||||
<span className="legal-tooltip-wrap">
|
|
||||||
<button className="legal-tooltip-trigger" type="button" aria-describedby="legal-terms-tip">
|
|
||||||
Terms
|
|
||||||
</button>
|
|
||||||
<span className="legal-tooltip" id="legal-terms-tip" role="tooltip">
|
|
||||||
{TERMS_SUMMARY}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="legal-tooltip-wrap">
|
|
||||||
<button className="legal-tooltip-trigger" type="button" aria-describedby="legal-privacy-tip">
|
|
||||||
Privacy
|
|
||||||
</button>
|
|
||||||
<span className="legal-tooltip" id="legal-privacy-tip" role="tooltip">
|
|
||||||
{PRIVACY_SUMMARY}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { Loader2, Target } from "lucide-react";
|
|
||||||
import { useEffect, useState, type FormEvent } from "react";
|
|
||||||
import {
|
|
||||||
BUILT_IN_SIZES,
|
|
||||||
canLimitFromSpend,
|
|
||||||
currency,
|
|
||||||
priceForLimitSize,
|
|
||||||
spendLimitFromCans,
|
|
||||||
} from "../lib/metrics";
|
|
||||||
import type { BuiltInSize, LimitCheckResult, UserLimits } from "../types";
|
|
||||||
|
|
||||||
type LimitsSettingsFormProps = {
|
|
||||||
limits: UserLimits;
|
|
||||||
check: LimitCheckResult;
|
|
||||||
saving: boolean;
|
|
||||||
onSave: (limits: UserLimits) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSettingsFormProps) {
|
|
||||||
const [canSizeMl, setCanSizeMl] = useState<BuiltInSize>(limits.limitCanSizeMl ?? 250);
|
|
||||||
const [canInput, setCanInput] = useState(limits.dailyCanLimit?.toString() ?? "");
|
|
||||||
const [spendInput, setSpendInput] = useState(limits.dailySpendLimit?.toString() ?? "");
|
|
||||||
const [stopInput, setStopInput] = useState(limits.stopTime ?? "");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCanSizeMl(limits.limitCanSizeMl ?? 250);
|
|
||||||
setCanInput(limits.dailyCanLimit?.toString() ?? "");
|
|
||||||
setSpendInput(limits.dailySpendLimit?.toString() ?? "");
|
|
||||||
setStopInput(limits.stopTime ?? "");
|
|
||||||
}, [limits.dailyCanLimit, limits.dailySpendLimit, limits.limitCanSizeMl, limits.stopTime]);
|
|
||||||
|
|
||||||
function syncFromCans(cans: number, size: BuiltInSize) {
|
|
||||||
setCanInput(cans.toString());
|
|
||||||
setSpendInput(spendLimitFromCans(cans, size).toFixed(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCanSizeChange(size: BuiltInSize) {
|
|
||||||
setCanSizeMl(size);
|
|
||||||
const canTrim = canInput.trim();
|
|
||||||
if (canTrim) {
|
|
||||||
const cans = Math.max(0.25, Number(canTrim) || 0);
|
|
||||||
syncFromCans(cans, size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCanInputChange(value: string) {
|
|
||||||
setCanInput(value);
|
|
||||||
const canTrim = value.trim();
|
|
||||||
if (!canTrim) {
|
|
||||||
setSpendInput("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cans = Math.max(0.25, Number(canTrim) || 0);
|
|
||||||
if (cans > 0) {
|
|
||||||
setSpendInput(spendLimitFromCans(cans, canSizeMl).toFixed(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSpendInputChange(value: string) {
|
|
||||||
setSpendInput(value);
|
|
||||||
const spendTrim = value.trim();
|
|
||||||
if (!spendTrim) {
|
|
||||||
setCanInput("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const spend = Math.max(0, Number(spendTrim) || 0);
|
|
||||||
if (spend >= 0) {
|
|
||||||
setCanInput(canLimitFromSpend(spend, canSizeMl).toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
const next: UserLimits = {};
|
|
||||||
|
|
||||||
const canTrim = canInput.trim();
|
|
||||||
if (canTrim) {
|
|
||||||
const parsed = Math.max(0.25, Number(canTrim) || 0);
|
|
||||||
if (parsed > 0) {
|
|
||||||
next.dailyCanLimit = parsed;
|
|
||||||
next.limitCanSizeMl = canSizeMl;
|
|
||||||
next.dailySpendLimit = spendLimitFromCans(parsed, canSizeMl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stopInput.trim()) {
|
|
||||||
next.stopTime = stopInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSave(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewParts: string[] = [];
|
|
||||||
if (limits.dailyCanLimit != null) {
|
|
||||||
previewParts.push(`${check.todayCans.toFixed(1)}/${limits.dailyCanLimit} cans today`);
|
|
||||||
}
|
|
||||||
if (limits.dailySpendLimit != null) {
|
|
||||||
previewParts.push(`${currency.format(check.todaySpend)} of ${currency.format(limits.dailySpendLimit)} spent today`);
|
|
||||||
}
|
|
||||||
if (limits.limitCanSizeMl != null) {
|
|
||||||
previewParts.push(`${limits.limitCanSizeMl}ml cans`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="grid gap-4" onSubmit={submit}>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<span className="text-sm font-medium text-slate-700">Usual can size</span>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{BUILT_IN_SIZES.map((size) => {
|
|
||||||
const isActive = canSizeMl === size;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={size}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleCanSizeChange(size)}
|
|
||||||
className="rounded-full border px-4 py-2 text-sm transition"
|
|
||||||
style={{
|
|
||||||
borderColor: isActive ? "var(--primary, #2563eb)" : "#cbd5e1",
|
|
||||||
background: isActive ? "#eff6ff" : "white",
|
|
||||||
color: isActive ? "#1d4ed8" : "#475569",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{size}ml ({currency.format(priceForLimitSize(size))})
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
Spend is based on your usual can size. Changing cans or spend updates the other.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<label className="grid gap-2 text-sm">
|
|
||||||
<span className="font-medium text-slate-700">Cans per day</span>
|
|
||||||
<input
|
|
||||||
className="field-input"
|
|
||||||
type="number"
|
|
||||||
min={0.25}
|
|
||||||
step={0.25}
|
|
||||||
placeholder="e.g. 3"
|
|
||||||
value={canInput}
|
|
||||||
onChange={(event) => handleCanInputChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-slate-500">Leave empty to remove. Counts use BST calendar days.</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm">
|
|
||||||
<span className="font-medium text-slate-700">Spend per day (£)</span>
|
|
||||||
<input
|
|
||||||
className="field-input"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={0.01}
|
|
||||||
placeholder="e.g. 5.00"
|
|
||||||
value={spendInput}
|
|
||||||
onChange={(event) => handleSpendInputChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
Linked to {canSizeMl}ml at {currency.format(priceForLimitSize(canSizeMl))}/can.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm sm:max-w-xs">
|
|
||||||
<span className="font-medium text-slate-700">Stop drinking by</span>
|
|
||||||
<input
|
|
||||||
className="field-input"
|
|
||||||
type="time"
|
|
||||||
value={stopInput}
|
|
||||||
onChange={(event) => setStopInput(event.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-slate-500">Europe/London (BST/GMT). Leave empty to remove.</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{previewParts.length ? (
|
|
||||||
<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}
|
|
||||||
|
|
||||||
<button className="primary-button w-fit" type="submit" disabled={saving}>
|
|
||||||
{saving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Target size={17} aria-hidden="true" />}
|
|
||||||
Save limits
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { ArrowRight, Check, ChevronLeft } from "lucide-react";
|
|
||||||
import { APP_THEMES } from "../data/themes";
|
|
||||||
import {
|
|
||||||
BUILT_IN_SIZES,
|
|
||||||
currency,
|
|
||||||
priceForLimitSize,
|
|
||||||
spendLimitFromCans,
|
|
||||||
} from "../lib/metrics";
|
|
||||||
import type { BuiltInSize, UserLimits } from "../types";
|
|
||||||
|
|
||||||
type OnboardingScreenProps = {
|
|
||||||
onSave: (limits: UserLimits, themeId: string) => Promise<void>;
|
|
||||||
onClose: () => void;
|
|
||||||
activeThemeId: string;
|
|
||||||
onThemeChange: (themeId: string) => void;
|
|
||||||
userName?: string;
|
|
||||||
initialLimits?: UserLimits;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STEP_COUNT = 5;
|
|
||||||
|
|
||||||
const curfewOptions: Array<{ id: string; label: string; hint: string }> = [
|
|
||||||
{ id: "16:00", label: "4:00 PM", hint: "Early cut-off" },
|
|
||||||
{ id: "18:00", label: "6:00 PM", hint: "Balanced default" },
|
|
||||||
{ id: "20:00", label: "8:00 PM", hint: "Late schedule" },
|
|
||||||
{ id: "none", label: "No curfew", hint: "Only track intake" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function OnboardingScreen({
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
activeThemeId,
|
|
||||||
onThemeChange,
|
|
||||||
userName,
|
|
||||||
initialLimits,
|
|
||||||
}: OnboardingScreenProps) {
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [limitCanSizeMl, setLimitCanSizeMl] = useState<BuiltInSize>(
|
|
||||||
initialLimits?.limitCanSizeMl ?? 250,
|
|
||||||
);
|
|
||||||
const [dailyCanLimit, setDailyCanLimit] = useState<number | "none">(
|
|
||||||
initialLimits?.dailyCanLimit ?? 2,
|
|
||||||
);
|
|
||||||
const [stopTime, setStopTime] = useState<string | "none">(initialLimits?.stopTime ?? "18:00");
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const activeTheme = useMemo(() => {
|
|
||||||
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
|
||||||
}, [activeThemeId]);
|
|
||||||
|
|
||||||
const derivedSpend =
|
|
||||||
dailyCanLimit !== "none" ? spendLimitFromCans(dailyCanLimit, limitCanSizeMl) : null;
|
|
||||||
const unitPrice = priceForLimitSize(limitCanSizeMl);
|
|
||||||
|
|
||||||
const progress = `${(step / STEP_COUNT) * 100}%`;
|
|
||||||
|
|
||||||
async function handleFinish() {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const limits: UserLimits = {};
|
|
||||||
if (dailyCanLimit !== "none") {
|
|
||||||
limits.dailyCanLimit = dailyCanLimit;
|
|
||||||
limits.limitCanSizeMl = limitCanSizeMl;
|
|
||||||
limits.dailySpendLimit = spendLimitFromCans(dailyCanLimit, limitCanSizeMl);
|
|
||||||
}
|
|
||||||
if (stopTime !== "none") limits.stopTime = stopTime;
|
|
||||||
|
|
||||||
await onSave(limits, activeThemeId);
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("setup save failed", err);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function incrementCans() {
|
|
||||||
if (dailyCanLimit === "none") {
|
|
||||||
setDailyCanLimit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dailyCanLimit < 10) setDailyCanLimit(Number((dailyCanLimit + 0.5).toFixed(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function decrementCans() {
|
|
||||||
if (dailyCanLimit === "none") return;
|
|
||||||
if (dailyCanLimit <= 0.5) {
|
|
||||||
setDailyCanLimit("none");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDailyCanLimit(Number((dailyCanLimit - 0.5).toFixed(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function goNext() {
|
|
||||||
setStep((current) => Math.min(current + 1, STEP_COUNT));
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
setStep((current) => Math.max(current - 1, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[100] flex min-h-screen flex-col overflow-y-auto px-5 py-6 sm:px-8"
|
|
||||||
style={{
|
|
||||||
background: "var(--bg)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 opacity-60"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(180deg, color-mix(in srgb, var(--primary-container) 24%, transparent), transparent 36%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<header className="relative z-10 mx-auto flex w-full max-w-3xl items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mb-3 h-1 overflow-hidden rounded-full bg-[var(--surface-container-high)]">
|
|
||||||
<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)]">
|
|
||||||
step {step} of {STEP_COUNT}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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)]">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)]">
|
|
||||||
Pick a theme, choose your usual can size, set a daily ceiling, and optionally a curfew.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goNext}
|
|
||||||
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
|
|
||||||
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
|
|
||||||
>
|
|
||||||
Start
|
|
||||||
<ArrowRight size={16} />
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<section className="grid gap-8">
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<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 app color.
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid max-h-[48vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
|
||||||
{APP_THEMES.map((theme) => {
|
|
||||||
const isActive = activeThemeId === theme.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={theme.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onThemeChange(theme.id)}
|
|
||||||
className="flex min-h-16 items-center justify-between rounded-2xl border px-4 text-left text-sm font-normal transition"
|
|
||||||
style={{
|
|
||||||
background: isActive ? "var(--surface-container-low)" : "var(--surface-container-lowest)",
|
|
||||||
borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 items-center gap-3">
|
|
||||||
<span className="h-6 w-6 shrink-0 rounded-full border border-white/40" style={{ background: theme.swatch }} />
|
|
||||||
<span className="truncate">{theme.label}</span>
|
|
||||||
</span>
|
|
||||||
{isActive && <Check size={16} style={{ color: "var(--primary)" }} />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goNext}
|
|
||||||
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
|
|
||||||
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
<ArrowRight size={16} />
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<section className="grid gap-9">
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<p className="text-sm font-normal text-[var(--primary)]">daily limit</p>
|
|
||||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
|
||||||
What size can do you usually have?
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
|
||||||
Your spend cap is calculated from your can size and daily ceiling. You can change this later in settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
|
||||||
{BUILT_IN_SIZES.map((size) => {
|
|
||||||
const isSelected = limitCanSizeMl === size;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={size}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setLimitCanSizeMl(size)}
|
|
||||||
className="flex min-h-20 flex-col items-start justify-center rounded-2xl border px-4 text-left transition"
|
|
||||||
style={{
|
|
||||||
background: isSelected ? "var(--surface-container-low)" : "var(--surface-container-lowest)",
|
|
||||||
borderColor: isSelected ? "var(--primary)" : "var(--outline-variant)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-lg font-normal text-[var(--text)]">{size}ml</span>
|
|
||||||
<span className="mt-1 text-sm font-normal text-[var(--muted)]">
|
|
||||||
{currency.format(priceForLimitSize(size))} per can
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<p className="text-sm font-normal text-[var(--muted)]">Daily can ceiling</p>
|
|
||||||
<div className="flex flex-wrap items-end gap-5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={decrementCans}
|
|
||||||
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
|
|
||||||
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<div className="min-w-44">
|
|
||||||
<p className="text-7xl font-normal leading-none tracking-[-0.06em] sm:text-8xl" style={{ color: "var(--primary)" }}>
|
|
||||||
{dailyCanLimit === "none" ? "No cap" : dailyCanLimit}
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-sm font-normal text-[var(--muted)]">
|
|
||||||
{dailyCanLimit === "none" ? "Unlimited daily volume" : dailyCanLimit === 1 ? "can per day" : "cans per day"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={incrementCans}
|
|
||||||
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
|
|
||||||
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{derivedSpend != null ? (
|
|
||||||
<p
|
|
||||||
className="rounded-2xl border px-4 py-3 text-sm font-normal"
|
|
||||||
style={{
|
|
||||||
background: "var(--surface-container-lowest)",
|
|
||||||
borderColor: "var(--outline-variant)",
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Daily budget: {currency.format(derivedSpend)} ({dailyCanLimit} × {currency.format(unitPrice)})
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm font-normal text-[var(--muted)]">No daily spend cap when cans are unlimited.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDailyCanLimit("none")}
|
|
||||||
className="rounded-full border px-4 py-2 text-sm font-normal transition"
|
|
||||||
style={{
|
|
||||||
background: dailyCanLimit === "none" ? "var(--primary-container)" : "var(--surface-container-lowest)",
|
|
||||||
borderColor: dailyCanLimit === "none" ? "var(--primary)" : "var(--outline-variant)",
|
|
||||||
color: dailyCanLimit === "none" ? "var(--on-primary-container)" : "var(--muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No daily cap
|
|
||||||
</button>
|
|
||||||
{dailyCanLimit === "none" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDailyCanLimit(2)}
|
|
||||||
className="rounded-full border px-4 py-2 text-sm font-normal transition"
|
|
||||||
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
|
|
||||||
>
|
|
||||||
Use 2 cans
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goNext}
|
|
||||||
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
|
|
||||||
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
<ArrowRight size={16} />
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 4 && (
|
|
||||||
<section className="grid gap-8">
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<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 the app warn you?
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
|
||||||
Pick a time. The app will warn when an entry is later than this.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
{curfewOptions.map((timeOption) => {
|
|
||||||
const isSelected = stopTime === timeOption.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={timeOption.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setStopTime(timeOption.id)}
|
|
||||||
className="flex min-h-20 items-center justify-between rounded-2xl border px-4 text-left transition"
|
|
||||||
style={{
|
|
||||||
background: isSelected ? "var(--surface-container-low)" : "var(--surface-container-lowest)",
|
|
||||||
borderColor: isSelected ? "var(--primary)" : "var(--outline-variant)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<span className="block text-lg font-normal text-[var(--text)]">{timeOption.label}</span>
|
|
||||||
<span className="mt-1 block text-sm font-normal text-[var(--muted)]">{timeOption.hint}</span>
|
|
||||||
</span>
|
|
||||||
{isSelected && <Check size={16} style={{ color: "var(--primary)" }} />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goNext}
|
|
||||||
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
|
|
||||||
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
|
|
||||||
>
|
|
||||||
Review
|
|
||||||
<ArrowRight size={16} />
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 5 && (
|
|
||||||
<section className="grid gap-8">
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid max-w-xl gap-3 rounded-3xl border p-5" style={{ background: "var(--surface-container-lowest)", borderColor: "var(--outline-variant)" }}>
|
|
||||||
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
|
|
||||||
<span className="text-sm font-normal text-[var(--muted)]">Theme</span>
|
|
||||||
<span className="flex items-center gap-2 text-sm font-normal text-[var(--text)]">
|
|
||||||
<span className="h-3 w-3 rounded-full" style={{ background: activeTheme.swatch }} />
|
|
||||||
{activeTheme.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
|
|
||||||
<span className="text-sm font-normal text-[var(--muted)]">Usual can size</span>
|
|
||||||
<span className="text-sm font-normal text-[var(--text)]">
|
|
||||||
{dailyCanLimit === "none" ? "—" : `${limitCanSizeMl}ml (${currency.format(unitPrice)}/can)`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
|
|
||||||
<span className="text-sm font-normal text-[var(--muted)]">Daily cans</span>
|
|
||||||
<span className="text-sm font-normal text-[var(--text)]">
|
|
||||||
{dailyCanLimit === "none" ? "No cap" : `${dailyCanLimit} ${dailyCanLimit === 1 ? "can" : "cans"}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
|
|
||||||
<span className="text-sm font-normal text-[var(--muted)]">Daily spend</span>
|
|
||||||
<span className="text-sm font-normal text-[var(--text)]">
|
|
||||||
{derivedSpend == null ? "No cap" : currency.format(derivedSpend)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-sm font-normal text-[var(--muted)]">Caffeine curfew</span>
|
|
||||||
<span className="text-sm font-normal text-[var(--text)]">{stopTime === "none" ? "No curfew" : stopTime}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleFinish()}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
|
|
||||||
>
|
|
||||||
{saving ? "Saving..." : "Start tracking"}
|
|
||||||
{!saving && <ArrowRight size={16} />}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="relative z-10 mx-auto flex w-full max-w-3xl items-center justify-between gap-4 pb-2">
|
|
||||||
{step > 1 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goBack}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex min-h-10 items-center gap-2 text-sm font-normal text-[var(--muted)] transition hover:text-[var(--text)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={16} />
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<p className="text-xs font-normal text-[var(--muted)]">you can edit this later.</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { BarcodeSeedProduct } from "../types";
|
|
||||||
import verifiedBarcodes from "./verified-barcodes.json";
|
|
||||||
|
|
||||||
// Verified retail barcodes only. Add rows here via verified-barcodes.json so
|
|
||||||
// the frontend seed data and Appwrite setup script stay aligned.
|
|
||||||
export const BUILT_IN_BARCODE_PRODUCTS = verifiedBarcodes as Record<string, BarcodeSeedProduct>;
|
|
||||||
+197
-84
@@ -1,116 +1,229 @@
|
|||||||
import { buildThemeTokens, type ThemeSeed, type ThemeTokens } from "../lib/themeTokens";
|
import { buildThemeTokens, type ThemeSeed, type ThemeTokens } from "../lib/themeTokens";
|
||||||
|
|
||||||
|
export type ThemeCategory = "vocaloid" | "flavour" | "sugarfree";
|
||||||
|
|
||||||
export type AppTheme = {
|
export type AppTheme = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
category: ThemeCategory;
|
||||||
swatch: string;
|
swatch: string;
|
||||||
tokens: ThemeTokens;
|
tokens: ThemeTokens;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v2";
|
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
||||||
export const OLD_THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
|
||||||
export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1";
|
export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1";
|
||||||
export const DEFAULT_THEME_ID = "mist";
|
export const DEFAULT_THEME_ID = "oura-mist";
|
||||||
|
|
||||||
const OLD_THEME_MAP: Record<string, string> = {
|
const LEGACY_ACCENT_MAP: Record<string, string> = {
|
||||||
// old theme ids can rot quietly
|
pink: "oura-mist",
|
||||||
[`${"ou"}${"ra"}-mist`]: "mist",
|
blue: "oura-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, swatch: string, seed: ThemeSeed): AppTheme {
|
function theme(id: string, label: string, category: ThemeCategory, swatch: string, seed: ThemeSeed): AppTheme {
|
||||||
return { id, label, swatch, tokens: buildThemeTokens(seed) };
|
return { id, label, category, swatch, tokens: buildThemeTokens(seed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_THEMES: AppTheme[] = [
|
export const APP_THEMES: AppTheme[] = [
|
||||||
theme("mist", "Mist", "#2563c7", {
|
theme("oura-mist", "Oura Mist", "vocaloid", "#4b86ad", {
|
||||||
primary: "#2563c7",
|
primary: "#4b86ad",
|
||||||
tokens: {
|
tokens: {
|
||||||
primary: "#2563c7",
|
primary: "#4b86ad",
|
||||||
primaryContainer: "#dbe9ff",
|
primaryContainer: "#dff2ff",
|
||||||
onPrimaryContainer: "#10243f",
|
onPrimaryContainer: "#10283a",
|
||||||
bg: "#eef3fb",
|
chartPrimary: "#4b86ad",
|
||||||
surface: "#eef3fb",
|
chartSecondary: "#6f8f7c",
|
||||||
surfaceContainerLowest: "#ffffff",
|
chartTertiary: "#9b7b51",
|
||||||
surfaceContainerLow: "#f7faff",
|
|
||||||
surfaceContainer: "#ffffff",
|
|
||||||
surfaceContainerHigh: "#eef4ff",
|
|
||||||
outline: "#c7d2e2",
|
|
||||||
outlineVariant: "#dce5f1",
|
|
||||||
text: "#202124",
|
|
||||||
muted: "#5f6670",
|
|
||||||
subtle: "#6f7782",
|
|
||||||
chartPrimary: "#2563c7",
|
|
||||||
chartSecondary: "#00897b",
|
|
||||||
chartTertiary: "#b85d1f",
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
theme("aqua", "Aqua", "#007f73", {
|
theme("miku-blue", "Miku Blue", "vocaloid", "#39c5bb", {
|
||||||
primary: "#007f73",
|
primary: "#39c5bb",
|
||||||
secondary: "#0b6f9f",
|
secondary: "#39d5ff",
|
||||||
tertiary: "#7a5bbd",
|
tertiary: "#7ce7ff",
|
||||||
}),
|
}),
|
||||||
theme("signal-red", "Signal red", "#b3261e", {
|
theme("teto-red", "Teto Red", "vocaloid", "#fe0404", {
|
||||||
primary: "#b3261e",
|
primary: "#fe0404",
|
||||||
secondary: "#7d5fff",
|
secondary: "#ff3448",
|
||||||
tertiary: "#126e82",
|
tertiary: "#ff6b6b",
|
||||||
}),
|
}),
|
||||||
theme("soft-pink", "Soft pink", "#a83f73", {
|
theme("pastel-pink", "Pastel Pink", "vocaloid", "#ffb7d9", {
|
||||||
primary: "#a83f73",
|
primary: "#e07aa8",
|
||||||
secondary: "#2563c7",
|
secondary: "#ffb7d9",
|
||||||
tertiary: "#8a6b10",
|
tertiary: "#ffd8e7",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
theme("original", "Original", "flavour", "#00a7ff", {
|
||||||
|
primary: "#0077c8",
|
||||||
|
secondary: "#00a7ff",
|
||||||
|
tertiary: "#1e3264",
|
||||||
|
}),
|
||||||
|
theme("zero", "Zero", "flavour", "#2a2a2a", {
|
||||||
|
primary: "#2a2a2a",
|
||||||
|
secondary: "#5c5c5c",
|
||||||
|
tertiary: "#8a8a8a",
|
||||||
|
dark: true,
|
||||||
|
}),
|
||||||
|
theme("summer", "Summer Edition", "flavour", "#f0e53b", {
|
||||||
|
primary: "#d4c400",
|
||||||
|
secondary: "#f0e53b",
|
||||||
|
tertiary: "#ffc247",
|
||||||
|
}),
|
||||||
|
theme("cherry", "Cherry Edition", "flavour", "#e40046", {
|
||||||
|
primary: "#c3093b",
|
||||||
|
secondary: "#e40046",
|
||||||
|
tertiary: "#ff6b8a",
|
||||||
|
}),
|
||||||
|
theme("spring", "Spring Edition", "flavour", "#ff8fab", {
|
||||||
|
primary: "#e85d8a",
|
||||||
|
secondary: "#ffb3c6",
|
||||||
|
tertiary: "#ffd8e7",
|
||||||
|
}),
|
||||||
|
theme("apple", "Apple Edition", "flavour", "#78be20", {
|
||||||
|
primary: "#5a9a12",
|
||||||
|
secondary: "#78be20",
|
||||||
|
tertiary: "#a8d84a",
|
||||||
|
}),
|
||||||
|
theme("peach", "Peach Edition", "flavour", "#ff9b63", {
|
||||||
|
primary: "#e87a3a",
|
||||||
|
secondary: "#ff9b63",
|
||||||
|
tertiary: "#ffc9a3",
|
||||||
|
}),
|
||||||
|
theme("ice", "Ice Edition", "flavour", "#49adbe", {
|
||||||
|
primary: "#2d8a9a",
|
||||||
|
secondary: "#49adbe",
|
||||||
|
tertiary: "#7ce7ff",
|
||||||
|
}),
|
||||||
|
theme("blue-edition", "Blue Edition", "flavour", "#496dff", {
|
||||||
|
primary: "#3a52cc",
|
||||||
|
secondary: "#496dff",
|
||||||
|
tertiary: "#9c73ff",
|
||||||
|
}),
|
||||||
|
theme("red-edition", "Red Edition", "flavour", "#ff355e", {
|
||||||
|
primary: "#e02045",
|
||||||
|
secondary: "#ff355e",
|
||||||
|
tertiary: "#ff6b8a",
|
||||||
|
}),
|
||||||
|
theme("tropical", "Tropical Edition", "flavour", "#ffc247", {
|
||||||
|
primary: "#e0a820",
|
||||||
|
secondary: "#ffc247",
|
||||||
|
tertiary: "#ff9b63",
|
||||||
|
}),
|
||||||
|
theme("coconut", "Coconut Edition", "flavour", "#7ce7ff", {
|
||||||
|
primary: "#4ec4e0",
|
||||||
|
secondary: "#7ce7ff",
|
||||||
|
tertiary: "#d8f9ff",
|
||||||
|
}),
|
||||||
|
theme("green-edition", "Green Edition", "flavour", "#b7ff4a", {
|
||||||
|
primary: "#7acc20",
|
||||||
|
secondary: "#b7ff4a",
|
||||||
|
tertiary: "#d4ff8a",
|
||||||
|
}),
|
||||||
|
theme("apricot", "Apricot Edition", "flavour", "#ff8c42", {
|
||||||
|
primary: "#e06a20",
|
||||||
|
secondary: "#ff8c42",
|
||||||
|
tertiary: "#ffb87a",
|
||||||
|
}),
|
||||||
|
theme("ruby", "Ruby Edition", "flavour", "#c3093b", {
|
||||||
|
primary: "#a00730",
|
||||||
|
secondary: "#c3093b",
|
||||||
|
tertiary: "#e04060",
|
||||||
|
}),
|
||||||
|
|
||||||
|
theme("sugarfree", "Sugarfree", "sugarfree", "#c8d4e0", {
|
||||||
|
primary: "#8a9bb0",
|
||||||
|
secondary: "#c8d4e0",
|
||||||
|
tertiary: "#e7eef8",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#e8e4a0", {
|
||||||
|
primary: "#c4c020",
|
||||||
|
secondary: "#e8e4a0",
|
||||||
|
tertiary: "#f0e53b",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#b8d4a0", {
|
||||||
|
primary: "#6a9a30",
|
||||||
|
secondary: "#b8d4a0",
|
||||||
|
tertiary: "#78be20",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#f0d0b8", {
|
||||||
|
primary: "#d08050",
|
||||||
|
secondary: "#f0d0b8",
|
||||||
|
tertiary: "#ff9b63",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#b8e0e8", {
|
||||||
|
primary: "#4a9aaa",
|
||||||
|
secondary: "#b8e0e8",
|
||||||
|
tertiary: "#49adbe",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#d8c8f0", {
|
||||||
|
primary: "#9070c0",
|
||||||
|
secondary: "#d8c8f0",
|
||||||
|
tertiary: "#b898e0",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#f0c8d8", {
|
||||||
|
primary: "#d06090",
|
||||||
|
secondary: "#f0c8d8",
|
||||||
|
tertiary: "#ffb7d9",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#c8d0f8", {
|
||||||
|
primary: "#5060c0",
|
||||||
|
secondary: "#c8d0f8",
|
||||||
|
tertiary: "#496dff",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#d0f0f8", {
|
||||||
|
primary: "#60b8d0",
|
||||||
|
secondary: "#d0f0f8",
|
||||||
|
tertiary: "#7ce7ff",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-green", "Green Sugarfree", "sugarfree", "#d8f0b8", {
|
||||||
|
primary: "#70a830",
|
||||||
|
secondary: "#d8f0b8",
|
||||||
|
tertiary: "#b7ff4a",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#f0c0c8", {
|
||||||
|
primary: "#a03050",
|
||||||
|
secondary: "#f0c0c8",
|
||||||
|
tertiary: "#c3093b",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
|
||||||
|
primary: "#d07090",
|
||||||
|
secondary: "#f8d0e0",
|
||||||
|
tertiary: "#ffb3c6",
|
||||||
|
sugarFree: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_CATEGORIES: Array<{ id: ThemeCategory; label: string }> = [
|
||||||
|
{ id: "vocaloid", label: "Vocaloid & Pink" },
|
||||||
|
{ id: "flavour", label: "Flavours" },
|
||||||
|
{ id: "sugarfree", label: "Sugarfree" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getThemeById(id: string): AppTheme {
|
export function getThemeById(id: string): AppTheme {
|
||||||
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
|
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normaliseThemeId(id: string | null | undefined): string {
|
|
||||||
if (!id) return DEFAULT_THEME_ID;
|
|
||||||
if (APP_THEMES.some((entry) => entry.id === id)) return id;
|
|
||||||
return OLD_THEME_MAP[id] ?? DEFAULT_THEME_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readStoredThemeId(): string {
|
export function readStoredThemeId(): string {
|
||||||
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
||||||
|
|
||||||
const stored = normaliseThemeId(localStorage.getItem(THEME_STORAGE_KEY));
|
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
if (stored !== DEFAULT_THEME_ID || localStorage.getItem(THEME_STORAGE_KEY)) return stored;
|
if (stored && APP_THEMES.some((entry) => entry.id === stored)) {
|
||||||
|
return stored;
|
||||||
const oldStored = normaliseThemeId(localStorage.getItem(OLD_THEME_STORAGE_KEY));
|
}
|
||||||
if (oldStored !== DEFAULT_THEME_ID || localStorage.getItem(OLD_THEME_STORAGE_KEY)) return oldStored;
|
|
||||||
|
const legacy = localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY);
|
||||||
return normaliseThemeId(localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY));
|
if (legacy && LEGACY_ACCENT_MAP[legacy]) {
|
||||||
|
return LEGACY_ACCENT_MAP[legacy];
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_THEME_ID;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,475 +0,0 @@
|
|||||||
{
|
|
||||||
"90162602": {
|
|
||||||
"flavourName": "Original",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL NON PMP - ORIGINAL 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-original-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode. Price uses tracker default."
|
|
||||||
},
|
|
||||||
"90493317": {
|
|
||||||
"flavourName": "Original",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL NON PMP - ORIGINAL 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-original-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current GBP 1.75 PMP barcode."
|
|
||||||
},
|
|
||||||
"90457999": {
|
|
||||||
"flavourName": "Original",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.65,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL NON PMP - ORIGINAL 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-original-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older GBP 1.65 PMP barcode."
|
|
||||||
},
|
|
||||||
"90162800": {
|
|
||||||
"flavourName": "Sugar Free",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL NON PMP - SUGAR FREE",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-sugar-free/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode. Price uses tracker default."
|
|
||||||
},
|
|
||||||
"90496066": {
|
|
||||||
"flavourName": "Sugar Free",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.7,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL NON PMP - SUGAR FREE",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-sugar-free/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current GBP 1.70 PMP barcode."
|
|
||||||
},
|
|
||||||
"90457982": {
|
|
||||||
"flavourName": "Sugar Free",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.6,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL NON PMP - SUGAR FREE",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-non-pmp-sugar-free/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older GBP 1.60 PMP barcode."
|
|
||||||
},
|
|
||||||
"90415425": {
|
|
||||||
"flavourName": "Zero",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL ZERO 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-zero-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode. Price uses tracker default."
|
|
||||||
},
|
|
||||||
"90496011": {
|
|
||||||
"flavourName": "Zero",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.7,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL ZERO 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-zero-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current GBP 1.70 PMP barcode."
|
|
||||||
},
|
|
||||||
"90457890": {
|
|
||||||
"flavourName": "Zero",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.6,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL ZERO 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-zero-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older GBP 1.60 PMP barcode."
|
|
||||||
},
|
|
||||||
"90493423": {
|
|
||||||
"flavourName": "Cherry Edition",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Bestway Wholesale",
|
|
||||||
"sourceName": "Red Bull Spring Edition Cherry Sakura Energy Drink 250ml",
|
|
||||||
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/833691-1",
|
|
||||||
"variant": "pmp",
|
|
||||||
"notes": "PMP barcode verified. Plain can barcode not publicly verified in the supplied source list."
|
|
||||||
},
|
|
||||||
"90493539": {
|
|
||||||
"flavourName": "Summer Edition",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Bestway Wholesale",
|
|
||||||
"sourceName": "Red Bull Summer Edition Citrus Zest Energy Drink 250ml",
|
|
||||||
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/833324-1",
|
|
||||||
"variant": "pmp",
|
|
||||||
"notes": "PMP barcode verified. Sugarfree Citrus Zest barcode was not publicly verified in the supplied source list."
|
|
||||||
},
|
|
||||||
"90486449": {
|
|
||||||
"flavourName": "Winter Edition",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "BB Foodservice",
|
|
||||||
"sourceName": "Red Bull Winter Edition Fuji Apple & Ginger Energy Drink",
|
|
||||||
"sourceUrl": "https://www.bbfoodservice.co.uk/product/830604-1",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Fuji Apple & Ginger listing mapped to existing Winter Edition flavour."
|
|
||||||
},
|
|
||||||
"90493485": {
|
|
||||||
"flavourName": "Winter Edition",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "BB Foodservice",
|
|
||||||
"sourceName": "Red Bull Winter Edition Fuji Apple & Ginger Energy Drink",
|
|
||||||
"sourceUrl": "https://www.bbfoodservice.co.uk/product/830604-1",
|
|
||||||
"variant": "pmp",
|
|
||||||
"notes": "PMP barcode mapped to existing Winter Edition flavour."
|
|
||||||
},
|
|
||||||
"90493355": {
|
|
||||||
"flavourName": "Peach",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Bestway Wholesale",
|
|
||||||
"sourceName": "Red Bull Peach Edition White Peach Energy Drink 250ml",
|
|
||||||
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832794-1",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified. Plain can barcode not publicly verified in the supplied source list."
|
|
||||||
},
|
|
||||||
"90474576": {
|
|
||||||
"flavourName": "Peach",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Bestway Wholesale",
|
|
||||||
"sourceName": "Red Bull Peach Edition White Peach Energy Drink 250ml",
|
|
||||||
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832794-1",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90457449": {
|
|
||||||
"flavourName": "Iced Vanilla",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - ICED VANILLA BERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-iced-vanilla-berry-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode."
|
|
||||||
},
|
|
||||||
"90493324": {
|
|
||||||
"flavourName": "Iced Vanilla",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - ICED VANILLA BERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-iced-vanilla-berry-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90486234": {
|
|
||||||
"flavourName": "Iced Vanilla",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - ICED VANILLA BERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-iced-vanilla-berry-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90454035": {
|
|
||||||
"flavourName": "Juneberry",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - JUNEBERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-juneberry-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode."
|
|
||||||
},
|
|
||||||
"90493737": {
|
|
||||||
"flavourName": "Juneberry",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - JUNEBERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-juneberry-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90474095": {
|
|
||||||
"flavourName": "Juneberry",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - JUNEBERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-juneberry-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90446412": {
|
|
||||||
"flavourName": "Watermelon",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - WATERMELON 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-watermelon-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode."
|
|
||||||
},
|
|
||||||
"90493713": {
|
|
||||||
"flavourName": "Watermelon",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - WATERMELON 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-watermelon-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90457975": {
|
|
||||||
"flavourName": "Watermelon",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - WATERMELON 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-watermelon-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90415739": {
|
|
||||||
"flavourName": "Tropical",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - TROPICAL 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-tropical-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode."
|
|
||||||
},
|
|
||||||
"90493348": {
|
|
||||||
"flavourName": "Tropical",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - TROPICAL 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-tropical-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90474057": {
|
|
||||||
"flavourName": "Tropical",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - TROPICAL 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-tropical-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90435348": {
|
|
||||||
"flavourName": "Coconut Berry",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - COCONUT & BERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-coconut-berry-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode."
|
|
||||||
},
|
|
||||||
"90493720": {
|
|
||||||
"flavourName": "Coconut Berry",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - COCONUT & BERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-coconut-berry-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90457951": {
|
|
||||||
"flavourName": "Coconut Berry",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - COCONUT & BERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-coconut-berry-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90456831": {
|
|
||||||
"flavourName": "Curuba Elderflower",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "Red Bull - SUMMER CARUBA 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-summer-caruba-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode appears under older Caruba naming."
|
|
||||||
},
|
|
||||||
"90493362": {
|
|
||||||
"flavourName": "Curuba Elderflower",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "Red Bull - SUMMER CARUBA 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-summer-caruba-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90474064": {
|
|
||||||
"flavourName": "Curuba Elderflower",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "Red Bull - SUMMER CARUBA 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-summer-caruba-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90453168": {
|
|
||||||
"flavourName": "Apricot Edition",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "Red Bull - APRICOT & STRAWBERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-apricot-strawberry-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode."
|
|
||||||
},
|
|
||||||
"90493300": {
|
|
||||||
"flavourName": "Apricot Edition",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "Red Bull - APRICOT & STRAWBERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-apricot-strawberry-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90457968": {
|
|
||||||
"flavourName": "Apricot Edition",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "Red Bull - APRICOT & STRAWBERRY 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-apricot-strawberry-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90454899": {
|
|
||||||
"flavourName": "Ruby",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Intamarque",
|
|
||||||
"sourceName": "Red Bull The Ruby Edition Spiced Pear Energy Drink 250ml",
|
|
||||||
"sourceUrl": "https://intamarquewholesale.com/products/red-bull-the-ruby-edition-spiced-pear-energy-drink-250ml",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode."
|
|
||||||
},
|
|
||||||
"90493560": {
|
|
||||||
"flavourName": "Ruby",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Intamarque",
|
|
||||||
"sourceName": "Red Bull The Ruby Edition Spiced Pear Energy Drink 250ml",
|
|
||||||
"sourceUrl": "https://intamarquewholesale.com/products/red-bull-the-ruby-edition-spiced-pear-energy-drink-250ml",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90474088": {
|
|
||||||
"flavourName": "Ruby",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"verifiedBy": "Intamarque",
|
|
||||||
"sourceName": "Red Bull The Ruby Edition Spiced Pear Energy Drink 250ml",
|
|
||||||
"sourceUrl": "https://intamarquewholesale.com/products/red-bull-the-ruby-edition-spiced-pear-energy-drink-250ml",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90456985": {
|
|
||||||
"flavourName": "Pink Sugarfree",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - SF PINK 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-sf-pink-250ml/",
|
|
||||||
"variant": "meal-deal-or-no-price",
|
|
||||||
"notes": "Verified non-PMP barcode."
|
|
||||||
},
|
|
||||||
"90493379": {
|
|
||||||
"flavourName": "Pink Sugarfree",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - SF PINK 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-sf-pink-250ml/",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "Current PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90474071": {
|
|
||||||
"flavourName": "Pink Sugarfree",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - SF PINK 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-sf-pink-250ml/",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90456978": {
|
|
||||||
"flavourName": "Pink Sugarfree",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Brand Factory Ltd",
|
|
||||||
"sourceName": "RED BULL COLOURS NON PMP - SF PINK 250ML",
|
|
||||||
"sourceUrl": "https://www.brandfactory.co.uk/product/red-bull-colours-non-pmp-sf-pink-250ml/",
|
|
||||||
"variant": "pmp",
|
|
||||||
"notes": "Additional PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90493294": {
|
|
||||||
"flavourName": "Lilac Sugarfree",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Bestway Wholesale",
|
|
||||||
"sourceName": "Red Bull Lilac Edition Sugarfree Grapefruit & Blossom Energy Drink",
|
|
||||||
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832789-1",
|
|
||||||
"variant": "current-pmp",
|
|
||||||
"notes": "PMP barcode verified. Plain can barcode not publicly verified in the supplied source list."
|
|
||||||
},
|
|
||||||
"90474774": {
|
|
||||||
"flavourName": "Lilac Sugarfree",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Bestway Wholesale",
|
|
||||||
"sourceName": "Red Bull Lilac Edition Sugarfree Grapefruit & Blossom Energy Drink",
|
|
||||||
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832789-1",
|
|
||||||
"variant": "older-pmp",
|
|
||||||
"notes": "Older PMP barcode verified."
|
|
||||||
},
|
|
||||||
"90486067": {
|
|
||||||
"flavourName": "Lilac Sugarfree",
|
|
||||||
"sizeMl": 250,
|
|
||||||
"pricePerCan": 1.75,
|
|
||||||
"sugarFree": true,
|
|
||||||
"verifiedBy": "Bestway Wholesale",
|
|
||||||
"sourceName": "Red Bull Lilac Edition Sugarfree Grapefruit & Blossom Energy Drink",
|
|
||||||
"sourceUrl": "https://www.bestwaywholesale.co.uk/product/832789-1",
|
|
||||||
"variant": "pmp",
|
|
||||||
"notes": "Additional PMP barcode verified."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+967
-1383
File diff suppressed because it is too large
Load Diff
+20
-4
@@ -1,14 +1,16 @@
|
|||||||
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 env = import.meta.env;
|
||||||
|
const currentOrigin = window.location.origin;
|
||||||
|
|
||||||
export const appwriteConfig = {
|
export const appwriteConfig = {
|
||||||
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
|
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
|
||||||
projectId: env.VITE_APPWRITE_PROJECT_ID!,
|
projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138",
|
||||||
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||||
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
|
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()
|
const client = new Client()
|
||||||
@@ -22,4 +24,18 @@ export async function pingAppwrite() {
|
|||||||
return client.ping();
|
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(/\/$/, "");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import type { Models } from "appwrite";
|
|
||||||
import type { BarcodeLookupCatalog, BarcodeProductDraft, BarcodeSeedProduct, UserBarcodeMapping } from "../types";
|
|
||||||
import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite";
|
|
||||||
import { normalizeBarcode } from "./barcodeLookup";
|
|
||||||
|
|
||||||
type BarcodeRowScope = "verified" | "user";
|
|
||||||
|
|
||||||
type BarcodeRow = Models.Row & {
|
|
||||||
scope: BarcodeRowScope;
|
|
||||||
ownerUserId?: string;
|
|
||||||
barcode: string;
|
|
||||||
flavourName: string;
|
|
||||||
sizeMl: number;
|
|
||||||
pricePerCan: number;
|
|
||||||
sugarFree: boolean;
|
|
||||||
caffeineMgPerCan?: number;
|
|
||||||
verifiedBy?: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
variant?: string;
|
|
||||||
notes?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function listBarcodeCatalog(): Promise<BarcodeLookupCatalog> {
|
|
||||||
const verifiedProducts: Record<string, BarcodeSeedProduct> = {};
|
|
||||||
const userMappings: UserBarcodeMapping[] = [];
|
|
||||||
const limit = 200;
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const response = await tablesDB.listRows<BarcodeRow>({
|
|
||||||
databaseId: appwriteConfig.databaseId,
|
|
||||||
tableId: appwriteConfig.barcodeCollectionId,
|
|
||||||
queries: [Query.orderAsc("barcode"), Query.limit(limit), Query.offset(offset)],
|
|
||||||
});
|
|
||||||
|
|
||||||
response.rows.forEach((row) => {
|
|
||||||
if (row.scope === "verified") {
|
|
||||||
verifiedProducts[row.barcode] = fromVerifiedRow(row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userMappings.push(fromUserRow(row));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.rows.length < limit) break;
|
|
||||||
offset += limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { verifiedProducts, userMappings };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertCloudUserBarcodeMapping(
|
|
||||||
userId: string,
|
|
||||||
barcodeValue: string,
|
|
||||||
product: BarcodeProductDraft,
|
|
||||||
) {
|
|
||||||
const barcode = normalizeBarcode(barcodeValue);
|
|
||||||
const existing = await findUserBarcodeRow(userId, barcode);
|
|
||||||
const data = toUserRowData(userId, barcode, product);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const row = await tablesDB.updateRow<BarcodeRow>({
|
|
||||||
databaseId: appwriteConfig.databaseId,
|
|
||||||
tableId: appwriteConfig.barcodeCollectionId,
|
|
||||||
rowId: existing.$id,
|
|
||||||
data,
|
|
||||||
permissions: userRowPermissions(userId),
|
|
||||||
});
|
|
||||||
return fromUserRow(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = await tablesDB.createRow<BarcodeRow>({
|
|
||||||
databaseId: appwriteConfig.databaseId,
|
|
||||||
tableId: appwriteConfig.barcodeCollectionId,
|
|
||||||
rowId: ID.unique(),
|
|
||||||
data,
|
|
||||||
permissions: userRowPermissions(userId),
|
|
||||||
});
|
|
||||||
return fromUserRow(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findUserBarcodeRow(userId: string, barcode: string) {
|
|
||||||
const response = await tablesDB.listRows<BarcodeRow>({
|
|
||||||
databaseId: appwriteConfig.databaseId,
|
|
||||||
tableId: appwriteConfig.barcodeCollectionId,
|
|
||||||
queries: [
|
|
||||||
Query.equal("scope", "user"),
|
|
||||||
Query.equal("ownerUserId", userId),
|
|
||||||
Query.equal("barcode", barcode),
|
|
||||||
Query.limit(1),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.rows[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromVerifiedRow(row: BarcodeRow): BarcodeSeedProduct {
|
|
||||||
return {
|
|
||||||
flavourName: row.flavourName,
|
|
||||||
sizeMl: row.sizeMl,
|
|
||||||
pricePerCan: row.pricePerCan,
|
|
||||||
sugarFree: row.sugarFree,
|
|
||||||
caffeineMgPerCan: row.caffeineMgPerCan,
|
|
||||||
verifiedBy: row.verifiedBy || "Verified source",
|
|
||||||
sourceName: row.sourceName,
|
|
||||||
sourceUrl: row.sourceUrl,
|
|
||||||
variant: row.variant,
|
|
||||||
notes: row.notes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromUserRow(row: BarcodeRow): UserBarcodeMapping {
|
|
||||||
return {
|
|
||||||
barcode: row.barcode,
|
|
||||||
flavourName: row.flavourName,
|
|
||||||
sizeMl: row.sizeMl,
|
|
||||||
pricePerCan: row.pricePerCan,
|
|
||||||
sugarFree: row.sugarFree,
|
|
||||||
caffeineMgPerCan: row.caffeineMgPerCan,
|
|
||||||
createdAt: row.$createdAt,
|
|
||||||
updatedAt: row.$updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toUserRowData(userId: string, barcode: string, product: BarcodeProductDraft) {
|
|
||||||
return {
|
|
||||||
scope: "user" as const,
|
|
||||||
ownerUserId: userId,
|
|
||||||
barcode,
|
|
||||||
flavourName: product.flavourName,
|
|
||||||
sizeMl: product.sizeMl,
|
|
||||||
pricePerCan: product.pricePerCan,
|
|
||||||
sugarFree: Boolean(product.sugarFree),
|
|
||||||
caffeineMgPerCan: product.caffeineMgPerCan,
|
|
||||||
verifiedBy: "User saved mapping",
|
|
||||||
sourceName: "",
|
|
||||||
sourceUrl: "",
|
|
||||||
variant: "user",
|
|
||||||
notes: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function userRowPermissions(userId: string) {
|
|
||||||
const role = Role.user(userId);
|
|
||||||
return [Permission.read(role), Permission.update(role), Permission.delete(role)];
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
type BarcodeDetectorConstructor = {
|
|
||||||
new (options?: { formats?: string[] }): {
|
|
||||||
detect: (source: ImageBitmapSource) => Promise<Array<{ rawValue?: string; format?: string }>>;
|
|
||||||
};
|
|
||||||
getSupportedFormats?: () => Promise<string[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WindowWithBarcodeDetector = Window & {
|
|
||||||
BarcodeDetector?: BarcodeDetectorConstructor;
|
|
||||||
};
|
|
||||||
|
|
||||||
let detectorReady: Promise<void> | null = null;
|
|
||||||
|
|
||||||
export function isAppleMobileDevice() {
|
|
||||||
if (typeof navigator === "undefined") return false;
|
|
||||||
const ua = navigator.userAgent;
|
|
||||||
return /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadBarcodeDetectorPolyfill() {
|
|
||||||
const { BarcodeDetectorPolyfill } = await import("@undecaf/barcode-detector-polyfill");
|
|
||||||
return BarcodeDetectorPolyfill;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureBarcodeDetector() {
|
|
||||||
if (detectorReady) return detectorReady;
|
|
||||||
|
|
||||||
detectorReady = (async () => {
|
|
||||||
const globalWindow = window as WindowWithBarcodeDetector;
|
|
||||||
const shouldForcePolyfill = isAppleMobileDevice();
|
|
||||||
|
|
||||||
if (shouldForcePolyfill) {
|
|
||||||
globalWindow.BarcodeDetector = await loadBarcodeDetectorPolyfill();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const getSupportedFormats = globalWindow.BarcodeDetector?.getSupportedFormats;
|
|
||||||
if (!getSupportedFormats) return;
|
|
||||||
await getSupportedFormats.call(globalWindow.BarcodeDetector);
|
|
||||||
} catch {
|
|
||||||
globalWindow.BarcodeDetector = await loadBarcodeDetectorPolyfill();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return detectorReady;
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { BUILT_IN_BARCODE_PRODUCTS } from "../data/barcodes";
|
|
||||||
import { BUILT_IN_FLAVOURS, flavourMeta } from "../data/flavours";
|
|
||||||
import { caffeinePerCan } from "./metrics";
|
|
||||||
import type {
|
|
||||||
BarcodeLookupCatalog,
|
|
||||||
BarcodeLookupResult,
|
|
||||||
BarcodeProductDraft,
|
|
||||||
ResolvedBarcodeProduct,
|
|
||||||
UserBarcodeMapping,
|
|
||||||
EntryDraft,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
const knownFlavourNames = new Set(BUILT_IN_FLAVOURS.map((flavour) => flavour.name));
|
|
||||||
|
|
||||||
export function normalizeBarcode(value: string) {
|
|
||||||
return value.replace(/\D/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function lookupBarcode(
|
|
||||||
rawBarcode: string,
|
|
||||||
catalogOrUserMappings: BarcodeLookupCatalog | UserBarcodeMapping[] = [],
|
|
||||||
): BarcodeLookupResult {
|
|
||||||
const catalog = Array.isArray(catalogOrUserMappings)
|
|
||||||
? { userMappings: catalogOrUserMappings }
|
|
||||||
: catalogOrUserMappings;
|
|
||||||
const userMappings = catalog.userMappings ?? [];
|
|
||||||
const verifiedProducts = catalog.verifiedProducts ?? BUILT_IN_BARCODE_PRODUCTS;
|
|
||||||
const barcode = normalizeBarcode(rawBarcode);
|
|
||||||
if (!barcode) {
|
|
||||||
return { status: "unknown", barcode: rawBarcode.trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMapping = userMappings.find((mapping) => mapping.barcode === barcode);
|
|
||||||
if (userMapping) {
|
|
||||||
return { status: "user", barcode, product: resolveProduct(userMapping, "user") };
|
|
||||||
}
|
|
||||||
|
|
||||||
const seedProduct = verifiedProducts[barcode];
|
|
||||||
if (!seedProduct) {
|
|
||||||
return { status: "unknown", barcode };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!knownFlavourNames.has(seedProduct.flavourName)) {
|
|
||||||
return {
|
|
||||||
status: "partial",
|
|
||||||
barcode,
|
|
||||||
product: seedProduct,
|
|
||||||
reason: "This barcode has product data, but its flavour is not in the built-in Red Bull list yet.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: "known", barcode, product: resolveProduct(seedProduct, "built-in") };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveProduct(
|
|
||||||
product: BarcodeProductDraft,
|
|
||||||
source: ResolvedBarcodeProduct["source"],
|
|
||||||
): ResolvedBarcodeProduct {
|
|
||||||
const meta = flavourMeta(product.flavourName);
|
|
||||||
return {
|
|
||||||
...product,
|
|
||||||
flavourAccent: meta.accent,
|
|
||||||
sugarFree: product.sugarFree ?? Boolean(meta.sugarFree),
|
|
||||||
caffeineMgPerCan: product.caffeineMgPerCan,
|
|
||||||
source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function barcodeProductToEntryDraft(
|
|
||||||
product: ResolvedBarcodeProduct,
|
|
||||||
barcode: string,
|
|
||||||
): EntryDraft {
|
|
||||||
return {
|
|
||||||
cans: 1,
|
|
||||||
flavour: product.flavourName,
|
|
||||||
flavourAccent: product.flavourAccent,
|
|
||||||
sizeMl: product.sizeMl,
|
|
||||||
pricePerCan: product.pricePerCan,
|
|
||||||
dateTime: new Date().toISOString(),
|
|
||||||
notes: `Barcode scan: ${barcode}`,
|
|
||||||
store: "",
|
|
||||||
sugarFree: Boolean(product.sugarFree),
|
|
||||||
caffeineMgPerCan: product.caffeineMgPerCan,
|
|
||||||
source: "manual",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function productCaffeineMg(product: BarcodeProductDraft) {
|
|
||||||
return caffeinePerCan(product.sizeMl, product.caffeineMgPerCan);
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
import {
|
|
||||||
BarcodeFormat,
|
|
||||||
BrowserCodeReader,
|
|
||||||
BrowserMultiFormatReader,
|
|
||||||
type IScannerControls,
|
|
||||||
} from "@zxing/browser";
|
|
||||||
import { ensureBarcodeDetector, isAppleMobileDevice } from "./barcodeDetectorSupport";
|
|
||||||
import { normalizeBarcode } from "./barcodeLookup";
|
|
||||||
|
|
||||||
export type BarcodeScannerErrorCode =
|
|
||||||
| "camera-denied"
|
|
||||||
| "no-camera"
|
|
||||||
| "unsupported"
|
|
||||||
| "camera-in-use"
|
|
||||||
| "unknown";
|
|
||||||
|
|
||||||
export type BarcodeScannerError = {
|
|
||||||
code: BarcodeScannerErrorCode;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BarcodeScanResult = {
|
|
||||||
value: string;
|
|
||||||
format: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BarcodeScannerController = {
|
|
||||||
mode: "native" | "zxing";
|
|
||||||
stop: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NativeBarcode = {
|
|
||||||
rawValue?: string;
|
|
||||||
format?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NativeBarcodeDetector = {
|
|
||||||
detect: (source: HTMLVideoElement) => Promise<NativeBarcode[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NativeBarcodeDetectorConstructor = new (options?: {
|
|
||||||
formats?: string[];
|
|
||||||
}) => NativeBarcodeDetector;
|
|
||||||
|
|
||||||
type WindowWithBarcodeDetector = Window & {
|
|
||||||
BarcodeDetector?: NativeBarcodeDetectorConstructor & {
|
|
||||||
getSupportedFormats?: () => Promise<string[]>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const NATIVE_FORMATS = ["ean_13", "ean_8", "upc_a", "upc_e"];
|
|
||||||
const ZXING_FORMATS = [
|
|
||||||
BarcodeFormat.EAN_13,
|
|
||||||
BarcodeFormat.EAN_8,
|
|
||||||
BarcodeFormat.UPC_A,
|
|
||||||
BarcodeFormat.UPC_E,
|
|
||||||
];
|
|
||||||
const PREFERRED_SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
|
||||||
video: {
|
|
||||||
facingMode: { ideal: "environment" },
|
|
||||||
width: { ideal: 1280 },
|
|
||||||
height: { ideal: 720 },
|
|
||||||
},
|
|
||||||
audio: false,
|
|
||||||
};
|
|
||||||
const IOS_NATIVE_SCAN_INTERVAL_MS = 150;
|
|
||||||
const VIDEO_READY_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
export async function startBarcodeScanner(
|
|
||||||
videoElement: HTMLVideoElement,
|
|
||||||
onResult: (result: BarcodeScanResult) => void,
|
|
||||||
onError: (error: BarcodeScannerError) => void,
|
|
||||||
): Promise<BarcodeScannerController> {
|
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
|
||||||
throw toScannerError(new Error("Camera access is not supported in this browser."));
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureBarcodeDetector();
|
|
||||||
|
|
||||||
if (await supportsNativeBarcodeDetector()) {
|
|
||||||
try {
|
|
||||||
return await startNativeBarcodeScanner(videoElement, onResult);
|
|
||||||
} catch (error) {
|
|
||||||
stopVideoStream(videoElement);
|
|
||||||
if (isCameraAccessError(error)) {
|
|
||||||
throw toScannerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return startZxingBarcodeScanner(videoElement, onResult, onError);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopVideoStream(videoElement: HTMLVideoElement | null) {
|
|
||||||
if (!videoElement) return;
|
|
||||||
const stream = videoElement.srcObject;
|
|
||||||
if (stream instanceof MediaStream) {
|
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
|
||||||
}
|
|
||||||
videoElement.pause();
|
|
||||||
videoElement.removeAttribute("src");
|
|
||||||
videoElement.srcObject = null;
|
|
||||||
videoElement.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function scannerErrorMessage(code: BarcodeScannerErrorCode) {
|
|
||||||
switch (code) {
|
|
||||||
case "camera-denied":
|
|
||||||
return "Camera permission was denied. Allow camera access, then try scanning again.";
|
|
||||||
case "no-camera":
|
|
||||||
return "No camera was found on this device. You can type the barcode instead.";
|
|
||||||
case "camera-in-use":
|
|
||||||
return "The camera looks busy in another app or browser tab. Close it there, then try again.";
|
|
||||||
case "unsupported":
|
|
||||||
return "Barcode scanning is not supported in this browser. You can type the barcode instead.";
|
|
||||||
case "unknown":
|
|
||||||
default:
|
|
||||||
return "The scanner could not start. You can type the barcode instead.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNativeBarcodeScanner(
|
|
||||||
videoElement: HTMLVideoElement,
|
|
||||||
onResult: (result: BarcodeScanResult) => void,
|
|
||||||
): Promise<BarcodeScannerController> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let stopped = false;
|
|
||||||
let animationFrame = 0;
|
|
||||||
let scanTimeout = 0;
|
|
||||||
let scanning = false;
|
|
||||||
let stream: MediaStream | null = null;
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
try {
|
|
||||||
stream = await getCameraStream();
|
|
||||||
prepareVideoElement(videoElement, stream);
|
|
||||||
await waitForVideoReady(videoElement);
|
|
||||||
|
|
||||||
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
|
||||||
if (!Detector) {
|
|
||||||
throw new Error("Native barcode detector unavailable.");
|
|
||||||
}
|
|
||||||
const detector = new Detector({ formats: NATIVE_FORMATS });
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
stopped = true;
|
|
||||||
window.cancelAnimationFrame(animationFrame);
|
|
||||||
window.clearTimeout(scanTimeout);
|
|
||||||
stopVideoStream(videoElement);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scan = async () => {
|
|
||||||
if (stopped || scanning) return;
|
|
||||||
scanning = true;
|
|
||||||
try {
|
|
||||||
if (isVideoFrameReady(videoElement)) {
|
|
||||||
const barcodes = await detector.detect(videoElement);
|
|
||||||
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
|
|
||||||
if (barcode?.rawValue) {
|
|
||||||
onResult({
|
|
||||||
value: normalizeBarcode(barcode.rawValue),
|
|
||||||
format: barcode.format ?? "unknown",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep scanning; transient frame errors are common on mobile Safari.
|
|
||||||
} finally {
|
|
||||||
scanning = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleNextScan = () => {
|
|
||||||
if (stopped) return;
|
|
||||||
if (isAppleMobileDevice()) {
|
|
||||||
scanTimeout = window.setTimeout(() => {
|
|
||||||
void scan().finally(() => {
|
|
||||||
if (!stopped) scheduleNextScan();
|
|
||||||
});
|
|
||||||
}, IOS_NATIVE_SCAN_INTERVAL_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
animationFrame = window.requestAnimationFrame(() => {
|
|
||||||
void scan().finally(() => {
|
|
||||||
if (!stopped) scheduleNextScan();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
scheduleNextScan();
|
|
||||||
|
|
||||||
resolve({ mode: "native", stop });
|
|
||||||
} catch (error) {
|
|
||||||
if (stream) stream.getTracks().forEach((track) => track.stop());
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void start();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startZxingBarcodeScanner(
|
|
||||||
videoElement: HTMLVideoElement,
|
|
||||||
onResult: (result: BarcodeScanResult) => void,
|
|
||||||
onError: (error: BarcodeScannerError) => void,
|
|
||||||
): Promise<BarcodeScannerController> {
|
|
||||||
const reader = new BrowserMultiFormatReader(undefined, {
|
|
||||||
delayBetweenScanAttempts: isAppleMobileDevice() ? 150 : 500,
|
|
||||||
});
|
|
||||||
reader.possibleFormats = ZXING_FORMATS;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stream = await getCameraStream();
|
|
||||||
prepareVideoElement(videoElement, stream);
|
|
||||||
await waitForVideoReady(videoElement);
|
|
||||||
|
|
||||||
const controls = await reader.decodeFromStream(stream, videoElement, (result, error) => {
|
|
||||||
if (result) {
|
|
||||||
onResult({
|
|
||||||
value: normalizeBarcode(result.getText()),
|
|
||||||
format: BarcodeFormat[result.getBarcodeFormat()] ?? "unknown",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (error && !/not.?found/i.test(error.name) && !/not.?found/i.test(error.message)) {
|
|
||||||
onError(toScannerError(error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: "zxing",
|
|
||||||
stop: () => stopZxingScanner(controls, videoElement),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
stopVideoStream(videoElement);
|
|
||||||
BrowserCodeReader.releaseAllStreams();
|
|
||||||
throw toScannerError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopZxingScanner(controls: IScannerControls, videoElement: HTMLVideoElement) {
|
|
||||||
controls.stop();
|
|
||||||
BrowserCodeReader.releaseAllStreams();
|
|
||||||
stopVideoStream(videoElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function supportsNativeBarcodeDetector() {
|
|
||||||
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
|
||||||
if (!Detector) return false;
|
|
||||||
if (!Detector.getSupportedFormats) return true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formats = await Detector.getSupportedFormats();
|
|
||||||
return NATIVE_FORMATS.some((format) => formats.includes(format));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCameraStream() {
|
|
||||||
const attempts: MediaStreamConstraints[] = [
|
|
||||||
PREFERRED_SCAN_CONSTRAINTS,
|
|
||||||
{ video: { facingMode: { ideal: "environment" } }, audio: false },
|
|
||||||
{ video: { facingMode: "environment" }, audio: false },
|
|
||||||
{ video: true, audio: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
let lastError: unknown;
|
|
||||||
for (const constraints of attempts) {
|
|
||||||
try {
|
|
||||||
return await navigator.mediaDevices.getUserMedia(constraints);
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
if (isCameraAccessError(error) && !(error instanceof DOMException && error.name === "OverconstrainedError")) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError ?? new Error("Could not access the camera.");
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareVideoElement(videoElement: HTMLVideoElement, stream: MediaStream) {
|
|
||||||
videoElement.srcObject = stream;
|
|
||||||
videoElement.setAttribute("playsinline", "true");
|
|
||||||
videoElement.setAttribute("webkit-playsinline", "true");
|
|
||||||
videoElement.setAttribute("autoplay", "true");
|
|
||||||
videoElement.muted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVideoFrameReady(videoElement: HTMLVideoElement) {
|
|
||||||
return videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && videoElement.videoWidth > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVideoReady(videoElement: HTMLVideoElement) {
|
|
||||||
if (isVideoFrameReady(videoElement)) {
|
|
||||||
await playVideoElement(videoElement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
let settled = false;
|
|
||||||
|
|
||||||
const settle = (action: () => void) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
cleanup();
|
|
||||||
action();
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryReady = () => {
|
|
||||||
if (!isVideoFrameReady(videoElement)) return false;
|
|
||||||
settle(() => {
|
|
||||||
void playVideoElement(videoElement).then(resolve).catch(reject);
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onReady = () => {
|
|
||||||
tryReady();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onError = () => {
|
|
||||||
settle(() => reject(new Error("Camera preview failed to start.")));
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
videoElement.removeEventListener("loadedmetadata", onReady);
|
|
||||||
videoElement.removeEventListener("loadeddata", onReady);
|
|
||||||
videoElement.removeEventListener("error", onError);
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
settle(() => reject(new Error("Camera preview timed out.")));
|
|
||||||
}, VIDEO_READY_TIMEOUT_MS);
|
|
||||||
|
|
||||||
videoElement.addEventListener("loadedmetadata", onReady);
|
|
||||||
videoElement.addEventListener("loadeddata", onReady);
|
|
||||||
videoElement.addEventListener("error", onError, { once: true });
|
|
||||||
|
|
||||||
tryReady();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playVideoElement(videoElement: HTMLVideoElement) {
|
|
||||||
try {
|
|
||||||
await videoElement.play();
|
|
||||||
} catch (error) {
|
|
||||||
if (videoElement.paused) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCameraAccessError(error: unknown) {
|
|
||||||
if (!(error instanceof DOMException)) return false;
|
|
||||||
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toScannerError(error: unknown): BarcodeScannerError {
|
|
||||||
if (error instanceof DOMException) {
|
|
||||||
if (error.name === "NotAllowedError" || error.name === "SecurityError") {
|
|
||||||
return { code: "camera-denied", message: scannerErrorMessage("camera-denied") };
|
|
||||||
}
|
|
||||||
if (error.name === "NotFoundError" || error.name === "OverconstrainedError") {
|
|
||||||
return { code: "no-camera", message: scannerErrorMessage("no-camera") };
|
|
||||||
}
|
|
||||||
if (error.name === "NotReadableError" || error.name === "TrackStartError") {
|
|
||||||
return { code: "camera-in-use", message: scannerErrorMessage("camera-in-use") };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && /not.?found|video input|requested device/i.test(error.message)) {
|
|
||||||
return { code: "no-camera", message: scannerErrorMessage("no-camera") };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && /not.?allowed|permission|denied/i.test(error.message)) {
|
|
||||||
return { code: "camera-denied", message: scannerErrorMessage("camera-denied") };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && /in use|busy|could not start video source/i.test(error.message)) {
|
|
||||||
return { code: "camera-in-use", message: scannerErrorMessage("camera-in-use") };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && /not supported|unsupported|barcode detector unavailable/i.test(error.message)) {
|
|
||||||
return { code: "unsupported", message: scannerErrorMessage("unsupported") };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { code: "unknown", message: scannerErrorMessage("unknown") };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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)];
|
||||||
|
}
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
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)];
|
|
||||||
}
|
|
||||||
+8
-36
@@ -1,5 +1,3 @@
|
|||||||
import type { LimitCheckResult } from "../types";
|
|
||||||
import { formatStopTimeLabel } from "./userLimits";
|
|
||||||
import { groupByFlavour } from "./metrics";
|
import { groupByFlavour } from "./metrics";
|
||||||
|
|
||||||
type GreetingInput = {
|
type GreetingInput = {
|
||||||
@@ -9,8 +7,6 @@ type GreetingInput = {
|
|||||||
currentStreak: number;
|
currentStreak: number;
|
||||||
todayCaffeineMg: number;
|
todayCaffeineMg: number;
|
||||||
allTimeCans: number;
|
allTimeCans: number;
|
||||||
dailyCanLimit?: number;
|
|
||||||
limitCheck?: LimitCheckResult;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type GreetingResult = {
|
type GreetingResult = {
|
||||||
@@ -42,22 +38,14 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
|||||||
if (cans === 0) {
|
if (cans === 0) {
|
||||||
headline =
|
headline =
|
||||||
streak > 0
|
streak > 0
|
||||||
? `${input.name}, nothing logged yet today. ${streak}-day streak still alive.`
|
? `${input.name}, nothing logged yet today — ${streak}-day streak still alive.`
|
||||||
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
|
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
|
||||||
} else if (cans === 1) {
|
} else if (cans === 1) {
|
||||||
headline = `${input.name}, one Red Bull in so far today.`;
|
headline = `${input.name}, one Red Bull in so far today.`;
|
||||||
} else if (input.dailyCanLimit != null) {
|
|
||||||
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.`;
|
|
||||||
} else {
|
|
||||||
headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
|
|
||||||
}
|
|
||||||
} else if (cans <= 3) {
|
} else if (cans <= 3) {
|
||||||
headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
|
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
||||||
} else {
|
} else {
|
||||||
headline = `${input.name}, ${cans} Red Bulls today. Worth watching the caffeine curve.`;
|
headline = `${input.name}, ${cans} Red Bulls today — worth watching the caffeine curve.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flavourLine = favourite
|
const flavourLine = favourite
|
||||||
@@ -66,38 +54,22 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
|||||||
: `All-time favourite: ${favourite} (${input.allTimeCans} cans logged).`
|
: `All-time favourite: ${favourite} (${input.allTimeCans} cans logged).`
|
||||||
: "Your flavour story is just getting started.";
|
: "Your flavour story is just getting started.";
|
||||||
|
|
||||||
const stopLine =
|
|
||||||
input.limitCheck?.pastStopTime && input.limitCheck?.violations.includes("stopTime")
|
|
||||||
? "You're past your stop time for today."
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const caffeineLine =
|
const caffeineLine =
|
||||||
stopLine ??
|
cans > 0 && input.todayCaffeineMg > 0
|
||||||
(cans > 0 && input.todayCaffeineMg > 0
|
|
||||||
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
|
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
|
||||||
: hour >= 17 && cans === 0
|
: hour >= 17 && cans === 0
|
||||||
? "Evening reset. Clean slate if you want it."
|
? "Evening reset — clean slate if you want it."
|
||||||
: hour >= 22
|
: hour >= 22
|
||||||
? "Late night. Pace yourself if you're still going."
|
? "Late night — pace yourself if you're still going."
|
||||||
: "Log an intake to unlock today's signals.");
|
: "Log an intake to unlock today's signals.";
|
||||||
|
|
||||||
const limitLine =
|
|
||||||
input.dailyCanLimit != null && cans > 0
|
|
||||||
? `${cans}/${input.dailyCanLimit} cans toward your daily limit.`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
badge,
|
badge,
|
||||||
headline,
|
headline,
|
||||||
subline: [flavourLine, limitLine ?? caffeineLine].filter(Boolean).join(" "),
|
subline: [flavourLine, caffeineLine].join(" "),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopTimeGreetingHint(stopTime?: string, pastStopTime?: boolean) {
|
|
||||||
if (!stopTime || !pastStopTime) return null;
|
|
||||||
return `Past your ${formatStopTimeLabel(stopTime)} stop time.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFlavourHistorySummary(entries: Parameters<typeof groupByFlavour>[0]) {
|
export function buildFlavourHistorySummary(entries: Parameters<typeof groupByFlavour>[0]) {
|
||||||
const breakdown = groupByFlavour(entries);
|
const breakdown = groupByFlavour(entries);
|
||||||
if (!breakdown.length) return "No flavour history yet.";
|
if (!breakdown.length) return "No flavour history yet.";
|
||||||
|
|||||||
+1
-16
@@ -1,4 +1,4 @@
|
|||||||
import type { BuiltInSize, RedBullEntry } from "../types";
|
import type { RedBullEntry } from "../types";
|
||||||
|
|
||||||
export const CAFFEINE_PER_250ML = 80;
|
export const CAFFEINE_PER_250ML = 80;
|
||||||
export const SUGAR_PER_250ML = 27;
|
export const SUGAR_PER_250ML = 27;
|
||||||
@@ -8,21 +8,6 @@ export const STANDARD_CAN_VALUES = {
|
|||||||
473: { pricePerCan: 2.85, caffeineMg: 151 },
|
473: { pricePerCan: 2.85, caffeineMg: 151 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const BUILT_IN_SIZES: BuiltInSize[] = [250, 355, 473];
|
|
||||||
|
|
||||||
export function priceForLimitSize(size: BuiltInSize): number {
|
|
||||||
return STANDARD_CAN_VALUES[size].pricePerCan;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function spendLimitFromCans(cans: number, size: BuiltInSize): number {
|
|
||||||
return Math.round(cans * priceForLimitSize(size) * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canLimitFromSpend(spend: number, size: BuiltInSize): number {
|
|
||||||
const raw = spend / priceForLimitSize(size);
|
|
||||||
return Math.round(raw * 4) / 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function spendFor(entry: RedBullEntry) {
|
export function spendFor(entry: RedBullEntry) {
|
||||||
return entry.cans * entry.pricePerCan;
|
return entry.cans * entry.pricePerCan;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
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, RedBullEntry } from "../types";
|
||||||
|
|
||||||
|
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[]) {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}, [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) },
|
||||||
|
...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, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, 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[]) {
|
||||||
|
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.`,
|
||||||
|
`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 };
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { normalizeBarcode } from "./barcodeLookup";
|
|
||||||
import type { BarcodeProductDraft, UserBarcodeMapping } from "../types";
|
|
||||||
|
|
||||||
const STORAGE_PREFIX = "red-bull-barcode-mappings:v1";
|
|
||||||
|
|
||||||
export function loadUserBarcodeMappings(userId: string) {
|
|
||||||
const raw = localStorage.getItem(storageKey(userId));
|
|
||||||
if (!raw) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed)) return [];
|
|
||||||
return parsed.filter(isUserBarcodeMapping);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveUserBarcodeMappings(userId: string, mappings: UserBarcodeMapping[]) {
|
|
||||||
localStorage.setItem(storageKey(userId), JSON.stringify(mappings));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upsertUserBarcodeMapping(
|
|
||||||
userId: string,
|
|
||||||
barcodeValue: string,
|
|
||||||
product: BarcodeProductDraft,
|
|
||||||
) {
|
|
||||||
const barcode = normalizeBarcode(barcodeValue);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const mappings = loadUserBarcodeMappings(userId);
|
|
||||||
const existing = mappings.find((mapping) => mapping.barcode === barcode);
|
|
||||||
const nextMapping: UserBarcodeMapping = {
|
|
||||||
...product,
|
|
||||||
barcode,
|
|
||||||
createdAt: existing?.createdAt ?? now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
const nextMappings = existing
|
|
||||||
? mappings.map((mapping) => (mapping.barcode === barcode ? nextMapping : mapping))
|
|
||||||
: [...mappings, nextMapping];
|
|
||||||
|
|
||||||
saveUserBarcodeMappings(userId, nextMappings);
|
|
||||||
return nextMapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
function storageKey(userId: string) {
|
|
||||||
return `${STORAGE_PREFIX}:${userId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUserBarcodeMapping(value: unknown): value is UserBarcodeMapping {
|
|
||||||
if (!value || typeof value !== "object") return false;
|
|
||||||
const mapping = value as Partial<UserBarcodeMapping>;
|
|
||||||
return (
|
|
||||||
typeof mapping.barcode === "string" &&
|
|
||||||
typeof mapping.flavourName === "string" &&
|
|
||||||
typeof mapping.sizeMl === "number" &&
|
|
||||||
typeof mapping.pricePerCan === "number" &&
|
|
||||||
typeof mapping.createdAt === "string" &&
|
|
||||||
typeof mapping.updatedAt === "string" &&
|
|
||||||
(mapping.sugarFree === undefined || typeof mapping.sugarFree === "boolean") &&
|
|
||||||
(mapping.caffeineMgPerCan === undefined || typeof mapping.caffeineMgPerCan === "number")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import type { EntryDraft, LimitCheckResult, LimitViolation, RedBullEntry, UserLimits } from "../types";
|
|
||||||
import { getBstHour } from "./greeting";
|
|
||||||
import { currency, spendFor, sum } from "./metrics";
|
|
||||||
|
|
||||||
export const DEFAULT_LIMITS: UserLimits = {};
|
|
||||||
|
|
||||||
const PREFS_CAN_KEY = "dailyCanLimit";
|
|
||||||
const PREFS_SPEND_KEY = "dailySpendLimit";
|
|
||||||
const PREFS_STOP_KEY = "stopTime";
|
|
||||||
const PREFS_SIZE_KEY = "limitCanSizeMl";
|
|
||||||
|
|
||||||
const VALID_LIMIT_SIZES = new Set([250, 355, 473]);
|
|
||||||
|
|
||||||
export function parseUserLimits(prefs: Record<string, unknown> | null | undefined): UserLimits {
|
|
||||||
if (!prefs) return { ...DEFAULT_LIMITS };
|
|
||||||
|
|
||||||
const limits: UserLimits = {};
|
|
||||||
const canLimit = Number(prefs[PREFS_CAN_KEY]);
|
|
||||||
const spendLimit = Number(prefs[PREFS_SPEND_KEY]);
|
|
||||||
const stopTime = typeof prefs[PREFS_STOP_KEY] === "string" ? prefs[PREFS_STOP_KEY] : undefined;
|
|
||||||
|
|
||||||
const sizeLimit = Number(prefs[PREFS_SIZE_KEY]);
|
|
||||||
|
|
||||||
if (Number.isFinite(canLimit) && canLimit > 0) limits.dailyCanLimit = canLimit;
|
|
||||||
if (Number.isFinite(spendLimit) && spendLimit >= 0) limits.dailySpendLimit = spendLimit;
|
|
||||||
if (stopTime && /^\d{2}:\d{2}$/.test(stopTime)) limits.stopTime = stopTime;
|
|
||||||
if (VALID_LIMIT_SIZES.has(sizeLimit)) limits.limitCanSizeMl = sizeLimit as 250 | 355 | 473;
|
|
||||||
|
|
||||||
return limits;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeUserLimits(limits: UserLimits): Record<string, unknown> {
|
|
||||||
const data: Record<string, unknown> = {};
|
|
||||||
if (limits.dailyCanLimit != null && limits.dailyCanLimit > 0) {
|
|
||||||
data[PREFS_CAN_KEY] = limits.dailyCanLimit;
|
|
||||||
}
|
|
||||||
if (limits.dailySpendLimit != null && limits.dailySpendLimit >= 0) {
|
|
||||||
data[PREFS_SPEND_KEY] = limits.dailySpendLimit;
|
|
||||||
}
|
|
||||||
if (limits.stopTime) {
|
|
||||||
data[PREFS_STOP_KEY] = limits.stopTime;
|
|
||||||
}
|
|
||||||
if (limits.limitCanSizeMl != null && VALID_LIMIT_SIZES.has(limits.limitCanSizeMl)) {
|
|
||||||
data[PREFS_SIZE_KEY] = limits.limitCanSizeMl;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergePrefsWithLimits(
|
|
||||||
existing: Record<string, unknown> | null | undefined,
|
|
||||||
limits: UserLimits,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const next = { ...(existing ?? {}) };
|
|
||||||
delete next[PREFS_CAN_KEY];
|
|
||||||
delete next[PREFS_SPEND_KEY];
|
|
||||||
delete next[PREFS_STOP_KEY];
|
|
||||||
delete next[PREFS_SIZE_KEY];
|
|
||||||
return { ...next, ...serializeUserLimits(limits) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatBstDateKey(date = new Date()) {
|
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: "Europe/London",
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBstMinutes(date = new Date()) {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
||||||
timeZone: "Europe/London",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
}).formatToParts(date);
|
|
||||||
|
|
||||||
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
|
||||||
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
|
||||||
return hour * 60 + minute;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseStopTimeMinutes(stopTime: string) {
|
|
||||||
const [hours, minutes] = stopTime.split(":").map((value) => Number(value));
|
|
||||||
return hours * 60 + minutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPastStopTime(stopTime: string | undefined, date = new Date()) {
|
|
||||||
if (!stopTime) return false;
|
|
||||||
return getBstMinutes(date) >= parseStopTimeMinutes(stopTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatStopTimeLabel(stopTime: string) {
|
|
||||||
const [hours, minutes] = stopTime.split(":").map((value) => Number(value));
|
|
||||||
const date = new Date();
|
|
||||||
date.setHours(hours, minutes, 0, 0);
|
|
||||||
return new Intl.DateTimeFormat("en-GB", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function entriesTodayBst(entries: RedBullEntry[], ref = new Date()) {
|
|
||||||
const key = formatBstDateKey(ref);
|
|
||||||
return entries.filter((entry) => formatBstDateKey(new Date(entry.dateTime)) === key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function spendForDraft(draft: EntryDraft) {
|
|
||||||
return draft.cans * draft.pricePerCan;
|
|
||||||
}
|
|
||||||
|
|
||||||
function todayTotals(entries: RedBullEntry[], excludeEntryId?: string, ref = new Date()) {
|
|
||||||
const todayEntries = entriesTodayBst(entries, ref).filter((entry) => entry.id !== excludeEntryId);
|
|
||||||
return {
|
|
||||||
todayCans: sum(todayEntries, (entry) => entry.cans),
|
|
||||||
todaySpend: sum(todayEntries, spendFor),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluateLimits(
|
|
||||||
limits: UserLimits,
|
|
||||||
entries: RedBullEntry[],
|
|
||||||
options?: { draft?: EntryDraft; excludeEntryId?: string; at?: Date },
|
|
||||||
): LimitCheckResult {
|
|
||||||
const ref = options?.at ?? new Date();
|
|
||||||
const { todayCans, todaySpend } = todayTotals(entries, options?.excludeEntryId, ref);
|
|
||||||
const draft = options?.draft;
|
|
||||||
const projectedCans = draft ? todayCans + draft.cans : todayCans;
|
|
||||||
const projectedSpend = draft ? todaySpend + spendForDraft(draft) : todaySpend;
|
|
||||||
const checkTime = draft?.dateTime ? new Date(draft.dateTime) : ref;
|
|
||||||
const pastStopTime = limits.stopTime ? isPastStopTime(limits.stopTime, checkTime) : false;
|
|
||||||
|
|
||||||
const violations: LimitViolation[] = [];
|
|
||||||
|
|
||||||
if (limits.dailyCanLimit != null) {
|
|
||||||
const over = draft ? projectedCans > limits.dailyCanLimit : todayCans >= limits.dailyCanLimit;
|
|
||||||
if (over) violations.push("cans");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (limits.dailySpendLimit != null) {
|
|
||||||
const over = draft ? projectedSpend > limits.dailySpendLimit : todaySpend >= limits.dailySpendLimit;
|
|
||||||
if (over) violations.push("spend");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (limits.stopTime && pastStopTime) {
|
|
||||||
violations.push("stopTime");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
violations,
|
|
||||||
projectedCans,
|
|
||||||
projectedSpend,
|
|
||||||
todayCans,
|
|
||||||
todaySpend,
|
|
||||||
pastStopTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function limitProgress(current: number, limit?: number) {
|
|
||||||
if (!limit || limit <= 0) return 0;
|
|
||||||
return Math.min(100, Math.round((current / limit) * 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function limitStatusMessage(
|
|
||||||
violations: LimitViolation[],
|
|
||||||
check: LimitCheckResult,
|
|
||||||
limits: UserLimits,
|
|
||||||
): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
if (violations.includes("cans") && limits.dailyCanLimit != null) {
|
|
||||||
lines.push(
|
|
||||||
`This would bring you to ${check.projectedCans.toFixed(1)}/${limits.dailyCanLimit} cans today (BST).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (violations.includes("spend") && limits.dailySpendLimit != null) {
|
|
||||||
lines.push(
|
|
||||||
`This would bring today's spend to ${currency.format(check.projectedSpend)} of your ${currency.format(limits.dailySpendLimit)} limit.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (violations.includes("stopTime") && limits.stopTime) {
|
|
||||||
lines.push(`You're past your stop time (${formatStopTimeLabel(limits.stopTime)} BST).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasAnyLimit(limits: UserLimits) {
|
|
||||||
return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { getBstHour };
|
|
||||||
@@ -34,57 +34,6 @@ export type EntryDraft = Omit<
|
|||||||
source?: RedBullEntry["source"];
|
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 = {
|
export type Filters = {
|
||||||
flavour: string;
|
flavour: string;
|
||||||
dateRange: DateFilter;
|
dateRange: DateFilter;
|
||||||
@@ -106,24 +55,6 @@ export type ImportPreview = {
|
|||||||
rows: ImportPreviewRow[];
|
rows: ImportPreviewRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserLimits = {
|
|
||||||
dailyCanLimit?: number;
|
|
||||||
dailySpendLimit?: number;
|
|
||||||
stopTime?: string;
|
|
||||||
limitCanSizeMl?: BuiltInSize;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LimitViolation = "cans" | "spend" | "stopTime";
|
|
||||||
|
|
||||||
export type LimitCheckResult = {
|
|
||||||
violations: LimitViolation[];
|
|
||||||
projectedCans: number;
|
|
||||||
projectedSpend: number;
|
|
||||||
todayCans: number;
|
|
||||||
todaySpend: number;
|
|
||||||
pastStopTime: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatRole = "user" | "assistant";
|
export type ChatRole = "user" | "assistant";
|
||||||
|
|
||||||
export type CoachMessage = {
|
export type CoachMessage = {
|
||||||
|
|||||||
Vendored
+2
-1
@@ -5,9 +5,10 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||||
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
||||||
readonly VITE_APPWRITE_COLLECTION_ID?: string;
|
readonly VITE_APPWRITE_COLLECTION_ID?: string;
|
||||||
readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string;
|
readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string;
|
||||||
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
|
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
|
||||||
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
||||||
|
readonly VITE_OLLAMA_PROXY_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
+13
-12
@@ -6,20 +6,21 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
display: [
|
display: [
|
||||||
"SF Pro Display",
|
"Google Sans",
|
||||||
"SF Pro Text",
|
"Google Sans Text",
|
||||||
|
"Product Sans",
|
||||||
|
"Roboto",
|
||||||
"-apple-system",
|
"-apple-system",
|
||||||
"BlinkMacSystemFont",
|
"BlinkMacSystemFont",
|
||||||
"Avenir Next",
|
|
||||||
"Helvetica Neue",
|
|
||||||
"sans-serif",
|
"sans-serif",
|
||||||
],
|
],
|
||||||
body: [
|
body: [
|
||||||
"SF Pro Text",
|
"Google Sans",
|
||||||
|
"Google Sans Text",
|
||||||
|
"Product Sans",
|
||||||
|
"Roboto",
|
||||||
"-apple-system",
|
"-apple-system",
|
||||||
"BlinkMacSystemFont",
|
"BlinkMacSystemFont",
|
||||||
"Avenir Next",
|
|
||||||
"Helvetica Neue",
|
|
||||||
"sans-serif",
|
"sans-serif",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -38,11 +39,11 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
apple: "0 18px 55px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18)",
|
apple: "0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08)",
|
||||||
fridge: "0 18px 70px rgba(0, 0, 0, 0.34), 0 1px 2px rgba(255, 255, 255, 0.06)",
|
fridge: "0 2px 6px rgba(69, 54, 62, 0.12), 0 8px 18px rgba(69, 54, 62, 0.08)",
|
||||||
can: "0 10px 24px rgba(57, 213, 255, 0.12)",
|
can: "0 1px 2px rgba(156, 65, 104, 0.18), 0 3px 8px rgba(156, 65, 104, 0.10)",
|
||||||
redline: "0 12px 28px rgba(255, 52, 72, 0.26)",
|
redline: "0 2px 8px rgba(186, 26, 26, 0.20)",
|
||||||
cyan: "0 14px 32px rgba(57, 213, 255, 0.18)",
|
cyan: "0 1px 2px rgba(156, 65, 104, 0.16), 0 4px 12px rgba(156, 65, 104, 0.10)",
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
"carbon-grid":
|
"carbon-grid":
|
||||||
|
|||||||
+118
-15
@@ -1,10 +1,37 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
|
||||||
plugins: [react(), deploymentHtml(command === "build")],
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
|
const ollamaProxy = {
|
||||||
|
target: "https://ollama.com",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: () => "/api/chat",
|
||||||
|
configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) {
|
||||||
|
proxy.on("proxyReq", (proxyReq) => {
|
||||||
|
if (env.OLLAMA_API_KEY) {
|
||||||
|
proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react(), ollamaProxyPlugin(env)],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api/ollama-chat": ollamaProxy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
proxy: {
|
||||||
|
"/api/ollama-chat": ollamaProxy,
|
||||||
|
},
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 700,
|
chunkSizeWarningLimit: 700,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
@@ -17,21 +44,97 @@ export default defineConfig(({ command }) => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function deploymentHtml(enabled: boolean): Plugin {
|
function ollamaProxyPlugin(env: Record<string, string>): Plugin {
|
||||||
return {
|
return {
|
||||||
name: "deployment-html",
|
name: "ollama-proxy",
|
||||||
transformIndexHtml(html) {
|
configureServer(server) {
|
||||||
if (!enabled) return html;
|
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
||||||
return html
|
},
|
||||||
.replace("</head>", `${readOptional(".deploy/head.html")}</head>`)
|
configurePreviewServer(server) {
|
||||||
.replace("</body>", `${readOptional(".deploy/body-end.html")}</body>`);
|
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readOptional(path: string) {
|
function createOllamaHandler(env: Record<string, string>) {
|
||||||
if (!existsSync(path)) return "";
|
return (req: IncomingMessage, res: ServerResponse) => {
|
||||||
return `\n${readFileSync(path, "utf8").trim()}\n`;
|
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>) : {};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user