Compare commits
12
Commits
main
..
98635ca238
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98635ca238 | ||
|
|
839d00aee1 | ||
|
|
023ec1096f | ||
|
|
a7993af1d2 | ||
|
|
f4a046ec9d | ||
|
|
34c048d63e | ||
|
|
e3ba9bab6b | ||
|
|
b4e0615e77 | ||
|
|
e067a3638c | ||
|
|
de6ce0c350 | ||
|
|
94c906cc59 | ||
|
|
a9a35cc751 |
+15
-2
@@ -1,8 +1,21 @@
|
||||
VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=your-project-id
|
||||
VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138
|
||||
VITE_APPWRITE_DATABASE_ID=redbull_tracker
|
||||
VITE_APPWRITE_COLLECTION_ID=intake_entries
|
||||
VITE_APPWRITE_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.
|
||||
APPWRITE_API_KEY=
|
||||
|
||||
# Appwrite chat table columns: userId, title, messages, updatedAt.
|
||||
# Enable row security and Users -> Create at table level.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
name: Build & Lint
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run lint || true
|
||||
- run: npm run build
|
||||
@@ -7,5 +7,3 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.deploy/
|
||||
public/*.html
|
||||
|
||||
@@ -15,14 +15,10 @@ personalized, and privacy-first self-tracking experience.
|
||||
- `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.
|
||||
- `public/` – PWA assets: web manifest and app icons, served at the site root.
|
||||
- `src/` – Contains client-side React source code, components, utility models, and stylesheets.
|
||||
- `src/components/` – Feature components (modals, panels, ledger, shell pieces); `src/components/views/`
|
||||
holds the four main views plus auth, and `src/components/ui/` holds the shared design primitives
|
||||
(Button, Card, Field, Badge, EmptyState).
|
||||
- `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, Appwrite client connections,
|
||||
motion constants, toast context, and the localStorage entry cache.
|
||||
- `src/lib/` – Business logic engines for calculations, file parsers, and Appwrite client connections.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
@@ -208,10 +204,6 @@ The repository does not currently feature automated test files. Testing is execu
|
||||
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.
|
||||
- **Motion Language**: Shared animation durations and the standard ease-out curve live in
|
||||
`src/lib/motion.ts` (`VIEW_TRANSITION`, `MODAL_TRANSITION`, `TOAST_TRANSITION`, `TILE_TRANSITION`).
|
||||
The root tree is wrapped in `MotionConfig reducedMotion="user"` (`src/main.tsx`), and `src/index.css`
|
||||
carries the global `prefers-reduced-motion` neutralizer plus the shared hover/press interaction rules.
|
||||
- **Configurable Environment Parameters**:
|
||||
- `VITE_APPWRITE_ENDPOINT` – Base URL for the Appwrite API server.
|
||||
- `VITE_APPWRITE_PROJECT_ID` – The Appwrite project instance identifier.
|
||||
|
||||
+197
-52
@@ -1,76 +1,221 @@
|
||||
# Red Bull tracker setup
|
||||
# Red Bull Intake Tracker Setup
|
||||
|
||||
This app uses Appwrite for auth and intake entries.
|
||||
## Commands
|
||||
|
||||
## env
|
||||
|
||||
Copy `.env.example` to `.env.local`, then fill in:
|
||||
|
||||
```sh
|
||||
VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=your_project_id
|
||||
VITE_APPWRITE_DATABASE_ID=redbull_tracker
|
||||
VITE_APPWRITE_COLLECTION_ID=intake_entries
|
||||
APPWRITE_API_KEY=server_key_for_setup_only
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
The script creates or updates:
|
||||
The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code.
|
||||
|
||||
- database: `redbull_tracker`
|
||||
- table: `intake_entries`
|
||||
- table permission: `Users -> Create`
|
||||
- row security: enabled
|
||||
Configured defaults:
|
||||
|
||||
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`
|
||||
- Barcode collection ID: `barcode_products`
|
||||
|
||||
## intake columns
|
||||
`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`.
|
||||
|
||||
| key | type | required |
|
||||
| --- | --- | --- |
|
||||
| `userId` | String, 64 | Yes |
|
||||
| `cans` | Float | Yes |
|
||||
| `flavour` | String, 128 | Yes |
|
||||
| `flavourAccent` | String, 32 | Yes |
|
||||
| `sizeMl` | Integer | Yes |
|
||||
| `pricePerCan` | Float | Yes |
|
||||
| `dateTime` | DateTime | Yes |
|
||||
| `notes` | String, 2000 | No |
|
||||
| `store` | String, 256 | No |
|
||||
| `sugarFree` | Boolean | Yes |
|
||||
| `caffeineMgPerCan` | Float | No |
|
||||
| `importKey` | String, 512 | Yes |
|
||||
| `source` | String, 32 | Yes |
|
||||
## Auth
|
||||
|
||||
## indexes
|
||||
Enable these auth methods in Appwrite Console:
|
||||
|
||||
- `user_date_desc`: `userId`, `dateTime`
|
||||
- `user_import_key`: `userId`, `importKey`
|
||||
- Email/password
|
||||
- GitHub OAuth
|
||||
- Google OAuth
|
||||
|
||||
## run
|
||||
Add a Web platform in Appwrite Console for local development:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
- Hostname: `localhost`
|
||||
- Hostname: `127.0.0.1`
|
||||
|
||||
If `client.ping()` shows `Failed to fetch`, this is usually the first thing to check.
|
||||
|
||||
For local OAuth callback URLs, add:
|
||||
|
||||
- Success URL: `http://localhost:5173`
|
||||
- Failure URL: `http://localhost:5173`
|
||||
- If Vite starts on another port, add that origin too, for example `http://127.0.0.1:5174`
|
||||
|
||||
For production, add your deployed origin as both success and failure URL, then update the `VITE_APPWRITE_OAUTH_*` variables.
|
||||
|
||||
In local dev, you can leave `VITE_APPWRITE_OAUTH_SUCCESS_URL` and `VITE_APPWRITE_OAUTH_FAILURE_URL` blank. The app will use the current browser origin automatically, which avoids getting redirected to a stale Vite port.
|
||||
|
||||
If OAuth returns to the app but you are still logged out:
|
||||
|
||||
- Confirm the current browser origin is listed under Appwrite project platforms, for example `localhost` and `127.0.0.1`.
|
||||
- Confirm the same origin is allowed in the OAuth provider success/failure URLs.
|
||||
- Clear old sessions/cookies for the local app and try again.
|
||||
- Restart Vite after editing `.env.local`.
|
||||
|
||||
## Database
|
||||
|
||||
Appwrite currently uses newer Console wording in many places:
|
||||
|
||||
| In this app / older SDK wording | Current Appwrite Console wording |
|
||||
| --- | --- |
|
||||
| Collection | Table |
|
||||
| Attribute | Column |
|
||||
| Document | Row |
|
||||
|
||||
So if the Console asks you to create a **table**, that is the same resource as the `VITE_APPWRITE_COLLECTION_ID` this app currently points at. If the setup below says **attributes**, add them as **columns** inside that table.
|
||||
|
||||
The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID.
|
||||
|
||||
The barcode scanner uses a separate `barcode_products` table by default. Verified Red Bull barcode rows are seeded by `scripts/setup-appwrite.mjs` using `APPWRITE_API_KEY`; browser code can only read verified rows and create/update the current user's own mappings with row-level permissions.
|
||||
|
||||
Create a database with ID:
|
||||
|
||||
```text
|
||||
redbull_tracker
|
||||
```
|
||||
|
||||
## 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
|
||||
- `.deploy/body-end.html` for footer links or deploy-only markup
|
||||
- any local public HTML pages your host needs
|
||||
Recommended collection-level permissions:
|
||||
|
||||
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,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,6 +1,6 @@
|
||||
# 🐂 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.
|
||||
Track your Red Bull consumption with per-can logging, barcode scanning, spending insights, and an AI-powered coach. Built with React, Appwrite, and Material You theming.
|
||||
|
||||
   
|
||||
|
||||
@@ -9,13 +9,14 @@ Track your Red Bull consumption with per-can logging, barcode scanning, spending
|
||||
- **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
|
||||
- **AI coach** — ChatGPT-style chat interface powered by Ollama, keeps per-session context and gives caffeine/spending advice
|
||||
- **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
|
||||
- **Appwrite auth** — email/password + OAuth login, row-level security per user
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -25,6 +26,7 @@ Track your Red Bull consumption with per-can logging, barcode scanning, spending
|
||||
| Styling | Tailwind CSS, Framer Motion |
|
||||
| Charts | Recharts |
|
||||
| Backend | Appwrite Cloud (auth, database, storage) |
|
||||
| AI | Ollama (via server proxy) |
|
||||
| Barcode | @zxing/browser |
|
||||
| Import/Export | ExcelJS |
|
||||
|
||||
@@ -75,9 +77,12 @@ The app runs at `http://localhost:5173`.
|
||||
| `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 |
|
||||
| `VITE_APPWRITE_CHAT_COLLECTION_ID` | Yes | Coach chats collection ID |
|
||||
| `VITE_APPWRITE_OAUTH_SUCCESS_URL` | No | OAuth redirect URL |
|
||||
| `VITE_APPWRITE_OAUTH_FAILURE_URL` | No | OAuth failure redirect URL |
|
||||
| `VITE_OLLAMA_PROXY_URL` | No | AI coach proxy endpoint |
|
||||
| `OLLAMA_API_KEY` | No | Server-side Ollama API key |
|
||||
| `OLLAMA_MODEL` | No | Ollama model for coach (default: `deepseek-v4-pro:cloud`) |
|
||||
| `APPWRITE_API_KEY` | No | Admin key for `setup:appwrite` script only |
|
||||
|
||||
## Project Structure
|
||||
@@ -88,7 +93,7 @@ src/
|
||||
├── components/
|
||||
│ ├── BarcodeScannerModal.tsx # Camera barcode scanner
|
||||
│ ├── BarcodeProductPreview.tsx
|
||||
│ ├── CoachPanel.tsx # Coach chat UI
|
||||
│ ├── CoachPanel.tsx # AI coach chat UI
|
||||
│ ├── DailyLimitsCard.tsx # Limit status & warnings
|
||||
│ ├── LimitsSettingsForm.tsx
|
||||
│ └── OnboardingScreen.tsx
|
||||
@@ -104,8 +109,8 @@ src/
|
||||
│ ├── 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
|
||||
│ ├── coachChats.ts # Coach chat persistence
|
||||
│ ├── useCoachSession.ts # Coach chat hook
|
||||
│ ├── userLimits.ts # Daily limit logic
|
||||
│ ├── metrics.ts # Computed stats & charts
|
||||
│ ├── excel.ts # Excel import/export
|
||||
|
||||
@@ -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) : {};
|
||||
}
|
||||
+2
-5
@@ -2,14 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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
|
||||
name="description"
|
||||
content="A Red Bull intake tracker for logging cans, spending, caffeine, sugar, flavours, and trends, synced across devices with Appwrite Cloud."
|
||||
content="A local-first Red Bull intake web app for tracking cans, spending, caffeine, sugar, flavours, and trends."
|
||||
/>
|
||||
<meta name="theme-color" content="#2563c7" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>Red Bull Intake Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+39
-29
@@ -7,9 +7,7 @@
|
||||
"": {
|
||||
"name": "red-bull-intake-tracker",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@undecaf/barcode-detector-polyfill": "^0.9.23",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
"appwrite": "^25.0.0",
|
||||
@@ -1161,6 +1159,9 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1174,6 +1175,9 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1187,6 +1191,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1200,6 +1207,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1213,6 +1223,9 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1226,6 +1239,9 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1239,6 +1255,9 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1252,6 +1271,9 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1265,6 +1287,9 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1278,6 +1303,9 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1291,6 +1319,9 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1304,6 +1335,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1317,6 +1351,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1842,24 +1879,6 @@
|
||||
"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": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -3602,15 +3621,6 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"name": "red-bull-intake-tracker",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,7 +11,6 @@
|
||||
"setup:appwrite": "node scripts/setup-appwrite.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@undecaf/barcode-detector-polyfill": "^0.9.23",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
"appwrite": "^25.0.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "Red Bull Intake Tracker",
|
||||
"short_name": "Intake",
|
||||
"description": "Track Red Bull and caffeine intake — cans, spending, flavours, and trends — synced across devices.",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#eef3fb",
|
||||
"theme_color": "#2563c7",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+54
-16
@@ -9,6 +9,7 @@ const endpoint = readEnv("VITE_APPWRITE_ENDPOINT", "https://fra.cloud.appwrite.i
|
||||
const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138");
|
||||
const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
|
||||
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
|
||||
const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats");
|
||||
const barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products");
|
||||
const apiKey = readEnv("APPWRITE_API_KEY", "");
|
||||
const verifiedBarcodeProducts = JSON.parse(
|
||||
@@ -43,9 +44,32 @@ await ensureTable({
|
||||
{ key: "user_import_key", type: "key", columns: ["userId", "importKey"], orders: ["ASC", "ASC"], lengths: [32, 128] },
|
||||
],
|
||||
});
|
||||
await ensureTable({
|
||||
tableId: chatTableId,
|
||||
name: "Coach chats",
|
||||
columns: [
|
||||
{ kind: "string", key: "userId", size: 64, required: true },
|
||||
{ kind: "string", key: "title", size: 512, required: true },
|
||||
{ kind: "longtext", key: "messages", required: true },
|
||||
{ kind: "datetime", key: "updatedAt", required: true },
|
||||
],
|
||||
indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }],
|
||||
});
|
||||
await retireLegacyChatColumns(chatTableId, [
|
||||
"encryptedTitle",
|
||||
"encryptedMessages",
|
||||
"titleIv",
|
||||
"messagesIv",
|
||||
"salt",
|
||||
"version",
|
||||
]);
|
||||
await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]);
|
||||
await ensureTable({
|
||||
tableId: barcodeTableId,
|
||||
name: "Barcode products",
|
||||
// Schema notes:
|
||||
// - scope="verified" rows are seeded by this admin script and readable by signed-in users.
|
||||
// - scope="user" rows are created by the browser SDK with per-user row permissions.
|
||||
columns: [
|
||||
{ kind: "string", key: "scope", size: 16, required: true },
|
||||
{ kind: "string", key: "ownerUserId", size: 64, required: false },
|
||||
@@ -130,11 +154,41 @@ async function ensureColumn(tableId, column) {
|
||||
array: false,
|
||||
};
|
||||
if (column.size) body.size = column.size;
|
||||
if (column.encrypt) body.encrypt = true;
|
||||
|
||||
await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]);
|
||||
console.log(`Column ${tableId}.${column.key} created.`);
|
||||
}
|
||||
|
||||
async function retireLegacyChatColumns(tableId, keys) {
|
||||
for (const key of keys) {
|
||||
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]);
|
||||
if (existing.status === 404) {
|
||||
console.log(`Legacy column ${tableId}.${key} already removed.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await request("DELETE", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [204, 404]);
|
||||
console.log(`Legacy column ${tableId}.${key} removed.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureIndex(tableId, index) {
|
||||
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]);
|
||||
if (existing.status === 200) {
|
||||
console.log(`Index ${tableId}.${index.key} exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await request(
|
||||
"POST",
|
||||
`/tablesdb/${databaseId}/tables/${tableId}/indexes`,
|
||||
{ key: index.key, type: index.type, columns: index.columns, orders: index.orders, lengths: index.lengths },
|
||||
[202, 201],
|
||||
);
|
||||
console.log(`Index ${tableId}.${index.key} created.`);
|
||||
}
|
||||
|
||||
async function seedVerifiedBarcodeProducts(tableId, products) {
|
||||
for (const [barcode, product] of Object.entries(products)) {
|
||||
const rowId = `verified_${barcode}`;
|
||||
@@ -172,22 +226,6 @@ async function seedVerifiedBarcodeProducts(tableId, products) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureIndex(tableId, index) {
|
||||
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]);
|
||||
if (existing.status === 200) {
|
||||
console.log(`Index ${tableId}.${index.key} exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await request(
|
||||
"POST",
|
||||
`/tablesdb/${databaseId}/tables/${tableId}/indexes`,
|
||||
{ key: index.key, type: index.type, columns: index.columns, orders: index.orders, lengths: index.lengths },
|
||||
[202, 201],
|
||||
);
|
||||
console.log(`Index ${tableId}.${index.key} created.`);
|
||||
}
|
||||
|
||||
async function waitForColumns(tableId, keys) {
|
||||
const pending = new Set(keys);
|
||||
for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) {
|
||||
|
||||
+2477
-310
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type AppCardProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AppCard({ title, subtitle, children }: AppCardProps) {
|
||||
return (
|
||||
<section className="app-card p-4 sm:p-5">
|
||||
<div className="mb-4">
|
||||
<h2 className="app-card-title text-xl">{title}</h2>
|
||||
{subtitle && <p className="app-card-subtitle mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from "../lib/barcodeScanner";
|
||||
import { listBarcodeCatalog, upsertCloudUserBarcodeMapping } from "../lib/appwriteBarcodes";
|
||||
import { caffeinePerCan, currency, defaultPriceForSize, wholeNumber } from "../lib/metrics";
|
||||
import { MODAL_TRANSITION } from "../lib/motion";
|
||||
import {
|
||||
loadUserBarcodeMappings,
|
||||
upsertUserBarcodeMapping,
|
||||
@@ -40,7 +39,6 @@ import type {
|
||||
UserBarcodeMapping,
|
||||
} from "../types";
|
||||
import { BarcodeProductPreview } from "./BarcodeProductPreview";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error";
|
||||
|
||||
@@ -190,34 +188,27 @@ export function BarcodeScannerModal({
|
||||
setManualMessage("");
|
||||
setMappingSaving(false);
|
||||
applyManualDefaults();
|
||||
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
|
||||
|
||||
let active = true;
|
||||
let frameId = 0;
|
||||
const video = videoRef.current;
|
||||
if (!video) return undefined;
|
||||
|
||||
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 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");
|
||||
});
|
||||
|
||||
void listBarcodeCatalog()
|
||||
.then((catalog) => {
|
||||
@@ -233,11 +224,19 @@ export function BarcodeScannerModal({
|
||||
|
||||
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);
|
||||
@@ -299,226 +298,229 @@ export function BarcodeScannerModal({
|
||||
: "Scanner paused";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
labelledBy="barcode-scanner-title"
|
||||
initialFocusRef={closeButtonRef}
|
||||
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/70 backdrop-blur-xl"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<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={MODAL_TRANSITION}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3 backdrop-blur-xl sm:p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="barcode-scanner-title"
|
||||
>
|
||||
<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>
|
||||
<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="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Camera scan</p>
|
||||
<h2 id="barcode-scanner-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
|
||||
Scan barcode
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">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>
|
||||
|
||||
<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 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"
|
||||
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>
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{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
|
||||
<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"
|
||||
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>
|
||||
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>
|
||||
|
||||
{phase === "found" && product && (
|
||||
<BarcodeProductPreview
|
||||
barcode={activeBarcode}
|
||||
busy={busy}
|
||||
product={product}
|
||||
onAddNow={() => addProductNow(product)}
|
||||
onCancel={onClose}
|
||||
onEdit={() => editProductBeforeAdding(product)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</Modal>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { currency, oneDecimal, wholeNumber } from "../lib/metrics";
|
||||
|
||||
type ChartTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: Array<{ name: string; value: number; color?: string }>;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function ChartTooltip({ active, payload, label }: ChartTooltipProps) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="chart-tooltip">
|
||||
<p className="mb-1 text-sm font-medium" style={{ color: "var(--text)" }}>{label}</p>
|
||||
{payload.map((item) => (
|
||||
<p key={item.name} className="text-sm" style={{ color: "var(--muted)" }}>
|
||||
<span style={{ color: item.color }}>{item.name}</span>: {formatMetricValue(item.name, item.value)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMetricValue(name: string, value: number) {
|
||||
if (/spend/i.test(name)) return currency.format(value);
|
||||
if (/caffeine/i.test(name)) return `${wholeNumber.format(value)}mg`;
|
||||
if (/sugar/i.test(name)) return `${oneDecimal.format(value)}g`;
|
||||
return oneDecimal.format(value);
|
||||
}
|
||||
@@ -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,58 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||
import { MODAL_TRANSITION } from "../lib/motion";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
busy: boolean;
|
||||
open: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
confirmLabel: string;
|
||||
tone?: "danger" | "warning";
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
busy,
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
tone = "danger",
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const ConfirmIcon = tone === "warning" ? AlertTriangle : Trash2;
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} labelledBy="confirm-title">
|
||||
<motion.div
|
||||
className="glass-panel w-full max-w-md p-5"
|
||||
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
transition={MODAL_TRANSITION}
|
||||
>
|
||||
<h2 id="confirm-title" className="text-2xl font-semibold tracking-tight text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-3 text-slate-400">{body}</p>
|
||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button className="secondary-button justify-center" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className={`${tone === "warning" ? "warning-button" : "danger-button"} justify-center`}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <ConfirmIcon size={17} aria-hidden="true" />}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -15,10 +15,10 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
|
||||
<section className="limits-card glass-panel p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="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 className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Daily limits</p>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-400">
|
||||
Set how many cans you want per day, when to stop, and a spend cap. Limits are optional and stored on your
|
||||
account.
|
||||
</p>
|
||||
</div>
|
||||
<button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}>
|
||||
@@ -37,7 +37,7 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
|
||||
return (
|
||||
<section className="limits-card glass-panel p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="section-kicker">Daily limits</p>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">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
|
||||
@@ -96,14 +96,7 @@ function LimitRow({
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
<div
|
||||
className="limit-progress"
|
||||
role="progressbar"
|
||||
aria-label={`${label}: ${value}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress}
|
||||
>
|
||||
<div className="limit-progress" aria-hidden="true">
|
||||
<div className="limit-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Plus, Zap } from "lucide-react";
|
||||
|
||||
type EmptyStateProps = {
|
||||
title: string;
|
||||
copy: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
};
|
||||
|
||||
export function EmptyState({ title, copy, actionLabel, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">
|
||||
<Zap size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<h3 className="empty-state-title">{title}</h3>
|
||||
<p className="empty-state-copy mt-2 max-w-sm">{copy}</p>
|
||||
{actionLabel && onAction && (
|
||||
<button className="primary-button mt-4" type="button" onClick={onAction}>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { Edit3, Loader2, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import {
|
||||
caffeineFor,
|
||||
currency,
|
||||
formatDateKey,
|
||||
oneDecimal,
|
||||
spendFor,
|
||||
sugarFor,
|
||||
wholeNumber,
|
||||
} from "../lib/metrics";
|
||||
import { AppCard } from "./AppCard";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
import type { EntryDerived, Filters, RedBullEntry } from "../types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const SKELETON_ROWS = 6;
|
||||
|
||||
type EntryLedgerProps = {
|
||||
entries: RedBullEntry[];
|
||||
totalEntries: number;
|
||||
entryDerived: Map<string, EntryDerived>;
|
||||
filters: Filters;
|
||||
loading: boolean;
|
||||
deletingId: string | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (entry: RedBullEntry) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onLogAgain?: (entry: RedBullEntry) => void;
|
||||
};
|
||||
|
||||
type DayGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
cans: number;
|
||||
caffeine: number;
|
||||
spend: number;
|
||||
entries: RedBullEntry[];
|
||||
};
|
||||
|
||||
export function EntryLedger({
|
||||
entries,
|
||||
totalEntries,
|
||||
entryDerived,
|
||||
filters,
|
||||
loading,
|
||||
deletingId,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onLogAgain,
|
||||
}: EntryLedgerProps) {
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, [filters]);
|
||||
|
||||
const visibleEntries = useMemo(() => entries.slice(0, visibleCount), [entries, visibleCount]);
|
||||
const dayGroups = useMemo(() => groupEntriesByDay(visibleEntries, entryDerived), [visibleEntries, entryDerived]);
|
||||
const showSkeleton = loading && entries.length === 0;
|
||||
const remaining = entries.length - visibleEntries.length;
|
||||
|
||||
return (
|
||||
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
|
||||
{showSkeleton ? (
|
||||
<div className="grid gap-2">
|
||||
{Array.from({ length: SKELETON_ROWS }, (_, index) => (
|
||||
<Skeleton key={index} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
) : entries.length ? (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
{dayGroups.map((group) => (
|
||||
<section className="day-group" key={group.key} aria-label={group.label}>
|
||||
<div className="day-group-header">
|
||||
<h3 className="day-group-title">{group.label}</h3>
|
||||
<p className="day-group-subtotals">
|
||||
{oneDecimal.format(group.cans)} can{group.cans === 1 ? "" : "s"} · {wholeNumber.format(group.caffeine)}mg · {currency.format(group.spend)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map((entry) => (
|
||||
<EntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
derived={derivedFor(entryDerived, entry)}
|
||||
deleting={deletingId === entry.id}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onLogAgain={onLogAgain}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{remaining > 0 ? (
|
||||
<button
|
||||
className="list-button mt-4 w-full"
|
||||
type="button"
|
||||
onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}
|
||||
>
|
||||
Load {Math.min(PAGE_SIZE, remaining)} more — {remaining} remaining
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title="No entries found" copy="Add your first intake or clear the current filters." actionLabel="Add intake" onAction={onAdd} />
|
||||
)}
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryRow({
|
||||
entry,
|
||||
derived,
|
||||
deleting,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onLogAgain,
|
||||
}: {
|
||||
entry: RedBullEntry;
|
||||
derived: EntryDerived;
|
||||
deleting: boolean;
|
||||
onEdit: (entry: RedBullEntry) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onLogAgain?: (entry: RedBullEntry) => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="entry-row" style={{ "--accent": entry.flavourAccent } as CSSProperties}>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full bg-[var(--accent)]" aria-hidden="true" />
|
||||
<h3 className="entry-title">{entry.flavour}</h3>
|
||||
<span className="entry-chip">
|
||||
{entry.cans} can{entry.cans === 1 ? "" : "s"} · {entry.sizeMl}ml
|
||||
</span>
|
||||
<span className="source-badge">{sourceLabel(entry.source)}</span>
|
||||
</div>
|
||||
<p className="entry-meta">
|
||||
{timeLabel(entry.dateTime)}
|
||||
{entry.store ? ` · ${entry.store}` : ""}
|
||||
</p>
|
||||
<p className="entry-summary mt-2">
|
||||
{currency.format(derived.spend)} · {wholeNumber.format(derived.caffeine)}mg caffeine · {oneDecimal.format(derived.sugar)}g sugar
|
||||
</p>
|
||||
{entry.notes ? <p className="entry-meta mt-2 leading-6">{entry.notes}</p> : null}
|
||||
</div>
|
||||
<div className="entry-row-actions">
|
||||
{onLogAgain ? (
|
||||
<button className="list-button entry-log-again" type="button" onClick={() => onLogAgain(entry)}>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Log again
|
||||
</button>
|
||||
) : null}
|
||||
<button className="icon-button" type="button" onClick={() => onEdit(entry)} aria-label={`Edit ${entry.flavour} entry`}>
|
||||
<Edit3 size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
style={{ color: "var(--error)" }}
|
||||
disabled={deleting}
|
||||
onClick={() => onDelete(entry.id)}
|
||||
aria-label={`Delete ${entry.flavour} entry`}
|
||||
>
|
||||
{deleting ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Trash2 size={17} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function groupEntriesByDay(entries: RedBullEntry[], entryDerived: Map<string, EntryDerived>): DayGroup[] {
|
||||
const groups: DayGroup[] = [];
|
||||
const byKey = new Map<string, DayGroup>();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
// Local calendar-day boundaries, matching startOfDay/formatDateKey in metrics.ts.
|
||||
const key = formatDateKey(new Date(entry.dateTime));
|
||||
let group = byKey.get(key);
|
||||
if (!group) {
|
||||
group = { key, label: dayLabel(key), cans: 0, caffeine: 0, spend: 0, entries: [] };
|
||||
byKey.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
const derived = derivedFor(entryDerived, entry);
|
||||
group.entries.push(entry);
|
||||
group.cans += entry.cans;
|
||||
group.caffeine += derived.caffeine;
|
||||
group.spend += derived.spend;
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function derivedFor(entryDerived: Map<string, EntryDerived>, entry: RedBullEntry): EntryDerived {
|
||||
return entryDerived.get(entry.id) ?? { spend: spendFor(entry), caffeine: caffeineFor(entry), sugar: sugarFor(entry) };
|
||||
}
|
||||
|
||||
function dayLabel(key: string) {
|
||||
const todayKey = formatDateKey(new Date());
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (key === todayKey) return "Today";
|
||||
if (key === formatDateKey(yesterday)) return "Yesterday";
|
||||
// Noon anchor keeps the parsed date on the intended local day.
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(`${key}T12:00:00`));
|
||||
}
|
||||
|
||||
function timeLabel(dateTime: string) {
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(dateTime));
|
||||
}
|
||||
|
||||
function sourceLabel(source: RedBullEntry["source"]) {
|
||||
switch (source) {
|
||||
case "quick-add":
|
||||
return "Quick add";
|
||||
case "excel":
|
||||
case "json":
|
||||
return "Imported";
|
||||
default:
|
||||
return "Manual";
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Loader2, Plus, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent } from "react";
|
||||
import { MATERIAL_ACCENTS } from "../data/accents";
|
||||
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours";
|
||||
import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics";
|
||||
import { MODAL_TRANSITION } from "../lib/motion";
|
||||
import { Modal } from "./Modal";
|
||||
import { Field } from "./ui";
|
||||
import type { EntryDraft, Flavour, RedBullEntry } from "../types";
|
||||
|
||||
type EntryModalProps = {
|
||||
open: boolean;
|
||||
entry: RedBullEntry | null;
|
||||
initialDraft: EntryDraft | null;
|
||||
flavours: Flavour[];
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (draft: EntryDraft) => void;
|
||||
};
|
||||
|
||||
export function EntryModal({
|
||||
open,
|
||||
entry,
|
||||
initialDraft,
|
||||
flavours,
|
||||
saving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: EntryModalProps) {
|
||||
const firstFieldRef = useRef<HTMLInputElement>(null);
|
||||
const activeDraft = entry ?? initialDraft;
|
||||
const initialFlavour = activeDraft?.flavour ?? DEFAULT_FLAVOUR.name;
|
||||
const [selectedFlavour, setSelectedFlavour] = useState(initialFlavour);
|
||||
const [customFlavour, setCustomFlavour] = useState("");
|
||||
const [customAccent, setCustomAccent] = useState(MATERIAL_ACCENTS.custom);
|
||||
const [cans, setCans] = useState(activeDraft?.cans.toString() ?? "1");
|
||||
const [sizePreset, setSizePreset] = useState(sizeToPreset(activeDraft?.sizeMl ?? 250));
|
||||
const [customSize, setCustomSize] = useState(activeDraft?.sizeMl.toString() ?? "250");
|
||||
const [pricePerCan, setPricePerCan] = useState(activeDraft?.pricePerCan.toString() ?? "1.75");
|
||||
const [dateTime, setDateTime] = useState(formatLocalInput(activeDraft ? new Date(activeDraft.dateTime) : new Date()));
|
||||
const [store, setStore] = useState(activeDraft?.store ?? "");
|
||||
const [notes, setNotes] = useState(activeDraft?.notes ?? "");
|
||||
const [sugarFree, setSugarFree] = useState(activeDraft?.sugarFree ?? false);
|
||||
const [caffeineOverride, setCaffeineOverride] = useState(activeDraft?.caffeineMgPerCan?.toString() ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const draft = entry ?? initialDraft;
|
||||
const editingCustom = draft && !BUILT_IN_FLAVOURS.some((flavour) => flavour.name === draft.flavour);
|
||||
setSelectedFlavour(editingCustom ? draft.flavour : draft?.flavour ?? DEFAULT_FLAVOUR.name);
|
||||
setCustomFlavour(editingCustom ? draft.flavour : "");
|
||||
setCustomAccent(draft?.flavourAccent ?? MATERIAL_ACCENTS.custom);
|
||||
setCans(draft?.cans.toString() ?? "1");
|
||||
setSizePreset(sizeToPreset(draft?.sizeMl ?? 250));
|
||||
setCustomSize(draft?.sizeMl.toString() ?? "250");
|
||||
setPricePerCan(draft?.pricePerCan.toString() ?? defaultPriceForSize(250).toString());
|
||||
setDateTime(formatLocalInput(draft ? new Date(draft.dateTime) : new Date()));
|
||||
setStore(draft?.store ?? "");
|
||||
setNotes(draft?.notes ?? "");
|
||||
setSugarFree(draft?.sugarFree ?? false);
|
||||
setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? "");
|
||||
}, [entry, initialDraft, open]);
|
||||
|
||||
const selectedMeta = flavourMeta(selectedFlavour);
|
||||
const isOther = selectedFlavour === "Other";
|
||||
const numericSize = Math.max(1, sizePreset === "custom" ? Number(customSize) || 250 : Number(sizePreset));
|
||||
const finalAccent = isOther ? customAccent : selectedMeta.accent;
|
||||
const caffeinePreview = caffeinePerCan(
|
||||
numericSize,
|
||||
sizePreset === "custom" && caffeineOverride.trim() ? Number(caffeineOverride) : undefined,
|
||||
);
|
||||
const parsedDate = new Date(dateTime);
|
||||
const dateValid = dateTime.trim() !== "" && !Number.isNaN(parsedDate.getTime());
|
||||
const dateError = dateTime.trim() !== "" && !dateValid ? "Enter a valid date and time" : undefined;
|
||||
|
||||
const draftPreview = useMemo(() => {
|
||||
const numericCans = Math.max(0.25, Number(cans) || 1);
|
||||
const numericPrice = Math.max(0, Number(pricePerCan) || 0);
|
||||
const finalFlavour = isOther ? customFlavour.trim() || "Other" : selectedFlavour;
|
||||
const meta = flavourMeta(finalFlavour);
|
||||
const override =
|
||||
sizePreset === "custom" && caffeineOverride.trim()
|
||||
? Math.max(0, Number(caffeineOverride) || 0)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
cans: numericCans,
|
||||
flavour: finalFlavour,
|
||||
flavourAccent: isOther ? customAccent || accentForCustomFlavour(finalFlavour) : meta.accent,
|
||||
sizeMl: numericSize,
|
||||
pricePerCan: numericPrice,
|
||||
dateTime: dateValid ? new Date(dateTime).toISOString() : "",
|
||||
notes: notes.trim(),
|
||||
store: store.trim(),
|
||||
sugarFree: sugarFree || Boolean(meta.sugarFree),
|
||||
caffeineMgPerCan: override,
|
||||
source: entry?.source ?? initialDraft?.source ?? "manual",
|
||||
};
|
||||
}, [
|
||||
cans,
|
||||
pricePerCan,
|
||||
isOther,
|
||||
customFlavour,
|
||||
selectedFlavour,
|
||||
customAccent,
|
||||
numericSize,
|
||||
dateTime,
|
||||
notes,
|
||||
store,
|
||||
sugarFree,
|
||||
sizePreset,
|
||||
caffeineOverride,
|
||||
dateValid,
|
||||
entry?.source,
|
||||
initialDraft?.source,
|
||||
]);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!draftPreview || !dateValid) return;
|
||||
onSave(draftPreview);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} labelledBy="entry-modal-title" initialFocusRef={firstFieldRef}>
|
||||
<motion.form
|
||||
className="modal-panel"
|
||||
onSubmit={submit}
|
||||
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||
transition={MODAL_TRANSITION}
|
||||
>
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="section-kicker">Intake details</p>
|
||||
<h2 id="entry-modal-title" className="app-card-title mt-1 text-3xl">
|
||||
{entry ? "Edit entry" : "Add intake"}
|
||||
</h2>
|
||||
</div>
|
||||
<button className="icon-button" type="button" onClick={onClose} aria-label="Close entry modal">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5">
|
||||
<fieldset className="entry-fieldset">
|
||||
<legend className="entry-fieldset-legend">What</legend>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field id="entry-cans" label="Number of cans">
|
||||
{(controlProps) => (
|
||||
<input ref={firstFieldRef} {...controlProps} className="field-control" min="0.25" step="0.25" type="number" value={cans} onChange={(event) => setCans(event.target.value)} required />
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field id="entry-flavour" label="Flavour">
|
||||
{(controlProps) => (
|
||||
<select
|
||||
{...controlProps}
|
||||
className="field-control"
|
||||
value={selectedFlavour}
|
||||
onChange={(event) => {
|
||||
const flavour = event.target.value;
|
||||
setSelectedFlavour(flavour);
|
||||
const meta = flavourMeta(flavour);
|
||||
setSugarFree(Boolean(meta.sugarFree));
|
||||
if (flavour !== "Other") setCustomAccent(meta.accent);
|
||||
}}
|
||||
>
|
||||
{flavours.map((flavour) => (
|
||||
<option key={flavour.name} value={flavour.name}>
|
||||
{flavour.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{isOther && (
|
||||
<>
|
||||
<Field id="entry-custom-flavour" label="Custom flavour">
|
||||
{(controlProps) => (
|
||||
<input {...controlProps} className="field-control" type="text" value={customFlavour} onChange={(event) => setCustomFlavour(event.target.value)} placeholder="Fig Apple, Sea Blue..." />
|
||||
)}
|
||||
</Field>
|
||||
<Field id="entry-custom-accent" label="Accent colour">
|
||||
{(controlProps) => (
|
||||
<input {...controlProps} className="field-control h-12 p-1" type="color" value={customAccent} onChange={(event) => setCustomAccent(event.target.value)} aria-label="Custom flavour accent colour" />
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field id="entry-size" label="Can size">
|
||||
{(controlProps) => (
|
||||
<select
|
||||
{...controlProps}
|
||||
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>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{sizePreset === "custom" && (
|
||||
<>
|
||||
<Field id="entry-custom-size" label="Custom size in ml">
|
||||
{(controlProps) => (
|
||||
<input {...controlProps} className="field-control" min="1" step="1" type="number" value={customSize} onChange={(event) => setCustomSize(event.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<Field id="entry-caffeine-override" label="Caffeine override mg/can">
|
||||
{(controlProps) => (
|
||||
<input {...controlProps} className="field-control" min="0" step="1" type="number" value={caffeineOverride} onChange={(event) => setCaffeineOverride(event.target.value)} placeholder={wholeNumber.format(caffeinePerCan(numericSize))} />
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg px-3 py-3 text-sm sm:col-span-2" style={{ border: "1px solid var(--outline-variant)", background: "var(--surface-container-low)", color: "var(--muted)" }}>
|
||||
Estimated caffeine per can: {wholeNumber.format(caffeinePreview)}mg
|
||||
</div>
|
||||
|
||||
<label className="field-label flex-row items-center gap-3 rounded-lg border px-3 py-3 sm:col-span-2" style={{ borderColor: "var(--outline-variant)", background: "var(--surface-container-lowest)" }}>
|
||||
<input className="h-4 w-4 accent-cyan-300" type="checkbox" checked={sugarFree} onChange={(event) => setSugarFree(event.target.checked)} />
|
||||
Count this entry as sugar-free / zero sugar
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="entry-fieldset">
|
||||
<legend className="entry-fieldset-legend">When</legend>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field id="entry-datetime" label="Date and time" error={dateError}>
|
||||
{(controlProps) => (
|
||||
<input {...controlProps} className="field-control" type="datetime-local" value={dateTime} onChange={(event) => setDateTime(event.target.value)} required />
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="entry-fieldset">
|
||||
<legend className="entry-fieldset-legend">Where</legend>
|
||||
<div className="grid gap-4">
|
||||
<Field id="entry-store" label="Location or store">
|
||||
{(controlProps) => (
|
||||
<input {...controlProps} className="field-control" type="text" value={store} onChange={(event) => setStore(event.target.value)} placeholder="BP, Tesco, airport..." />
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field id="entry-notes" label="Notes">
|
||||
{(controlProps) => (
|
||||
<textarea {...controlProps} className="field-control min-h-24 resize-y" value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Late drive, gym stop, exam fuel..." />
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="entry-fieldset">
|
||||
<legend className="entry-fieldset-legend">Price</legend>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field id="entry-price" label="Price per can">
|
||||
{(controlProps) => (
|
||||
<input {...controlProps} className="field-control" min="0" step="0.01" type="number" value={pricePerCan} onChange={(event) => setPricePerCan(event.target.value)} required />
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 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={saving} style={{ "--accent": finalAccent } as CSSProperties}>
|
||||
{saving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Plus size={17} aria-hidden="true" />}
|
||||
{entry ? "Save changes" : "Log intake"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function sizeToPreset(size: number) {
|
||||
if (size === 250 || size === 355 || size === 473) return size.toString();
|
||||
return "custom";
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Search, SlidersHorizontal, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { DEFAULT_FILTERS } from "../lib/filters";
|
||||
import type { DateFilter, Filters, Flavour } from "../types";
|
||||
|
||||
type FiltersPanelProps = {
|
||||
filters: Filters;
|
||||
flavours: Flavour[];
|
||||
compact?: boolean;
|
||||
onChange: (filters: Filters) => void;
|
||||
};
|
||||
|
||||
export function FiltersPanel({ filters, flavours, compact = false, onChange }: FiltersPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const title = compact ? "Chart filters" : "Filters";
|
||||
|
||||
const set = <Key extends keyof Filters>(key: Key, value: Filters[Key]) => {
|
||||
onChange({ ...filters, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="filter-bar" aria-label={title}>
|
||||
<button
|
||||
className="filter-bar-toggle"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||
{title}
|
||||
</button>
|
||||
|
||||
<div className="filter-bar-controls" data-open={open}>
|
||||
<label className="field-label filter-bar-field filter-bar-field--search">
|
||||
Store or location
|
||||
<span className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} aria-hidden="true" />
|
||||
<input
|
||||
className="field-control pl-9"
|
||||
type="search"
|
||||
placeholder="Tesco, Shell, corner shop..."
|
||||
value={filters.store}
|
||||
onChange={(event) => set("store", event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="field-label filter-bar-field">
|
||||
Flavour
|
||||
<select className="field-control" value={filters.flavour} onChange={(event) => set("flavour", event.target.value)}>
|
||||
<option value="all">All flavours</option>
|
||||
{flavours.map((flavour) => (
|
||||
<option key={flavour.name} value={flavour.name}>
|
||||
{flavour.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="field-label filter-bar-field">
|
||||
Date range
|
||||
<select className="field-control" value={filters.dateRange} onChange={(event) => set("dateRange", event.target.value as DateFilter)}>
|
||||
<option value="all">All time</option>
|
||||
<option value="today">Today</option>
|
||||
<option value="week">This week</option>
|
||||
<option value="month">This month</option>
|
||||
<option value="custom">Custom range</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{filters.dateRange === "custom" && (
|
||||
<>
|
||||
<label className="field-label filter-bar-field">
|
||||
From
|
||||
<input className="field-control" type="date" value={filters.from} onChange={(event) => set("from", event.target.value)} />
|
||||
</label>
|
||||
<label className="field-label filter-bar-field">
|
||||
To
|
||||
<input className="field-control" type="date" value={filters.to} onChange={(event) => set("to", event.target.value)} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className="secondary-button filter-bar-clear" type="button" onClick={() => onChange(DEFAULT_FILTERS)}>
|
||||
<X size={17} aria-hidden="true" />
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Activity, CalendarDays, Camera, Plus, PoundSterling, Zap } from "lucide-react";
|
||||
import { buildDynamicGreeting } from "../lib/greeting";
|
||||
import { currency } from "../lib/metrics";
|
||||
import { userInitial } from "../lib/userDisplay";
|
||||
import { WellnessPill } from "./MetricTiles";
|
||||
import type { Dashboard } from "../lib/dashboard";
|
||||
import type { AuthUser, LimitCheckResult, UserLimits } from "../types";
|
||||
|
||||
type GreetingPanelProps = {
|
||||
summary: Dashboard;
|
||||
user: AuthUser;
|
||||
userLimits: UserLimits;
|
||||
limitCheck: LimitCheckResult;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
};
|
||||
|
||||
export function GreetingPanel({ summary, user, userLimits, limitCheck, onAdd, onScan }: GreetingPanelProps) {
|
||||
const todayNumber = Number.parseFloat(summary.todayCans) || 0;
|
||||
const canLimit = userLimits.dailyCanLimit;
|
||||
const name = firstName(user);
|
||||
const greeting = buildDynamicGreeting({
|
||||
name,
|
||||
todayCans: todayNumber,
|
||||
favouriteFlavour: summary.favouriteFlavour,
|
||||
currentStreak: Number.parseInt(summary.currentStreak, 10) || 0,
|
||||
todayCaffeineMg: Number.parseFloat(summary.todayCaffeine.replace(/[^\d.]/g, "")) || 0,
|
||||
allTimeCans: Number.parseFloat(summary.allTimeCans) || 0,
|
||||
dailyCanLimit: canLimit,
|
||||
limitCheck,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="home-hero">
|
||||
<div className="hero-icon-row" aria-hidden="true">
|
||||
<span><Zap size={22} /></span>
|
||||
<span><PoundSterling size={22} /></span>
|
||||
<span><CalendarDays size={22} /></span>
|
||||
<span><Activity size={22} /></span>
|
||||
</div>
|
||||
|
||||
<div className="hero-avatar">{userInitial(user)}</div>
|
||||
<p className="hero-kicker">{greeting.badge}</p>
|
||||
<h2 className="hero-name">{name}</h2>
|
||||
<p className="hero-copy">{greeting.subline}</p>
|
||||
|
||||
<div className="hero-action-row">
|
||||
<button className="hero-search-button" type="button" onClick={onAdd}>
|
||||
<Plus size={22} aria-hidden="true" />
|
||||
Add intake
|
||||
</button>
|
||||
<button className="hero-scan-button secondary-button" type="button" onClick={onScan}>
|
||||
<Camera size={22} aria-hidden="true" />
|
||||
Scan barcode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hero-stat-row">
|
||||
<WellnessPill label="Today" value={`${summary.todayCans} cans`} />
|
||||
<WellnessPill label="Caffeine" value={summary.todayCaffeine} />
|
||||
<WellnessPill label="Sugar" value={summary.todaySugar} />
|
||||
<WellnessPill label="Spend" value={currency.format(limitCheck.todaySpend)} />
|
||||
<WellnessPill label="Streak" value={`${summary.currentStreak} days`} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function firstName(user: AuthUser) {
|
||||
const fallback = user.email?.split("@")[0] ?? "there";
|
||||
const value = (user.name || fallback).trim();
|
||||
return value.split(/\s+/)[0] || "there";
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { FileSpreadsheet, Loader2, X } from "lucide-react";
|
||||
import { humanDateTime } from "../lib/metrics";
|
||||
import { MODAL_TRANSITION } from "../lib/motion";
|
||||
import { MiniMetric } from "./MetricTiles";
|
||||
import { Modal } from "./Modal";
|
||||
import type { ImportPreview, ImportPreviewRow, ImportProgress } from "../types";
|
||||
|
||||
type ImportPreviewModalProps = {
|
||||
busy: boolean;
|
||||
preview: ImportPreview | null;
|
||||
importProgress: ImportProgress | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function ImportPreviewModal({
|
||||
busy,
|
||||
preview,
|
||||
importProgress,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ImportPreviewModalProps) {
|
||||
const validRows = preview?.rows.filter((row) => row.entry && !row.errors.length && !row.duplicate) ?? [];
|
||||
const invalidRows = preview?.rows.filter((row) => row.errors.length) ?? [];
|
||||
const duplicateRows = preview?.rows.filter((row) => row.duplicate) ?? [];
|
||||
|
||||
return (
|
||||
<Modal open={Boolean(preview)} onClose={onClose} labelledBy="import-preview-title">
|
||||
{preview && (
|
||||
<motion.div
|
||||
className="modal-panel max-w-5xl"
|
||||
initial={{ opacity: 0, y: 18, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||
transition={MODAL_TRANSITION}
|
||||
>
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-pink-100">
|
||||
{preview.fileName.toLowerCase().endsWith(".json") ? "JSON import" : "Excel import"}
|
||||
</p>
|
||||
<h2 id="import-preview-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
|
||||
Preview rows
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">{preview.fileName}</p>
|
||||
</div>
|
||||
<button className="icon-button" type="button" onClick={onClose} aria-label="Close import preview">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-3">
|
||||
<MiniMetric label="Ready" value={`${validRows.length}`} accent="#39d5ff" />
|
||||
<MiniMetric label="Duplicates" value={`${duplicateRows.length}`} accent="#ffd84d" />
|
||||
<MiniMetric label="Invalid" value={`${invalidRows.length}`} accent="#ff3448" />
|
||||
</div>
|
||||
|
||||
<div className="hidden max-h-[48vh] overflow-auto rounded-lg border border-white/10 sm:block">
|
||||
<table className="w-full min-w-[760px] border-collapse text-left text-sm">
|
||||
<thead className="sticky top-0 bg-[#0d142c] text-xs uppercase tracking-[0.14em] text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-3">Row</th>
|
||||
<th className="px-3 py-3">Status</th>
|
||||
<th className="px-3 py-3">Date</th>
|
||||
<th className="px-3 py-3">Flavour</th>
|
||||
<th className="px-3 py-3">Size</th>
|
||||
<th className="px-3 py-3">Cans</th>
|
||||
<th className="px-3 py-3">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.rows.map((row) => (
|
||||
<tr key={row.rowNumber} className="border-t border-white/10">
|
||||
<td className="px-3 py-3 text-slate-400">{row.rowNumber}</td>
|
||||
<td className="px-3 py-3">
|
||||
<ImportStatusBadge invalid={row.errors.length > 0} duplicate={row.duplicate} />
|
||||
</td>
|
||||
<td className="px-3 py-3 text-slate-300">{row.entry ? humanDateTime(row.entry.dateTime) : "-"}</td>
|
||||
<td className="px-3 py-3 text-white">{row.entry?.flavour ?? "-"}</td>
|
||||
<td className="px-3 py-3 text-slate-300">{row.entry ? `${row.entry.sizeMl}ml` : "-"}</td>
|
||||
<td className="px-3 py-3 text-slate-300">{row.entry?.cans ?? "-"}</td>
|
||||
<td className="px-3 py-3 text-slate-400">{rowMessage(row)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid max-h-[48vh] gap-2 overflow-auto sm:hidden">
|
||||
{preview.rows.map((row) => (
|
||||
<article key={row.rowNumber} className="rounded-lg border border-white/10 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-slate-400">Row {row.rowNumber}</span>
|
||||
<ImportStatusBadge invalid={row.errors.length > 0} duplicate={row.duplicate} />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-white">{row.entry?.flavour ?? "No entry parsed"}</p>
|
||||
{row.entry ? (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{humanDateTime(row.entry.dateTime)} · {row.entry.sizeMl}ml · {row.entry.cans} can{row.entry.cans === 1 ? "" : "s"}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-slate-400">{rowMessage(row)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
{busy && importProgress ? (
|
||||
<p className="text-center text-sm text-slate-400 sm:mr-auto" role="status" aria-live="polite">
|
||||
Importing {importProgress.done} of {importProgress.total}…
|
||||
</p>
|
||||
) : null}
|
||||
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="excel-button justify-center" type="button" disabled={!validRows.length || busy} onClick={onConfirm}>
|
||||
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <FileSpreadsheet size={17} aria-hidden="true" />}
|
||||
Import {validRows.length} row{validRows.length === 1 ? "" : "s"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportStatusBadge({ invalid, duplicate }: { invalid: boolean; duplicate: boolean }) {
|
||||
return (
|
||||
<span className={`rounded px-2 py-1 text-xs font-semibold ${invalid ? "bg-red-500/15 text-red-100" : duplicate ? "bg-amber-300/15 text-amber-100" : "bg-cyan-300/15 text-cyan-100"}`}>
|
||||
{invalid ? "Invalid" : duplicate ? "Duplicate" : "Ready"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function rowMessage(row: ImportPreviewRow) {
|
||||
return row.errors.join(" ") || row.duplicateReason || "Looks good.";
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Gauge } from "lucide-react";
|
||||
import type { Insight } from "../lib/dashboard";
|
||||
|
||||
export function InsightCard({ insight }: { insight: Insight }) {
|
||||
return (
|
||||
<article className="glass-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2" style={{ color: "var(--primary, #2563c7)" }}>
|
||||
<Gauge size={17} aria-hidden="true" />
|
||||
<p className="insight-card-label">{insight.label}</p>
|
||||
</div>
|
||||
<p className="insight-card-value text-lg">{insight.value}</p>
|
||||
<p className="insight-card-detail mt-2">{insight.detail}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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,13 +1,7 @@
|
||||
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";
|
||||
import type { LimitCheckResult, UserLimits } from "../types";
|
||||
import { currency } from "../lib/metrics";
|
||||
|
||||
type LimitsSettingsFormProps = {
|
||||
limits: UserLimits;
|
||||
@@ -17,57 +11,15 @@ type LimitsSettingsFormProps = {
|
||||
};
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}, [limits.dailyCanLimit, limits.dailySpendLimit, limits.stopTime]);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -76,11 +28,13 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
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 (parsed > 0) next.dailyCanLimit = parsed;
|
||||
}
|
||||
|
||||
const spendTrim = spendInput.trim();
|
||||
if (spendTrim) {
|
||||
const parsed = Math.max(0, Number(spendTrim) || 0);
|
||||
next.dailySpendLimit = parsed;
|
||||
}
|
||||
|
||||
if (stopInput.trim()) {
|
||||
@@ -97,42 +51,12 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
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)" : "var(--outline-variant)",
|
||||
background: isActive ? "var(--primary-container)" : "var(--surface-container-lowest)",
|
||||
color: isActive ? "var(--on-primary-container)" : "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<span className="font-medium text-slate-300">Cans per day</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="number"
|
||||
@@ -140,13 +64,13 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
step={0.25}
|
||||
placeholder="e.g. 3"
|
||||
value={canInput}
|
||||
onChange={(event) => handleCanInputChange(event.target.value)}
|
||||
onChange={(event) => setCanInput(event.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-slate-500">Leave empty to remove. Counts use device-local calendar days.</span>
|
||||
<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>
|
||||
<span className="font-medium text-slate-300">Spend per day (£)</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="number"
|
||||
@@ -154,34 +78,25 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
|
||||
step={0.01}
|
||||
placeholder="e.g. 5.00"
|
||||
value={spendInput}
|
||||
onChange={(event) => handleSpendInputChange(event.target.value)}
|
||||
onChange={(event) => setSpendInput(event.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-slate-500">
|
||||
Linked to {canSizeMl}ml at {currency.format(priceForLimitSize(canSizeMl))}/can.
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">Based on price per can in your log.</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>
|
||||
<span className="font-medium text-slate-300">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">Your device's local time. Leave empty to remove.</span>
|
||||
<span className="text-xs text-slate-500">Europe/London (BST/GMT). Leave empty to remove.</span>
|
||||
</label>
|
||||
|
||||
{previewParts.length ? (
|
||||
<p
|
||||
className="rounded-lg border px-3 py-2 text-sm"
|
||||
style={{
|
||||
borderColor: "var(--outline-variant)",
|
||||
background: "var(--surface-container-low)",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
<p className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-300">
|
||||
Today so far: {previewParts.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { ShellBackdrop } from "./ShellBackdrop";
|
||||
import type { SetupStatus } from "../types";
|
||||
|
||||
type LoadingScreenProps = {
|
||||
setupStatus: SetupStatus;
|
||||
shellStyle: CSSProperties;
|
||||
themeId: string;
|
||||
resolvedMode: "light" | "dark";
|
||||
};
|
||||
|
||||
export function LoadingScreen({ setupStatus, shellStyle, themeId, resolvedMode }: LoadingScreenProps) {
|
||||
return (
|
||||
<div className="app-shell min-h-screen" data-theme={themeId} data-mode={resolvedMode} style={shellStyle}>
|
||||
<ShellBackdrop />
|
||||
<div className="flex min-h-screen items-center justify-center p-6">
|
||||
<div className="glass-panel w-full max-w-md p-6 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-lg metric-tile-icon">
|
||||
<Loader2 className="animate-spin" size={24} aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="app-card-title mt-5 text-2xl">Red Bull tracker</h1>
|
||||
<p className="app-card-subtitle mt-3 leading-6">{setupStatus.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { TILE_TRANSITION } from "../lib/motion";
|
||||
import { StatHint } from "./StatHint";
|
||||
import { Card } from "./ui";
|
||||
|
||||
type MetricTileProps = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
accent: string;
|
||||
};
|
||||
|
||||
export function MetricTile({ icon: Icon, label, value, detail, accent }: MetricTileProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={TILE_TRANSITION}
|
||||
>
|
||||
<Card className="metric-tile">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="metric-tile-label">{label}</p>
|
||||
<p className="metric-tile-value break-words">{value}</p>
|
||||
</div>
|
||||
<div className="metric-tile-icon" style={{ color: accent }}>
|
||||
<Icon size={20} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="metric-tile-detail mt-4">{detail}</p>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MiniMetric({ label, value, accent }: { label: string; value: string; accent: string }) {
|
||||
return (
|
||||
<div className="mini-metric-card metric-soft">
|
||||
<p className="mini-metric-label">
|
||||
{label}
|
||||
<StatHint label={label} hint={statHint(label)} />
|
||||
</p>
|
||||
<p className="mini-metric-value truncate" style={{ color: accent }}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WellnessPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="wellness-pill">
|
||||
<span>
|
||||
{label}
|
||||
<StatHint label={label} hint={statHint(label)} />
|
||||
</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statHint(label: string) {
|
||||
return label === "Caffeine" || label === "Sugar"
|
||||
? "estimated from the logged can. check the label if it matters."
|
||||
: undefined;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NAV_ITEMS } from "./navItems";
|
||||
import type { AppView } from "../types";
|
||||
|
||||
export function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (view: AppView) => void }) {
|
||||
return (
|
||||
<nav className="mobile-nav-bar" aria-label="Main navigation">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`mobile-nav-item ${activeView === item.id ? "mobile-nav-item-active" : ""}`}
|
||||
onClick={() => onChange(item.id)}
|
||||
aria-current={activeView === item.id ? "page" : undefined}
|
||||
>
|
||||
<item.icon size={20} aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||
import { MODAL_TRANSITION } from "../lib/motion";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const DEFAULT_BACKDROP_CLASS =
|
||||
"modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl";
|
||||
|
||||
// Tracks open modals in stacking order so only the topmost one answers
|
||||
// keyboard events when dialogs are layered (e.g. limit confirm over EntryModal).
|
||||
const openModalIds: number[] = [];
|
||||
let modalIdCounter = 0;
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
labelledBy?: string;
|
||||
label?: string;
|
||||
closeOnEscape?: boolean;
|
||||
closeOnBackdrop?: boolean;
|
||||
initialFocusRef?: RefObject<HTMLElement>;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
labelledBy,
|
||||
label,
|
||||
closeOnEscape = true,
|
||||
closeOnBackdrop = false,
|
||||
initialFocusRef,
|
||||
className = DEFAULT_BACKDROP_CLASS,
|
||||
children,
|
||||
}: ModalProps) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const modalIdRef = useRef<number | null>(null);
|
||||
if (modalIdRef.current === null) modalIdRef.current = ++modalIdCounter;
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
const modalId = modalIdRef.current as number;
|
||||
openModalIds.push(modalId);
|
||||
const isTopmost = () => openModalIds[openModalIds.length - 1] === modalId;
|
||||
const backdrop = backdropRef.current;
|
||||
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
if (!isTopmost()) return;
|
||||
if (!backdrop) return;
|
||||
const target = initialFocusRef?.current ?? backdrop.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
if (target) target.focus();
|
||||
else backdrop.focus();
|
||||
}, 80);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isTopmost()) return;
|
||||
if (event.key === "Escape") {
|
||||
if (closeOnEscape) onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
if (!backdrop) return;
|
||||
const focusable = Array.from(backdrop.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey) {
|
||||
if (active === first || !backdrop.contains(active)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (active === last || !backdrop.contains(active)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
const stackIndex = openModalIds.indexOf(modalId);
|
||||
if (stackIndex !== -1) openModalIds.splice(stackIndex, 1);
|
||||
window.clearTimeout(focusTimer);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
const active = document.activeElement;
|
||||
if (
|
||||
previouslyFocused &&
|
||||
(!active || active === document.body || (backdrop?.contains(active) ?? false))
|
||||
) {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
};
|
||||
}, [open, closeOnEscape, initialFocusRef]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={backdropRef}
|
||||
className={className}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={MODAL_TRANSITION}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelledBy}
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => {
|
||||
if (closeOnBackdrop && event.target === event.currentTarget) onCloseRef.current();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
+188
-156
@@ -1,24 +1,18 @@
|
||||
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";
|
||||
import { APP_THEMES, THEME_CATEGORIES, type ThemeCategory } from "../data/themes";
|
||||
import { currency } from "../lib/metrics";
|
||||
import type { UserLimits } from "../types";
|
||||
|
||||
type OnboardingScreenProps = {
|
||||
onSave: (limits: UserLimits, themeId: string) => Promise<boolean>;
|
||||
onSave: (limits: UserLimits, themeId: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
activeThemeId: string;
|
||||
onThemeChange: (themeId: string) => void;
|
||||
userName?: string;
|
||||
initialLimits?: UserLimits;
|
||||
};
|
||||
|
||||
const STEP_COUNT = 5;
|
||||
const STEP_COUNT = 6;
|
||||
|
||||
const curfewOptions: Array<{ id: string; label: string; hint: string }> = [
|
||||
{ id: "16:00", label: "4:00 PM", hint: "Early cut-off" },
|
||||
@@ -33,48 +27,36 @@ export function OnboardingScreen({
|
||||
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 [dailyCanLimit, setDailyCanLimit] = useState<number | "none">(2);
|
||||
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5);
|
||||
const [stopTime, setStopTime] = useState<string | "none">("18:00");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState<ThemeCategory>("flavour");
|
||||
|
||||
const visibleThemes = useMemo(() => {
|
||||
return APP_THEMES.filter((theme) => theme.category === activeCategory);
|
||||
}, [activeCategory]);
|
||||
|
||||
const activeTheme = useMemo(() => {
|
||||
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
|
||||
}, [activeThemeId]);
|
||||
|
||||
const derivedSpend =
|
||||
dailyCanLimit !== "none" ? spendLimitFromCans(dailyCanLimit, limitCanSizeMl) : null;
|
||||
const unitPrice = priceForLimitSize(limitCanSizeMl);
|
||||
|
||||
const progress = `${(step / STEP_COUNT) * 100}%`;
|
||||
|
||||
async function handleFinish() {
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
try {
|
||||
const limits: UserLimits = {};
|
||||
if (dailyCanLimit !== "none") {
|
||||
limits.dailyCanLimit = dailyCanLimit;
|
||||
limits.limitCanSizeMl = limitCanSizeMl;
|
||||
limits.dailySpendLimit = spendLimitFromCans(dailyCanLimit, limitCanSizeMl);
|
||||
}
|
||||
if (dailyCanLimit !== "none") limits.dailyCanLimit = dailyCanLimit;
|
||||
if (dailySpendLimit !== "none") limits.dailySpendLimit = dailySpendLimit;
|
||||
if (stopTime !== "none") limits.stopTime = stopTime;
|
||||
|
||||
const saved = await onSave(limits, activeThemeId);
|
||||
if (saved) {
|
||||
onClose();
|
||||
} else {
|
||||
setSaveError("Couldn't save your setup — check your connection and try again.");
|
||||
}
|
||||
} catch {
|
||||
setSaveError("Couldn't save your setup — check your connection and try again.");
|
||||
await onSave(limits, activeThemeId);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to save onboarding preferences", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -97,6 +79,23 @@ export function OnboardingScreen({
|
||||
setDailyCanLimit(Number((dailyCanLimit - 0.5).toFixed(1)));
|
||||
}
|
||||
|
||||
function incrementSpend() {
|
||||
if (dailySpendLimit === "none") {
|
||||
setDailySpendLimit(1);
|
||||
return;
|
||||
}
|
||||
if (dailySpendLimit < 30) setDailySpendLimit(Number((dailySpendLimit + 0.5).toFixed(2)));
|
||||
}
|
||||
|
||||
function decrementSpend() {
|
||||
if (dailySpendLimit === "none") return;
|
||||
if (dailySpendLimit <= 0.5) {
|
||||
setDailySpendLimit("none");
|
||||
return;
|
||||
}
|
||||
setDailySpendLimit(Number((dailySpendLimit - 0.5).toFixed(2)));
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
setStep((current) => Math.min(current + 1, STEP_COUNT));
|
||||
}
|
||||
@@ -118,7 +117,7 @@ export function OnboardingScreen({
|
||||
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%)",
|
||||
"radial-gradient(circle at 76% 20%, color-mix(in srgb, var(--primary-container) 62%, transparent) 0 22%, transparent 44%), radial-gradient(circle at 12% 84%, color-mix(in srgb, var(--tertiary-container) 48%, transparent) 0 18%, transparent 42%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -128,22 +127,22 @@ export function OnboardingScreen({
|
||||
<div className="h-full rounded-full bg-[var(--primary)] transition-all duration-500" style={{ width: progress }} />
|
||||
</div>
|
||||
<p className="text-xs font-normal uppercase tracking-[0.18em] text-[var(--muted)]">
|
||||
step {step} of {STEP_COUNT}
|
||||
Question {step} of {STEP_COUNT}
|
||||
</p>
|
||||
</div>
|
||||
<p className="hidden text-xs font-normal text-[var(--muted)] sm:block">Red Bull tracker</p>
|
||||
<p className="hidden text-xs font-normal text-[var(--muted)] sm:block">Red Bull Intake 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>
|
||||
<p className="text-sm font-normal text-[var(--primary)]">Energy 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.
|
||||
Six quick screens. Pick a theme, then set light guardrails for cans, spend, and late caffeine.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -161,14 +160,35 @@ export function OnboardingScreen({
|
||||
{step === 2 && (
|
||||
<section className="grid gap-8">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--primary)]">theme</p>
|
||||
<p className="text-sm font-normal text-[var(--primary)]">1. Visual style</p>
|
||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||
Choose the app color.
|
||||
Choose the mood you want to see every day.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{THEME_CATEGORIES.map((cat) => {
|
||||
const isActive = activeCategory === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className="rounded-full border px-4 py-2 text-sm font-normal transition"
|
||||
style={{
|
||||
background: isActive ? "var(--primary-container)" : "var(--surface-container-lowest)",
|
||||
borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
|
||||
color: isActive ? "var(--on-primary-container)" : "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid max-h-[48vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||
{APP_THEMES.map((theme) => {
|
||||
{visibleThemes.map((theme) => {
|
||||
const isActive = activeThemeId === theme.id;
|
||||
return (
|
||||
<button
|
||||
@@ -207,82 +227,41 @@ export function OnboardingScreen({
|
||||
{step === 3 && (
|
||||
<section className="grid gap-9">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--primary)]">daily limit</p>
|
||||
<p className="text-sm font-normal text-[var(--primary)]">2. Daily cans</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?
|
||||
What is your daily can ceiling?
|
||||
</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.
|
||||
App warns before logging past this number. You can change it later.
|
||||
</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)",
|
||||
}}
|
||||
<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)" }}
|
||||
>
|
||||
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>
|
||||
)}
|
||||
-
|
||||
</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 className="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -322,14 +301,90 @@ export function OnboardingScreen({
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<section className="grid gap-8">
|
||||
<section className="grid gap-9">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--primary)]">time limit</p>
|
||||
<p className="text-sm font-normal text-[var(--primary)]">3. Daily spend</p>
|
||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||
When should the app warn you?
|
||||
Set a daily spend line.
|
||||
</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.
|
||||
Useful for catching small purchases before they stack up.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={decrementSpend}
|
||||
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-52">
|
||||
<p className="text-7xl font-normal leading-none tracking-[-0.06em] sm:text-8xl" style={{ color: "var(--primary)" }}>
|
||||
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)}
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-normal text-[var(--muted)]">
|
||||
{dailySpendLimit === "none" ? "No daily budget" : "maximum per day"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={incrementSpend}
|
||||
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 className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDailySpendLimit("none")}
|
||||
className="rounded-full border px-4 py-2 text-sm font-normal transition"
|
||||
style={{
|
||||
background: dailySpendLimit === "none" ? "var(--primary-container)" : "var(--surface-container-lowest)",
|
||||
borderColor: dailySpendLimit === "none" ? "var(--primary)" : "var(--outline-variant)",
|
||||
color: dailySpendLimit === "none" ? "var(--on-primary-container)" : "var(--muted)",
|
||||
}}
|
||||
>
|
||||
No spend cap
|
||||
</button>
|
||||
{dailySpendLimit === "none" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDailySpendLimit(3.5)}
|
||||
className="rounded-full border px-4 py-2 text-sm font-normal transition"
|
||||
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
|
||||
>
|
||||
Use £3.50
|
||||
</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 === 5 && (
|
||||
<section className="grid gap-8">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--primary)]">4. Caffeine curfew</p>
|
||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||
When should late caffeine stop?
|
||||
</h2>
|
||||
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
|
||||
Choose when the app should warn you that sleep may take the hit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -369,10 +424,10 @@ export function OnboardingScreen({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
{step === 6 && (
|
||||
<section className="grid gap-8">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm font-normal text-[var(--primary)]">done</p>
|
||||
<p className="text-sm font-normal text-[var(--primary)]">Ready</p>
|
||||
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
|
||||
This is your tracking profile.
|
||||
</h2>
|
||||
@@ -386,12 +441,6 @@ export function OnboardingScreen({
|
||||
{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)]">
|
||||
@@ -401,7 +450,7 @@ export function OnboardingScreen({
|
||||
<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)}
|
||||
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -410,33 +459,16 @@ export function OnboardingScreen({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<p className="max-w-xl text-sm font-normal" style={{ color: "var(--error)" }} role="alert">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="inline-flex min-h-12 w-fit items-center rounded-full border px-6 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
style={{ borderColor: "var(--outline-variant)", color: "var(--muted)" }}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</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>
|
||||
@@ -455,7 +487,7 @@ export function OnboardingScreen({
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<p className="text-xs font-normal text-[var(--muted)]">you can edit this later.</p>
|
||||
<p className="text-xs font-normal text-[var(--muted)]">Minimal setup. Editable later.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Zap } from "lucide-react";
|
||||
import { flavourMeta } from "../data/flavours";
|
||||
import { currency } from "../lib/metrics";
|
||||
import { AppCard } from "./AppCard";
|
||||
import type { QuickAddItem } from "../data/quickAdds";
|
||||
|
||||
type QuickAddPanelProps = {
|
||||
items: QuickAddItem[];
|
||||
onQuickAdd: (item: QuickAddItem) => void;
|
||||
};
|
||||
|
||||
export function QuickAddPanel({ items, onQuickAdd }: QuickAddPanelProps) {
|
||||
return (
|
||||
<AppCard title="Quick add" subtitle="One tap entries">
|
||||
<div className="quick-add-grid grid gap-2">
|
||||
{items.map((item) => {
|
||||
const meta = flavourMeta(item.flavour);
|
||||
return (
|
||||
<button key={item.label} className="quick-add-button" type="button" onClick={() => onQuickAdd(item)}>
|
||||
<span className="quick-add-icon">
|
||||
<Zap size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-medium">{item.label}</span>
|
||||
<span className="quick-add-meta">
|
||||
{item.sizeMl}ml · {item.flavour}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: meta.accent }}>
|
||||
{currency.format(item.pricePerCan)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export function ShellBackdrop() {
|
||||
return (
|
||||
<>
|
||||
<div className="backdrop-wash pointer-events-none fixed inset-0 -z-10" />
|
||||
<div className="backdrop-grid pointer-events-none fixed inset-0 -z-10" />
|
||||
<div className="backdrop-rail pointer-events-none fixed inset-x-0 top-0 -z-10 h-1" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Camera, Cloud, Command, Loader2, Plus, User } from "lucide-react";
|
||||
import { LegalFootnote } from "./LegalFootnote";
|
||||
import { NAV_ITEMS } from "./navItems";
|
||||
import type { AppView, AuthUser, SetupStatus } from "../types";
|
||||
|
||||
type SidebarProps = {
|
||||
activeView: AppView;
|
||||
dataLoading: boolean;
|
||||
setupStatus: SetupStatus;
|
||||
user: AuthUser;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
onChange: (view: AppView) => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
export function Sidebar({
|
||||
activeView,
|
||||
dataLoading,
|
||||
setupStatus,
|
||||
user,
|
||||
onAdd,
|
||||
onScan,
|
||||
onChange,
|
||||
onOpenSettings,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<aside className="material-drawer glass-panel">
|
||||
<div className="mb-7 flex items-center gap-3 px-2 pt-1">
|
||||
<div className="can-emblem">
|
||||
<Command size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-medium text-slate-950">Red Bull</p>
|
||||
<p className="truncate text-sm text-slate-600">Intake tracker</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="drawer-primary-action" type="button" onClick={onAdd}>
|
||||
<Plus size={19} aria-hidden="true" />
|
||||
Add intake
|
||||
</button>
|
||||
|
||||
<button className="secondary-button w-full justify-center" type="button" onClick={onScan}>
|
||||
<Camera size={18} aria-hidden="true" />
|
||||
Scan barcode
|
||||
</button>
|
||||
|
||||
<nav className="drawer-nav" aria-label="Main navigation">
|
||||
{NAV_ITEMS.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`nav-item ${activeView === item.id ? "nav-item-active" : ""}`}
|
||||
onClick={() => onChange(item.id)}
|
||||
>
|
||||
<span className={`nav-icon-dot nav-icon-dot-${index}`} aria-hidden="true">
|
||||
<item.icon size={21} />
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<LegalFootnote className="mb-3 px-1" />
|
||||
|
||||
<div className="drawer-footer">
|
||||
<div className="drawer-info-card">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
|
||||
{dataLoading ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Cloud size={15} aria-hidden="true" />}
|
||||
Sync
|
||||
</div>
|
||||
<p className={`text-xs ${setupStatus.state === "ok" ? "text-emerald-700" : "text-amber-700"}`}>{setupStatus.message}</p>
|
||||
</div>
|
||||
|
||||
<button className="account-pill" type="button" onClick={onOpenSettings}>
|
||||
<User size={16} aria-hidden="true" />
|
||||
{user.name || user.email || "Account & settings"}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
type SkeletonProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Skeleton({ className = "" }: SkeletonProps) {
|
||||
return <div className={className ? `skeleton ${className}` : "skeleton"} aria-hidden="true" />;
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { canLimitFromSpend, currency, entriesInRange, oneDecimal, spendFor, sum } from "../lib/metrics";
|
||||
import { AppCard } from "./AppCard";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import type { RedBullEntry, UserLimits } from "../types";
|
||||
|
||||
type ForecastPoint = {
|
||||
label: string;
|
||||
current: number;
|
||||
lower: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const FORECAST_WINDOWS = [7, 30, 90, 365] as const;
|
||||
|
||||
type SpendForecastCardProps = {
|
||||
entries: RedBullEntry[];
|
||||
userLimits: UserLimits;
|
||||
onSaveLimits?: (limits: UserLimits) => void;
|
||||
};
|
||||
|
||||
export function SpendForecastCard({ entries, userLimits, onSaveLimits }: SpendForecastCardProps) {
|
||||
const [projectionDays, setProjectionDays] = useState<7 | 30 | 90 | 365>(30);
|
||||
const now = useMemo(() => new Date(), []);
|
||||
|
||||
const firstEntryDate = useMemo(() => {
|
||||
if (!entries.length) return now;
|
||||
return new Date(
|
||||
[...entries].sort(
|
||||
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime(),
|
||||
)[0].dateTime,
|
||||
);
|
||||
}, [entries, now]);
|
||||
|
||||
const activePeriodDays = useMemo(() => {
|
||||
const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime());
|
||||
return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
|
||||
}, [firstEntryDate, now]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const periodStart = new Date(now.getTime() - activePeriodDays * 86_400_000);
|
||||
const recentEntries = entriesInRange(entries, periodStart, now);
|
||||
const totalSpend = sum(recentEntries, spendFor);
|
||||
const totalCans = sum(recentEntries, (entry) => entry.cans);
|
||||
const hasData = recentEntries.length > 0;
|
||||
return {
|
||||
hasData,
|
||||
avgDailySpend: hasData ? totalSpend / activePeriodDays : 0,
|
||||
avgDailyCans: hasData ? totalCans / activePeriodDays : 0,
|
||||
};
|
||||
}, [entries, activePeriodDays, now]);
|
||||
|
||||
const projectionData = useMemo<ForecastPoint[]>(() => {
|
||||
return Array.from({ length: projectionDays }).map((_, index) => {
|
||||
const day = index + 1;
|
||||
const dataPoint: ForecastPoint = {
|
||||
label: `day ${day}`,
|
||||
current: Number((day * stats.avgDailySpend).toFixed(2)),
|
||||
lower: Number((day * stats.avgDailySpend * 0.8).toFixed(2)),
|
||||
};
|
||||
if (userLimits.dailySpendLimit != null) {
|
||||
dataPoint.limit = Number((day * userLimits.dailySpendLimit).toFixed(2));
|
||||
}
|
||||
return dataPoint;
|
||||
});
|
||||
}, [projectionDays, stats.avgDailySpend, userLimits?.dailySpendLimit]);
|
||||
|
||||
if (!stats.hasData) {
|
||||
return (
|
||||
<AppCard title="Spend forecast" subtitle="Based on past spending">
|
||||
<EmptyState title="No spend forecast yet" copy="Add an intake first." />
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
const projectedSpend = stats.avgDailySpend * projectionDays;
|
||||
const projectedCans = stats.avgDailyCans * projectionDays;
|
||||
const lowerSpend = projectedSpend * 0.8;
|
||||
const possibleSavings = projectedSpend - lowerSpend;
|
||||
|
||||
const saveLowerLimit = () => {
|
||||
if (!onSaveLimits) return;
|
||||
const lowerDailyLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100;
|
||||
const size = userLimits.limitCanSizeMl ?? 250;
|
||||
onSaveLimits({
|
||||
...userLimits,
|
||||
limitCanSizeMl: size,
|
||||
dailySpendLimit: lowerDailyLimit,
|
||||
dailyCanLimit: canLimitFromSpend(lowerDailyLimit, size),
|
||||
});
|
||||
};
|
||||
|
||||
const onWindowKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const keys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
|
||||
if (!keys.includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1;
|
||||
const currentIndex = FORECAST_WINDOWS.indexOf(projectionDays);
|
||||
const next = FORECAST_WINDOWS[(currentIndex + direction + FORECAST_WINDOWS.length) % FORECAST_WINDOWS.length];
|
||||
setProjectionDays(next);
|
||||
const buttons = event.currentTarget.querySelectorAll<HTMLButtonElement>("button");
|
||||
buttons[FORECAST_WINDOWS.indexOf(next)]?.focus();
|
||||
};
|
||||
|
||||
const forecastChartLabel = `Projected spend over the next ${projectionDays} days: ${currency.format(projectedSpend)} at the current daily average of ${currency.format(stats.avgDailySpend)}`;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="Spend forecast"
|
||||
subtitle={`${activePeriodDays} day average: ${currency.format(stats.avgDailySpend)} per day`}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 border-b pb-4 sm:flex-row sm:items-center sm:justify-between" style={{ borderColor: "var(--outline-variant)" }}>
|
||||
<p className="app-card-subtitle">Forecast window</p>
|
||||
<div
|
||||
className="segmented-control max-w-xs self-start"
|
||||
role="radiogroup"
|
||||
aria-label="Forecast window"
|
||||
onKeyDown={onWindowKeyDown}
|
||||
>
|
||||
{FORECAST_WINDOWS.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={projectionDays === days}
|
||||
tabIndex={projectionDays === days ? 0 : -1}
|
||||
onClick={() => setProjectionDays(days)}
|
||||
className={projectionDays === days ? "segmented-control-active" : ""}
|
||||
>
|
||||
{days === 365 ? "1 year" : `${days} days`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="forecast-stat space-y-1">
|
||||
<span className="forecast-stat-label">Projected spend</span>
|
||||
<p className="forecast-stat-value">{currency.format(projectedSpend)}</p>
|
||||
<span className="forecast-stat-note">
|
||||
~{oneDecimal.format(projectedCans)} cans logged
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="forecast-stat forecast-stat--positive space-y-1">
|
||||
<span className="forecast-stat-label">20 percent lower</span>
|
||||
<p className="forecast-stat-value">{currency.format(lowerSpend)}</p>
|
||||
<span className="forecast-stat-note">
|
||||
~{oneDecimal.format(projectedCans * 0.8)} cans logged
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="forecast-stat forecast-stat--positive flex flex-col justify-between space-y-1">
|
||||
<div>
|
||||
<span className="forecast-stat-label">Possible savings</span>
|
||||
<p className="forecast-stat-value">{currency.format(possibleSavings)}</p>
|
||||
</div>
|
||||
{onSaveLimits && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveLowerLimit}
|
||||
className="forecast-stat-note mt-1 block text-left underline"
|
||||
style={{ color: "var(--success)" }}
|
||||
>
|
||||
Lock daily limit to {currency.format(stats.avgDailySpend * 0.8)}/day
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="forecast-chart-wrap relative" role="img" aria-label={forecastChartLabel}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={projectionData} margin={{ top: 12, right: 16, bottom: 0, left: -10 }}>
|
||||
<defs>
|
||||
<linearGradient id="currentProj" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--primary)" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="optimalProj" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--success)" stopOpacity={0.15} />
|
||||
<stop offset="100%" stopColor="var(--success)" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||
<XAxis dataKey="label" stroke="var(--subtle)" tickLine={false} axisLine={false} interval="preserveStartEnd" minTickGap={24} />
|
||||
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} tickFormatter={(val) => `£${val}`} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="current"
|
||||
name="current"
|
||||
stroke="var(--primary)"
|
||||
fill="url(#currentProj)"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="lower"
|
||||
name="20 percent lower"
|
||||
stroke="var(--success)"
|
||||
fill="url(#optimalProj)"
|
||||
strokeWidth={3}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
{userLimits.dailySpendLimit != null && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="limit"
|
||||
name="daily limit"
|
||||
stroke="var(--warning)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
strokeDasharray="6 6"
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Info } from "lucide-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
type StatHintProps = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function StatHint({ label, hint }: StatHintProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLSpanElement>(null);
|
||||
const tooltipId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!hint) return null;
|
||||
|
||||
return (
|
||||
<span className="stat-hint" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="stat-hint-trigger"
|
||||
aria-label={`More about ${label}`}
|
||||
aria-expanded={open}
|
||||
aria-describedby={open ? tooltipId : undefined}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Info size={12} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<span className="stat-hint-popover" role="tooltip" id={tooltipId}>
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import type { SetupStatus } from "../types";
|
||||
|
||||
type StatusRailProps = {
|
||||
busyAction: string | null;
|
||||
setupStatus: SetupStatus;
|
||||
};
|
||||
|
||||
export function StatusRail({ busyAction, setupStatus }: StatusRailProps) {
|
||||
if (!busyAction && setupStatus.state === "ok") return null;
|
||||
return (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{busyAction && (
|
||||
<div className="status-card">
|
||||
<Loader2 className="animate-spin" size={17} aria-hidden="true" />
|
||||
Working on {actionLabel(busyAction)}...
|
||||
</div>
|
||||
)}
|
||||
{setupStatus.state === "error" && (
|
||||
<div className="status-card status-card--warning">
|
||||
<AlertTriangle size={17} aria-hidden="true" />
|
||||
{setupStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function actionLabel(value: string) {
|
||||
return value
|
||||
.replace(/^quick-/, "quick add ")
|
||||
.replace(/-/g, " ");
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { APP_THEMES, getThemeById, type ThemeMode } from "../data/themes";
|
||||
|
||||
const THEME_MODES: Array<{ id: ThemeMode; label: string }> = [
|
||||
{ id: "light", label: "Light" },
|
||||
{ id: "dark", label: "Dark" },
|
||||
{ id: "system", label: "System" },
|
||||
];
|
||||
|
||||
type ThemePickerProps = {
|
||||
themeId: string;
|
||||
themeMode: ThemeMode;
|
||||
onChange: (id: string) => void;
|
||||
onThemeModeChange: (mode: ThemeMode) => void;
|
||||
};
|
||||
|
||||
export function ThemePicker({ themeId, themeMode, onChange, onThemeModeChange }: ThemePickerProps) {
|
||||
const activeTheme = getThemeById(themeId);
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div
|
||||
className="segmented-control max-w-xs"
|
||||
role="group"
|
||||
aria-label="Colour mode"
|
||||
style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}
|
||||
>
|
||||
{THEME_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
aria-pressed={themeMode === mode.id}
|
||||
className={themeMode === mode.id ? "segmented-control-active" : ""}
|
||||
onClick={() => onThemeModeChange(mode.id)}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="theme-preview-strip">
|
||||
<div className="theme-preview-chip primary-button px-4 py-2 text-sm">Button</div>
|
||||
<div className="theme-preview-chip glass-panel px-4 py-2 text-sm">Panel</div>
|
||||
<div className="theme-preview-chip rounded-lg px-4 py-2 text-sm" style={{ background: "var(--chart-secondary)", color: "var(--on-secondary)" }}>
|
||||
Chart
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-picker-grid" role="group" aria-label="App themes">
|
||||
{APP_THEMES.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
type="button"
|
||||
aria-pressed={themeId === theme.id}
|
||||
className={`theme-tile ${themeId === theme.id ? "theme-tile-active" : ""}`}
|
||||
onClick={() => onChange(theme.id)}
|
||||
>
|
||||
<span className="theme-tile-swatch" style={{ background: theme.swatch }} aria-hidden="true" />
|
||||
<span className="theme-tile-label">{theme.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
Current theme: <span className="font-semibold text-slate-900">{activeTheme.label}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { TOAST_TRANSITION } from "../lib/motion";
|
||||
import { ToastContext, type ToastInput, type ToastTone } from "../lib/toasts";
|
||||
|
||||
const TOAST_DURATION_MS = 4500;
|
||||
const MAX_TOASTS = 4;
|
||||
// Identical tone+message repeats inside this window are swallowed so
|
||||
// StrictMode double effects and rapid state re-sets never double-fire.
|
||||
const DEDUPE_WINDOW_MS = 1200;
|
||||
|
||||
type Toast = ToastInput & { id: number };
|
||||
|
||||
const TONE_ICONS: Record<ToastTone, typeof Info> = {
|
||||
success: CheckCircle2,
|
||||
error: AlertTriangle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const nextIdRef = useRef(0);
|
||||
const lastToastRef = useRef<(ToastInput & { at: number }) | null>(null);
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const pushToast = useCallback(
|
||||
({ tone, message }: ToastInput) => {
|
||||
const now = Date.now();
|
||||
const last = lastToastRef.current;
|
||||
if (last && last.tone === tone && last.message === message && now - last.at < DEDUPE_WINDOW_MS) return;
|
||||
lastToastRef.current = { tone, message, at: now };
|
||||
const id = ++nextIdRef.current;
|
||||
setToasts((current) => [...current, { id, tone, message }].slice(-MAX_TOASTS));
|
||||
window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS);
|
||||
},
|
||||
[dismissToast],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ pushToast }), [pushToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="toast-stack" role="status" aria-live="polite">
|
||||
<AnimatePresence initial={false}>
|
||||
{toasts.map((toast) => {
|
||||
const ToneIcon = TONE_ICONS[toast.tone];
|
||||
return (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
className={`toast toast--${toast.tone}`}
|
||||
initial={{ opacity: 0, y: -12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={TOAST_TRANSITION}
|
||||
>
|
||||
<ToneIcon className="toast-icon" size={18} aria-hidden="true" />
|
||||
<p className="toast-message">{toast.message}</p>
|
||||
<button
|
||||
className="toast-dismiss"
|
||||
type="button"
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Camera, Plus } from "lucide-react";
|
||||
import { MATERIAL_ACCENTS } from "../data/accents";
|
||||
import { currency } from "../lib/metrics";
|
||||
import { limitStatusMessage } from "../lib/userLimits";
|
||||
import { MiniMetric } from "./MetricTiles";
|
||||
import type { Dashboard } from "../lib/dashboard";
|
||||
import type { LimitCheckResult, RedBullEntry, UserLimits } from "../types";
|
||||
|
||||
type TodayPanelProps = {
|
||||
summary: Dashboard;
|
||||
entries: RedBullEntry[];
|
||||
userLimits: UserLimits;
|
||||
limitCheck: LimitCheckResult;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
};
|
||||
|
||||
export function TodayPanel({ summary, entries, userLimits, limitCheck, onAdd, onScan }: TodayPanelProps) {
|
||||
const limitSummary =
|
||||
userLimits.dailyCanLimit != null || userLimits.dailySpendLimit != null
|
||||
? limitCheck.violations.length
|
||||
? limitStatusMessage(limitCheck.violations, limitCheck, userLimits)
|
||||
: `${limitCheck.todayCans} cans · ${currency.format(limitCheck.todaySpend)} spent today`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<section className="can-panel today-panel relative overflow-hidden p-5 sm:p-7">
|
||||
<p className="section-kicker">Today</p>
|
||||
<div className="mt-3 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="today-stat-value">{summary.todayCans}</p>
|
||||
<p className="today-stat-label mt-2">cans logged</p>
|
||||
{limitSummary ? <p className="today-limit-summary mt-2">{limitSummary}</p> : null}
|
||||
</div>
|
||||
<div className="today-panel-metrics lg:min-w-[420px]">
|
||||
<MiniMetric label="Caffeine" value={summary.todayCaffeine} accent={MATERIAL_ACCENTS.primary} />
|
||||
<MiniMetric label="Sugar" value={summary.todaySugar} accent={MATERIAL_ACCENTS.secondary} />
|
||||
<MiniMetric label="Streak" value={summary.currentStreak} accent={MATERIAL_ACCENTS.tertiary} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="today-action-row mt-6 flex flex-wrap items-center gap-2">
|
||||
<button className="primary-button" type="button" onClick={onAdd}>
|
||||
<Plus size={18} aria-hidden="true" />
|
||||
Add intake
|
||||
</button>
|
||||
<button className="secondary-button" type="button" onClick={onScan}>
|
||||
<Camera size={18} aria-hidden="true" />
|
||||
Scan barcode
|
||||
</button>
|
||||
<span className="entry-chip px-3 py-2 text-sm">
|
||||
{entries.length ? `${summary.allTimeCans} all-time cans` : "Ready for your first entry"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Camera, Plus } from "lucide-react";
|
||||
import { NAV_ITEMS } from "./navItems";
|
||||
import type { AppView } from "../types";
|
||||
|
||||
type TopBarProps = {
|
||||
activeView: AppView;
|
||||
busyAction: string | null;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function TopBar({ activeView, busyAction, onAdd, onScan, className = "" }: TopBarProps) {
|
||||
const activeItem = NAV_ITEMS.find((item) => item.id === activeView) ?? NAV_ITEMS[0];
|
||||
const ActiveIcon = activeItem.icon;
|
||||
const title = activeItem.label;
|
||||
const subtitle = new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
}).format(new Date());
|
||||
|
||||
return (
|
||||
<header className={`top-app-bar ${className}`.trim()} data-view={activeView}>
|
||||
<div className="top-app-bar-main">
|
||||
<div className="top-title-cluster">
|
||||
<span className="top-app-icon">
|
||||
<ActiveIcon size={24} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="top-kicker">{subtitle}</p>
|
||||
<h1 className="top-title">{title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-action-row">
|
||||
<button
|
||||
className="secondary-button top-action-button justify-center"
|
||||
type="button"
|
||||
onClick={onScan}
|
||||
disabled={Boolean(busyAction)}
|
||||
aria-label="Scan barcode"
|
||||
>
|
||||
<Camera size={18} aria-hidden="true" />
|
||||
<span className="top-action-label">Scan</span>
|
||||
</button>
|
||||
<button
|
||||
className="primary-button top-action-button justify-center"
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
disabled={Boolean(busyAction)}
|
||||
aria-label="Add intake"
|
||||
>
|
||||
<Plus size={18} aria-hidden="true" />
|
||||
<span className="top-action-label">Add intake</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { CalendarDays, Home, LineChart, Settings2 } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { AppView } from "../types";
|
||||
|
||||
export const NAV_ITEMS: Array<{ id: AppView; label: string; icon: LucideIcon }> = [
|
||||
{ id: "overview", label: "Overview", icon: Home },
|
||||
{ id: "logbook", label: "Logbook", icon: CalendarDays },
|
||||
{ id: "trends", label: "Trends", icon: LineChart },
|
||||
{ id: "settings", label: "Settings", icon: Settings2 },
|
||||
];
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type BadgeTone = "neutral" | "accent" | "success" | "warning" | "danger";
|
||||
|
||||
export type BadgeProps = {
|
||||
tone?: BadgeTone;
|
||||
children: ReactNode;
|
||||
} & HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export function Badge({ tone = "neutral", className, children, ...rest }: BadgeProps) {
|
||||
const classes = ["badge", tone !== "neutral" ? `badge-${tone}` : "", className].filter(Boolean).join(" ");
|
||||
return (
|
||||
<span className={classes} {...rest}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger";
|
||||
|
||||
export type ButtonProps = {
|
||||
variant?: ButtonVariant;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const BUTTON_VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||
primary: "primary-button",
|
||||
secondary: "secondary-button",
|
||||
danger: "danger-button",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
loading = false,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
type = "button",
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const classes = [BUTTON_VARIANT_CLASSES[variant], className].filter(Boolean).join(" ");
|
||||
return (
|
||||
<button className={classes} type={type} disabled={disabled || loading} aria-busy={loading || undefined} {...rest}>
|
||||
{loading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type CardProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Card({ title, subtitle, actions, className, children }: CardProps) {
|
||||
const classes = ["app-card p-4 sm:p-5", className].filter(Boolean).join(" ");
|
||||
return (
|
||||
<section className={classes}>
|
||||
{(title || subtitle || actions) && (
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{title && <h2 className="app-card-title text-xl">{title}</h2>}
|
||||
{subtitle && <p className="app-card-subtitle mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Zap } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type EmptyStateProps = {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function EmptyState({ icon, title, body, action, className }: EmptyStateProps) {
|
||||
const classes = ["empty-state", className].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className="empty-state-icon">{icon ?? <Zap size={22} aria-hidden="true" />}</div>
|
||||
<h3 className="empty-state-title">{title}</h3>
|
||||
<p className="empty-state-copy mt-2 max-w-sm">{body}</p>
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type FieldControlProps = {
|
||||
id: string;
|
||||
"aria-invalid": true | undefined;
|
||||
"aria-describedby": string | undefined;
|
||||
};
|
||||
|
||||
export type FieldProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
help?: string;
|
||||
className?: string;
|
||||
children: (controlProps: FieldControlProps) => ReactNode;
|
||||
};
|
||||
|
||||
export function Field({ id, label, error, help, className, children }: FieldProps) {
|
||||
const message = error ?? help;
|
||||
const messageId = message ? `${id}-message` : undefined;
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
{children({
|
||||
id,
|
||||
"aria-invalid": error ? true : undefined,
|
||||
"aria-describedby": messageId,
|
||||
})}
|
||||
</div>
|
||||
{message && (
|
||||
<p id={messageId} className={error ? "field-error-text" : "field-help-text"}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export { Badge } from "./Badge";
|
||||
export type { BadgeProps, BadgeTone } from "./Badge";
|
||||
export { Button } from "./Button";
|
||||
export type { ButtonProps, ButtonVariant } from "./Button";
|
||||
export { Card } from "./Card";
|
||||
export type { CardProps } from "./Card";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export type { EmptyStateProps } from "./EmptyState";
|
||||
export { Field } from "./Field";
|
||||
export type { FieldControlProps, FieldProps } from "./Field";
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Eye, EyeOff, Loader2, LogIn } from "lucide-react";
|
||||
import { useState, type CSSProperties, type FormEvent } from "react";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import { ShellBackdrop } from "../ShellBackdrop";
|
||||
import type { SetupStatus } from "../../types";
|
||||
|
||||
type AuthMode = "login" | "signup";
|
||||
|
||||
type AuthViewProps = {
|
||||
authError: string;
|
||||
busy: boolean;
|
||||
setupStatus: SetupStatus;
|
||||
shellStyle: CSSProperties;
|
||||
themeId: string;
|
||||
resolvedMode: "light" | "dark";
|
||||
onLogin: (email: string, password: string) => Promise<void>;
|
||||
onSignup: (name: string, email: string, password: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function AuthView({
|
||||
authError,
|
||||
busy,
|
||||
setupStatus,
|
||||
shellStyle,
|
||||
themeId,
|
||||
resolvedMode,
|
||||
onLogin,
|
||||
onSignup,
|
||||
}: AuthViewProps) {
|
||||
const [mode, setMode] = useState<AuthMode>("login");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (mode === "signup") {
|
||||
void onSignup(name, email, password);
|
||||
return;
|
||||
}
|
||||
void onLogin(email, password);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell min-h-screen" data-theme={themeId} data-mode={resolvedMode} style={shellStyle}>
|
||||
<ShellBackdrop />
|
||||
<main className="flex min-h-screen items-center justify-center p-6">
|
||||
<div className="auth-panel-shell">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="hero-name text-3xl">Red Bull tracker</h1>
|
||||
<p className="hero-copy mt-2 text-sm">Track intake, sync across devices.</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-panel-card">
|
||||
{setupStatus.state !== "ok" && (
|
||||
<div className="limit-alert mb-4 px-3 py-2 text-xs">
|
||||
{setupStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="auth-mode-toggle mb-5">
|
||||
<button className={mode === "login" ? "auth-mode-active" : ""} type="button" onClick={() => setMode("login")}>
|
||||
Log in
|
||||
</button>
|
||||
<button className={mode === "signup" ? "auth-mode-active" : ""} type="button" onClick={() => setMode("signup")}>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="grid gap-3" onSubmit={submit}>
|
||||
{mode === "signup" && (
|
||||
<label className="field-label">
|
||||
Name
|
||||
<input className="field-control" type="text" autoComplete="name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Ned" />
|
||||
</label>
|
||||
)}
|
||||
<label className="field-label">
|
||||
Email
|
||||
<input className="field-control" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="you@example.com" required />
|
||||
</label>
|
||||
<label className="field-label">
|
||||
Password
|
||||
<span className="relative block">
|
||||
<input
|
||||
className="field-control pr-12"
|
||||
minLength={8}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete={mode === "signup" ? "new-password" : "current-password"}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="8+ characters"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
className="password-toggle"
|
||||
type="button"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
aria-pressed={showPassword}
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{authError && (
|
||||
<div
|
||||
className="rounded-md px-3 py-2 text-sm"
|
||||
style={{
|
||||
border: "1px solid color-mix(in srgb, var(--error) 35%, transparent)",
|
||||
background: "var(--error-container)",
|
||||
color: "var(--on-error-container)",
|
||||
}}
|
||||
>
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="primary-button w-full mt-1" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <LogIn size={17} aria-hidden="true" />}
|
||||
{mode === "signup" ? "Create account" : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<LegalFootnote className="mt-5" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { EntryLedger } from "../EntryLedger";
|
||||
import { FiltersPanel } from "../FiltersPanel";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import type { EntryDerived, Filters, Flavour, RedBullEntry } from "../../types";
|
||||
|
||||
type LogbookViewProps = {
|
||||
entries: RedBullEntry[];
|
||||
totalEntries: number;
|
||||
entryDerived: Map<string, EntryDerived>;
|
||||
filters: Filters;
|
||||
flavours: Flavour[];
|
||||
loading: boolean;
|
||||
deletingId: string | null;
|
||||
onFilterChange: (filters: Filters) => void;
|
||||
onAdd: () => void;
|
||||
onEdit: (entry: RedBullEntry) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onLogAgain?: (entry: RedBullEntry) => void;
|
||||
};
|
||||
|
||||
export function LogbookView({
|
||||
entries,
|
||||
totalEntries,
|
||||
entryDerived,
|
||||
filters,
|
||||
flavours,
|
||||
loading,
|
||||
deletingId,
|
||||
onFilterChange,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onLogAgain,
|
||||
}: LogbookViewProps) {
|
||||
return (
|
||||
<section className="grid gap-4">
|
||||
<div className="logbook-layout grid gap-4">
|
||||
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} />
|
||||
<EntryLedger
|
||||
entries={entries}
|
||||
totalEntries={totalEntries}
|
||||
entryDerived={entryDerived}
|
||||
filters={filters}
|
||||
loading={loading}
|
||||
deletingId={deletingId}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onLogAgain={onLogAgain}
|
||||
/>
|
||||
</div>
|
||||
<LegalFootnote />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Activity, AlertTriangle, CalendarDays, ChevronRight, PoundSterling, TimerReset } from "lucide-react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { MATERIAL_ACCENTS } from "../../data/accents";
|
||||
import type { QuickAddItem } from "../../data/quickAdds";
|
||||
import { currency, humanDateTime, spendFor } from "../../lib/metrics";
|
||||
import { limitStatusMessage } from "../../lib/userLimits";
|
||||
import { AppCard } from "../AppCard";
|
||||
import { ChartTooltip } from "../ChartTooltip";
|
||||
import { DailyLimitsCard } from "../DailyLimitsCard";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
import { GreetingPanel } from "../GreetingPanel";
|
||||
import { InsightCard } from "../InsightCard";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import { MetricTile } from "../MetricTiles";
|
||||
import { QuickAddPanel } from "../QuickAddPanel";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { TodayPanel } from "../TodayPanel";
|
||||
import type { Dashboard, Insight } from "../../lib/dashboard";
|
||||
import type { AuthUser, LimitCheckResult, RedBullEntry, UserLimits } from "../../types";
|
||||
|
||||
type OverviewViewProps = {
|
||||
summary: Dashboard;
|
||||
entries: RedBullEntry[];
|
||||
insights: Insight[];
|
||||
quickAdds: QuickAddItem[];
|
||||
recentEntries: RedBullEntry[];
|
||||
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
||||
flavourData: Array<{ name: string; value: number; spend: number; accent: string }>;
|
||||
user: AuthUser;
|
||||
userLimits: UserLimits;
|
||||
limitCheck: LimitCheckResult;
|
||||
loading: boolean;
|
||||
onQuickAdd: (item: QuickAddItem) => void;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
onOpenLogbook: () => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
export function OverviewView({
|
||||
summary,
|
||||
entries,
|
||||
insights,
|
||||
quickAdds,
|
||||
recentEntries,
|
||||
chartData,
|
||||
flavourData,
|
||||
user,
|
||||
userLimits,
|
||||
limitCheck,
|
||||
loading,
|
||||
onQuickAdd,
|
||||
onAdd,
|
||||
onScan,
|
||||
onOpenLogbook,
|
||||
onOpenSettings,
|
||||
}: OverviewViewProps) {
|
||||
const todaySpendRaw = limitCheck.todaySpend;
|
||||
const showSkeleton = loading && entries.length === 0;
|
||||
const spendLimitDetail =
|
||||
userLimits.dailySpendLimit != null
|
||||
? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today`
|
||||
: `${summary.monthSpend} this month`;
|
||||
const chartTotalSpend = chartData.reduce((total, point) => total + point.spend, 0);
|
||||
const chartTotalCans = chartData.reduce((total, point) => total + point.cans, 0);
|
||||
const spendChartLabel = `Spend over the last ${chartData.length} logged days, total ${currency.format(chartTotalSpend)} across ${chartTotalCans} cans`;
|
||||
const flavourChartLabel = flavourData.length
|
||||
? `Cans by flavour across ${flavourData.length} flavours. Most logged: ${flavourData[0].name}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<GreetingPanel summary={summary} user={user} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
|
||||
|
||||
<DailyLimitsCard limits={userLimits} check={limitCheck} onOpenSettings={onOpenSettings} />
|
||||
|
||||
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<TodayPanel summary={summary} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
|
||||
</div>
|
||||
|
||||
{limitCheck.violations.length ? (
|
||||
<section className="limit-alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 shrink-0" size={20} aria-hidden="true" style={{ color: "var(--warning)" }} />
|
||||
<div>
|
||||
<p className="limit-alert-title">Limit alerts</p>
|
||||
<p className="limit-alert-copy mt-1">
|
||||
{limitStatusMessage(limitCheck.violations, limitCheck, userLimits)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="overview-metrics-grid grid gap-3">
|
||||
{showSkeleton ? (
|
||||
Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="h-28" />)
|
||||
) : (
|
||||
<>
|
||||
<MetricTile icon={CalendarDays} label="This month" value={summary.monthCans} detail={`${summary.monthSpend} spent`} accent={MATERIAL_ACCENTS.primary} />
|
||||
<MetricTile
|
||||
icon={PoundSterling}
|
||||
label={userLimits.dailySpendLimit != null ? "Today's budget" : "Total spend"}
|
||||
value={userLimits.dailySpendLimit != null ? currency.format(todaySpendRaw) : summary.totalSpend}
|
||||
detail={spendLimitDetail}
|
||||
accent={MATERIAL_ACCENTS.secondary}
|
||||
/>
|
||||
<MetricTile icon={Activity} label="Favourite" value={summary.favouriteFlavour} detail="by total cans" accent={MATERIAL_ACCENTS.tertiary} />
|
||||
<MetricTile icon={TimerReset} label="Days without" value={summary.daysWithoutRedBull} detail={`${summary.currentStreak} day streak`} accent={MATERIAL_ACCENTS.error} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="overview-charts-grid grid gap-4">
|
||||
<AppCard title="Spend overview" subtitle="Last 30 logged days">
|
||||
{showSkeleton ? (
|
||||
<Skeleton className="chart-shell chart-shell--area" />
|
||||
) : chartData.length ? (
|
||||
<div className="chart-shell chart-shell--area" role="img" aria-label={spendChartLabel}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
|
||||
<defs>
|
||||
<linearGradient id="mikuSpend" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#39d5ff" stopOpacity={0.36} />
|
||||
<stop offset="100%" stopColor="#39d5ff" stopOpacity={0.03} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="rgba(203,213,225,0.12)" vertical={false} />
|
||||
<XAxis dataKey="label" stroke="#94a3b8" tickLine={false} axisLine={false} />
|
||||
<YAxis stroke="#94a3b8" tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Area type="monotone" dataKey="spend" name="Spend" stroke="#39d5ff" fill="url(#mikuSpend)" strokeWidth={3} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="No spend data yet" copy="Add an intake or use quick add to start the chart." actionLabel="Add intake" onAction={onAdd} />
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Recent entries" subtitle={`${recentEntries.length} shown`}>
|
||||
{showSkeleton ? (
|
||||
<div className="grid gap-2">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
) : recentEntries.length ? (
|
||||
<div className="grid gap-2">
|
||||
{recentEntries.map((entry) => (
|
||||
<MiniEntry key={entry.id} entry={entry} />
|
||||
))}
|
||||
<button className="list-button" type="button" onClick={onOpenLogbook}>
|
||||
Open logbook
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Nothing logged" copy="Your newest entries will appear here." actionLabel="Add intake" onAction={onAdd} />
|
||||
)}
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<section className="overview-insights-grid grid gap-3">
|
||||
{insights.map((insight) => (
|
||||
<InsightCard key={insight.label} insight={insight} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4">
|
||||
<AppCard title="Flavour mix" subtitle="Cans by flavour">
|
||||
{flavourData.length ? (
|
||||
<div className="chart-shell chart-shell--pie" role="img" aria-label={flavourChartLabel}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={flavourData} dataKey="value" nameKey="name" innerRadius={70} outerRadius={104} paddingAngle={4} stroke="#080d1f" strokeWidth={4}>
|
||||
{flavourData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.accent} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="No flavours yet" copy="Flavour breakdown appears after your first entry." />
|
||||
)}
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<LegalFootnote className="mt-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniEntry({ entry }: { entry: RedBullEntry }) {
|
||||
return (
|
||||
<div className="mini-entry-card">
|
||||
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: entry.flavourAccent }} />
|
||||
<div className="min-w-0">
|
||||
<p className="mini-entry-title truncate">{entry.flavour}</p>
|
||||
<p className="mini-entry-meta truncate">{humanDateTime(entry.dateTime)}</p>
|
||||
</div>
|
||||
<p className="mini-entry-price">{currency.format(spendFor(entry))}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { Cloud, FileJson, FileSpreadsheet, Loader2, LogOut, RefreshCcw, RotateCcw, Sparkles, Upload } from "lucide-react";
|
||||
import { MATERIAL_ACCENTS } from "../../data/accents";
|
||||
import type { AppTheme, ThemeMode } from "../../data/themes";
|
||||
import { appwriteConfig } from "../../lib/appwrite";
|
||||
import { userInitial } from "../../lib/userDisplay";
|
||||
import { AppCard } from "../AppCard";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import { LimitsSettingsForm } from "../LimitsSettingsForm";
|
||||
import { MiniMetric } from "../MetricTiles";
|
||||
import { ThemePicker } from "../ThemePicker";
|
||||
import type { Dashboard } from "../../lib/dashboard";
|
||||
import type { AuthUser, LimitCheckResult, RedBullEntry, SetupStatus, UserLimits } from "../../types";
|
||||
|
||||
type SettingsViewProps = {
|
||||
activeTheme: AppTheme;
|
||||
summary: Dashboard;
|
||||
dataLoading: boolean;
|
||||
entries: RedBullEntry[];
|
||||
setupStatus: SetupStatus;
|
||||
themeId: string;
|
||||
themeMode: ThemeMode;
|
||||
user: AuthUser | null;
|
||||
userLimits: UserLimits;
|
||||
limitCheck: LimitCheckResult;
|
||||
busyAction: string | null;
|
||||
onExportExcel: () => void;
|
||||
onImportExcel: () => void;
|
||||
onExportJson: () => void;
|
||||
onImportJson: () => void;
|
||||
onLogout: () => void;
|
||||
onReset: () => void;
|
||||
onThemeChange: (id: string) => void;
|
||||
onThemeModeChange: (mode: ThemeMode) => void;
|
||||
onSaveLimits: (limits: UserLimits) => void;
|
||||
onRerunOnboarding: () => void;
|
||||
};
|
||||
|
||||
export function SettingsView({
|
||||
activeTheme,
|
||||
summary,
|
||||
dataLoading,
|
||||
entries,
|
||||
setupStatus,
|
||||
themeId,
|
||||
themeMode,
|
||||
user,
|
||||
userLimits,
|
||||
limitCheck,
|
||||
busyAction,
|
||||
onExportExcel,
|
||||
onImportExcel,
|
||||
onExportJson,
|
||||
onImportJson,
|
||||
onLogout,
|
||||
onReset,
|
||||
onThemeChange,
|
||||
onThemeModeChange,
|
||||
onSaveLimits,
|
||||
onRerunOnboarding,
|
||||
}: SettingsViewProps) {
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
|
||||
<div className="grid content-start gap-4">
|
||||
<AppCard title="Limits" subtitle="Personal caps for cans, spend, and stop time (local time)">
|
||||
<LimitsSettingsForm
|
||||
limits={userLimits}
|
||||
check={limitCheck}
|
||||
saving={busyAction === "save-limits"}
|
||||
onSave={onSaveLimits}
|
||||
/>
|
||||
<div className="mt-4 border-t border-white/5 pt-4 flex justify-end">
|
||||
<button
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-white/5 border border-white/10 px-4 text-xs font-bold text-slate-300 hover:bg-white/10 transition active:scale-95"
|
||||
type="button"
|
||||
onClick={onRerunOnboarding}
|
||||
>
|
||||
<Sparkles size={14} className="text-cyan-400" />
|
||||
Run setup again
|
||||
</button>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Appearance" subtitle={`Pick a theme and colour mode · ${activeTheme.label} active`}>
|
||||
<ThemePicker themeId={themeId} themeMode={themeMode} onChange={onThemeChange} onThemeModeChange={onThemeModeChange} />
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Account" subtitle="Signed in with Appwrite">
|
||||
<div className="account-card">
|
||||
<div className="account-avatar">{userInitial(user)}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-medium text-slate-950">{user?.name || "Appwrite user"}</p>
|
||||
<p className="truncate text-sm text-slate-500">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.05] p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||
{dataLoading ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Cloud size={16} aria-hidden="true" />}
|
||||
{setupStatus.message}
|
||||
</div>
|
||||
</div>
|
||||
<button className="secondary-button mt-4 justify-center" type="button" onClick={onLogout}>
|
||||
<LogOut size={17} aria-hidden="true" />
|
||||
Log out
|
||||
</button>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-4">
|
||||
<AppCard title="Data & sync" subtitle={`${entries.length} entries synced for this user`}>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MiniMetric label="All-time cans" value={summary.allTimeCans} accent={MATERIAL_ACCENTS.primary} />
|
||||
<MiniMetric label="Total spend" value={summary.totalSpend} accent={MATERIAL_ACCENTS.tertiary} />
|
||||
<MiniMetric label="Favourite" value={summary.favouriteFlavour} accent={MATERIAL_ACCENTS.secondary} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-2 xl:grid-cols-2">
|
||||
<button className="secondary-button justify-center" type="button" onClick={() => window.location.reload()} disabled={dataLoading}>
|
||||
{dataLoading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <RefreshCcw size={17} aria-hidden="true" />}
|
||||
Sync now
|
||||
</button>
|
||||
<button className="excel-button justify-center" type="button" onClick={onExportExcel} disabled={!entries.length || Boolean(busyAction)}>
|
||||
<FileSpreadsheet size={17} aria-hidden="true" />
|
||||
Export XLSX
|
||||
</button>
|
||||
<button className="excel-button justify-center" type="button" onClick={onImportExcel} disabled={Boolean(busyAction)}>
|
||||
<Upload size={17} aria-hidden="true" />
|
||||
Import XLSX
|
||||
</button>
|
||||
<button className="secondary-button justify-center" type="button" onClick={onExportJson} disabled={!entries.length || Boolean(busyAction)}>
|
||||
<FileJson size={17} aria-hidden="true" />
|
||||
Export JSON
|
||||
</button>
|
||||
<button className="secondary-button justify-center" type="button" onClick={onImportJson} disabled={Boolean(busyAction)}>
|
||||
<Upload size={17} aria-hidden="true" />
|
||||
Import JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-white/10 bg-white/[0.05] p-4">
|
||||
<p className="text-sm font-semibold text-white">Configured Appwrite IDs</p>
|
||||
<dl className="mt-3 grid gap-2 text-sm text-slate-300">
|
||||
<DataPair label="Endpoint" value={appwriteConfig.endpoint} />
|
||||
<DataPair label="Project" value={appwriteConfig.projectId} />
|
||||
<DataPair label="Database" value={appwriteConfig.databaseId} />
|
||||
<DataPair label="Collection" value={appwriteConfig.collectionId} />
|
||||
</dl>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<section className="app-card danger-zone p-4 sm:p-5" aria-labelledby="danger-zone-title">
|
||||
<h2 id="danger-zone-title" className="app-card-title danger-zone-title text-xl">
|
||||
Danger zone
|
||||
</h2>
|
||||
<p className="app-card-subtitle mt-1">
|
||||
Permanently delete every entry on this account. This cannot be undone.
|
||||
</p>
|
||||
<button className="danger-button mt-4 justify-center" type="button" onClick={onReset} disabled={!entries.length || Boolean(busyAction)}>
|
||||
<RotateCcw size={17} aria-hidden="true" />
|
||||
Delete all entries
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<LegalFootnote className="mt-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataPair({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[110px_1fr]">
|
||||
<dt className="text-slate-500">{label}</dt>
|
||||
<dd className="truncate font-mono text-xs" style={{ color: "var(--primary)" }}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart as RechartsLineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { currency, oneDecimal, wholeNumber } from "../../lib/metrics";
|
||||
import { AppCard } from "../AppCard";
|
||||
import { ChartTooltip } from "../ChartTooltip";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
import { FiltersPanel } from "../FiltersPanel";
|
||||
import { InsightCard } from "../InsightCard";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { SpendForecastCard } from "../SpendForecastCard";
|
||||
import type { Insight } from "../../lib/dashboard";
|
||||
import type { Filters, Flavour, RedBullEntry, UserLimits } from "../../types";
|
||||
|
||||
const CHART_HEIGHT = 300;
|
||||
const AXIS_PROPS = {
|
||||
stroke: "var(--subtle)",
|
||||
tickLine: false,
|
||||
axisLine: false,
|
||||
interval: "preserveStartEnd",
|
||||
minTickGap: 24,
|
||||
} as const;
|
||||
|
||||
type TrendsViewProps = {
|
||||
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
||||
weekData: Array<{ label: string; spend: number; cans: number }>;
|
||||
flavourData: Array<{ name: string; value: number; spend: number; accent: string }>;
|
||||
insights: Insight[];
|
||||
entries: RedBullEntry[];
|
||||
filters: Filters;
|
||||
flavours: Flavour[];
|
||||
userLimits: UserLimits;
|
||||
loading: boolean;
|
||||
onFilterChange: (filters: Filters) => void;
|
||||
onSaveLimits: (limits: UserLimits) => void;
|
||||
};
|
||||
|
||||
export function TrendsView({
|
||||
chartData,
|
||||
weekData,
|
||||
flavourData,
|
||||
insights,
|
||||
entries,
|
||||
filters,
|
||||
flavours,
|
||||
userLimits,
|
||||
loading,
|
||||
onFilterChange,
|
||||
onSaveLimits,
|
||||
}: TrendsViewProps) {
|
||||
const showSkeleton = loading && entries.length === 0;
|
||||
const totalSpend = chartData.reduce((total, point) => total + point.spend, 0);
|
||||
const totalCans = chartData.reduce((total, point) => total + point.cans, 0);
|
||||
const totalCaffeine = chartData.reduce((total, point) => total + point.caffeine, 0);
|
||||
const weekSpend = weekData.reduce((total, point) => total + point.spend, 0);
|
||||
const weekCans = weekData.reduce((total, point) => total + point.cans, 0);
|
||||
|
||||
const cansSpendLabel = `Spend and cans over ${chartData.length} logged days, total ${currency.format(totalSpend)} and ${oneDecimal.format(totalCans)} cans`;
|
||||
const caffeineLabel = `Estimated caffeine per day over ${chartData.length} logged days, total ${wholeNumber.format(totalCaffeine)} milligrams`;
|
||||
const weekLabel = `Weekly spend and cans across ${weekData.length} weeks, total ${currency.format(weekSpend)} and ${oneDecimal.format(weekCans)} cans`;
|
||||
const flavourLabel = flavourData.length
|
||||
? `Cans by flavour across ${flavourData.length} flavours. Most logged: ${flavourData[0].name}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<section className="logbook-layout grid gap-4">
|
||||
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} compact />
|
||||
<AppCard title="Cans and spend" subtitle={`${entries.length} entries in view`}>
|
||||
{showSkeleton ? (
|
||||
<Skeleton className="h-[300px]" />
|
||||
) : chartData.length ? (
|
||||
<div role="img" aria-label={cansSpendLabel}>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||
<AreaChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||
<defs>
|
||||
<linearGradient id="trendSpend" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#39d5ff" stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor="#39d5ff" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="trendCans" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ff3448" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#ff3448" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Area type="monotone" dataKey="spend" name="Spend" stroke="#39d5ff" fill="url(#trendSpend)" strokeWidth={3} />
|
||||
<Area type="monotone" dataKey="cans" name="Cans" stroke="#ff3448" fill="url(#trendCans)" strokeWidth={3} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="No trend data" copy="Filtered chart data appears here." />
|
||||
)}
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<AppCard title="Caffeine by day" subtitle="Estimated mg">
|
||||
{showSkeleton ? (
|
||||
<Skeleton className="h-[300px]" />
|
||||
) : chartData.length ? (
|
||||
<div role="img" aria-label={caffeineLabel}>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||
<BarChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Bar dataKey="caffeine" name="Caffeine" fill="#39d5ff" radius={[8, 8, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="No caffeine data" copy="Add entries to estimate caffeine over time." />
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Weekly comparison" subtitle="Spend and cans">
|
||||
{showSkeleton ? (
|
||||
<Skeleton className="h-[300px]" />
|
||||
) : weekData.length ? (
|
||||
<div role="img" aria-label={weekLabel}>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||
<RechartsLineChart data={weekData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Line type="monotone" dataKey="spend" name="Spend" stroke="#ffd84d" strokeWidth={3} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="cans" name="Cans" stroke="#ffb7d9" strokeWidth={3} dot={{ r: 3 }} />
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="No weekly comparison" copy="Weekly comparisons appear as your history grows." />
|
||||
)}
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
|
||||
<AppCard title="Flavour split" subtitle="Cans by flavour">
|
||||
{flavourData.length ? (
|
||||
<div role="img" aria-label={flavourLabel}>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||
<PieChart>
|
||||
<Pie data={flavourData} dataKey="value" nameKey="name" innerRadius={76} outerRadius={118} paddingAngle={4} stroke="#080d1f" strokeWidth={4}>
|
||||
{flavourData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.accent} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="No flavour split" copy="Entries will form a flavour mix here." />
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-3 xl:grid-cols-1">
|
||||
{insights.map((insight) => (
|
||||
<InsightCard key={insight.label} insight={insight} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4">
|
||||
<SpendForecastCard
|
||||
entries={entries}
|
||||
userLimits={userLimits}
|
||||
onSaveLimits={onSaveLimits}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<LegalFootnote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const MATERIAL_ACCENTS = {
|
||||
primary: "var(--chart-primary)",
|
||||
secondary: "var(--chart-secondary)",
|
||||
tertiary: "var(--chart-tertiary)",
|
||||
error: "var(--chart-error)",
|
||||
custom: "#b85d84",
|
||||
};
|
||||
+17
-12
@@ -1,20 +1,25 @@
|
||||
import type { Flavour } from "../types";
|
||||
|
||||
export const BUILT_IN_FLAVOURS: Flavour[] = [
|
||||
{ name: "Original", accent: "#00A7FF" },
|
||||
{ name: "Sugar Free", accent: "#E7EEF8", sugarFree: true },
|
||||
{ name: "Ruby", accent: "#C3093B" },
|
||||
{ name: "Iced Vanilla", accent: "#49adbe" },
|
||||
{ name: "Tropical", accent: "#FFC247" },
|
||||
{ name: "Watermelon", accent: "#FF355E" },
|
||||
{ name: "Original", accent: "#282874" },
|
||||
{ name: "Zero", accent: "#B1D0EE", sugarFree: true },
|
||||
{ name: "Sugar Free", accent: "#009EDF", sugarFree: true },
|
||||
{ name: "Ruby", accent: "#B50045" },
|
||||
{ name: "Iced Vanilla", accent: "#53B2C2" },
|
||||
{ name: "Tropical", accent: "#FFCB04" },
|
||||
{ name: "Cherry Edition", accent: "#D81B60" },
|
||||
{ name: "Apricot Edition", accent: "#F3911B" },
|
||||
{ name: "Lilac Sugarfree", accent: "#7D62CE", sugarFree: true },
|
||||
{ name: "Pink Sugarfree", accent: "#E77BAB", sugarFree: true },
|
||||
{ name: "Watermelon", accent: "#E6301F" },
|
||||
{ name: "Blueberry", accent: "#496DFF" },
|
||||
{ name: "Coconut Berry", accent: "#D8F9FF" },
|
||||
{ name: "Peach", accent: "#FF9B63" },
|
||||
{ name: "Juneberry", accent: "#9C73FF" },
|
||||
{ name: "Coconut Berry", accent: "#0070B8" },
|
||||
{ name: "Peach", accent: "#E24585" },
|
||||
{ name: "Juneberry", accent: "#0085C8" },
|
||||
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
||||
{ name: "Curuba Elderflower", accent: "#B7FF4A" },
|
||||
{ name: "Winter Edition", accent: "#7CE7FF" },
|
||||
{ name: "Summer Edition", accent: "#f0e53b" },
|
||||
{ name: "Curuba Elderflower", accent: "#78B941" },
|
||||
{ name: "Winter Edition", accent: "#BF1431" },
|
||||
{ name: "Summer Edition", accent: "#F2E853" },
|
||||
{ name: "Other", accent: "#AEB9C7" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export const QUICK_ADDS = [
|
||||
{ label: "Original", flavour: "Original", sizeMl: 250, pricePerCan: 1.75 },
|
||||
{ label: "Sugar Free", flavour: "Sugar Free", sizeMl: 250, pricePerCan: 1.75 },
|
||||
{ label: "Iced Vanilla", flavour: "Iced Vanilla", sizeMl: 250, pricePerCan: 1.75 },
|
||||
{ label: "473ml Original", flavour: "Original", sizeMl: 473, pricePerCan: 2.85 },
|
||||
];
|
||||
|
||||
export type QuickAddItem = (typeof QUICK_ADDS)[number];
|
||||
+197
-110
@@ -1,144 +1,231 @@
|
||||
import { buildThemeTokens, type ThemeSeed, type ThemeTokens } from "../lib/themeTokens";
|
||||
|
||||
export type ThemeCategory = "vocaloid" | "flavour" | "sugarfree";
|
||||
|
||||
export type AppTheme = {
|
||||
id: string;
|
||||
label: string;
|
||||
category: ThemeCategory;
|
||||
swatch: string;
|
||||
seed: ThemeSeed;
|
||||
tokens: ThemeTokens;
|
||||
};
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v2";
|
||||
export const OLD_THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
||||
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
||||
export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1";
|
||||
export const THEME_MODE_STORAGE_KEY = "red-bull-intake-tracker.theme-mode.v1";
|
||||
export const DEFAULT_THEME_ID = "mist";
|
||||
export const DEFAULT_THEME_ID = "oura-mist";
|
||||
|
||||
const OLD_THEME_MAP: Record<string, string> = {
|
||||
// old theme ids can rot quietly
|
||||
[`${"ou"}${"ra"}-mist`]: "mist",
|
||||
[`${"mi"}${"ku"}-blue`]: "aqua",
|
||||
[`${"te"}${"to"}-red`]: "signal-red",
|
||||
"pastel-pink": "soft-pink",
|
||||
original: "aqua",
|
||||
zero: "mist",
|
||||
summer: "soft-pink",
|
||||
cherry: "signal-red",
|
||||
spring: "soft-pink",
|
||||
apple: "mist",
|
||||
peach: "soft-pink",
|
||||
ice: "aqua",
|
||||
"blue-edition": "aqua",
|
||||
"red-edition": "signal-red",
|
||||
tropical: "soft-pink",
|
||||
coconut: "aqua",
|
||||
"green-edition": "mist",
|
||||
apricot: "soft-pink",
|
||||
ruby: "signal-red",
|
||||
sugarfree: "mist",
|
||||
"sf-summer": "soft-pink",
|
||||
"sf-apple": "mist",
|
||||
"sf-peach": "soft-pink",
|
||||
"sf-ice": "aqua",
|
||||
"sf-lilac": "mist",
|
||||
"sf-pink": "soft-pink",
|
||||
"sf-blue": "aqua",
|
||||
"sf-coconut": "aqua",
|
||||
"sf-green": "mist",
|
||||
"sf-ruby": "signal-red",
|
||||
"sf-spring": "soft-pink",
|
||||
pink: "soft-pink",
|
||||
blue: "aqua",
|
||||
const LEGACY_ACCENT_MAP: Record<string, string> = {
|
||||
pink: "oura-mist",
|
||||
blue: "oura-mist",
|
||||
};
|
||||
|
||||
function theme(id: string, label: string, swatch: string, seed: ThemeSeed): AppTheme {
|
||||
return { id, label, swatch, seed, tokens: buildThemeTokens(seed) };
|
||||
function theme(id: string, label: string, category: ThemeCategory, swatch: string, seed: ThemeSeed): AppTheme {
|
||||
return { id, label, category, swatch, tokens: buildThemeTokens(seed) };
|
||||
}
|
||||
|
||||
export const APP_THEMES: AppTheme[] = [
|
||||
theme("mist", "Mist", "#2563c7", {
|
||||
primary: "#2563c7",
|
||||
theme("oura-mist", "Oura Mist", "vocaloid", "#4b86ad", {
|
||||
primary: "#4b86ad",
|
||||
tokens: {
|
||||
primary: "#2563c7",
|
||||
primaryContainer: "#dbe9ff",
|
||||
onPrimaryContainer: "#10243f",
|
||||
bg: "#eef3fb",
|
||||
surface: "#eef3fb",
|
||||
surfaceContainerLowest: "#ffffff",
|
||||
surfaceContainerLow: "#f7faff",
|
||||
surfaceContainer: "#ffffff",
|
||||
surfaceContainerHigh: "#eef4ff",
|
||||
outline: "#c7d2e2",
|
||||
outlineVariant: "#dce5f1",
|
||||
text: "#202124",
|
||||
muted: "#5f6670",
|
||||
subtle: "#6f7782",
|
||||
chartPrimary: "#2563c7",
|
||||
chartSecondary: "#00897b",
|
||||
chartTertiary: "#b85d1f",
|
||||
primary: "#4b86ad",
|
||||
primaryContainer: "#dff2ff",
|
||||
onPrimaryContainer: "#10283a",
|
||||
chartPrimary: "#4b86ad",
|
||||
chartSecondary: "#6f8f7c",
|
||||
chartTertiary: "#9b7b51",
|
||||
},
|
||||
}),
|
||||
theme("aqua", "Aqua", "#007f73", {
|
||||
primary: "#007f73",
|
||||
secondary: "#0b6f9f",
|
||||
tertiary: "#7a5bbd",
|
||||
theme("miku-blue", "Miku Blue", "vocaloid", "#39c5bb", {
|
||||
primary: "#39c5bb",
|
||||
secondary: "#39d5ff",
|
||||
tertiary: "#7ce7ff",
|
||||
}),
|
||||
theme("signal-red", "Signal red", "#b3261e", {
|
||||
primary: "#b3261e",
|
||||
secondary: "#7d5fff",
|
||||
tertiary: "#126e82",
|
||||
theme("teto-red", "Teto Red", "vocaloid", "#fe0404", {
|
||||
primary: "#fe0404",
|
||||
secondary: "#ff3448",
|
||||
tertiary: "#ff6b6b",
|
||||
}),
|
||||
theme("soft-pink", "Soft pink", "#a83f73", {
|
||||
primary: "#a83f73",
|
||||
secondary: "#2563c7",
|
||||
tertiary: "#8a6b10",
|
||||
theme("pastel-pink", "Pastel Pink", "vocaloid", "#ffb7d9", {
|
||||
primary: "#e07aa8",
|
||||
secondary: "#ffb7d9",
|
||||
tertiary: "#ffd8e7",
|
||||
}),
|
||||
|
||||
theme("original", "Original", "flavour", "#282874", {
|
||||
primary: "#282874",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d4af37",
|
||||
tokens: {
|
||||
chartSecondary: "#e6301f",
|
||||
},
|
||||
}),
|
||||
theme("zero", "Zero", "flavour", "#b1d0ee", {
|
||||
primary: "#b1d0ee",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#e6301f",
|
||||
}),
|
||||
theme("summer", "Summer Edition", "flavour", "#f0e53b", {
|
||||
primary: "#f2e853",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#8a8f98",
|
||||
}),
|
||||
theme("cherry", "Cherry Edition", "flavour", "#d81b60", {
|
||||
primary: "#d81b60",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#b50045",
|
||||
}),
|
||||
theme("spring", "Spring Edition", "flavour", "#ff8fab", {
|
||||
primary: "#e85d8a",
|
||||
secondary: "#ffb3c6",
|
||||
tertiary: "#ffd8e7",
|
||||
}),
|
||||
theme("apple", "Apple Edition", "flavour", "#bf1431", {
|
||||
primary: "#bf1431",
|
||||
secondary: "#f6c300",
|
||||
tertiary: "#f3911b",
|
||||
}),
|
||||
theme("peach", "Peach Edition", "flavour", "#e24585", {
|
||||
primary: "#e24585",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d6417e",
|
||||
}),
|
||||
theme("ice", "Ice Edition", "flavour", "#49adbe", {
|
||||
primary: "#53b2c2",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#49adbe",
|
||||
}),
|
||||
theme("blue-edition", "Blue Edition", "flavour", "#0085c8", {
|
||||
primary: "#0085c8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#ff73d1",
|
||||
}),
|
||||
theme("red-edition", "Red Edition", "flavour", "#e6301f", {
|
||||
primary: "#e6301f",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#78b941",
|
||||
}),
|
||||
theme("tropical", "Tropical Edition", "flavour", "#ffcb04", {
|
||||
primary: "#ffcb04",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#f6c300",
|
||||
}),
|
||||
theme("coconut", "Coconut Edition", "flavour", "#0070b8", {
|
||||
primary: "#0070b8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#8a8f98",
|
||||
}),
|
||||
theme("green-edition", "Green Edition", "flavour", "#78b941", {
|
||||
primary: "#78b941",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#f3911b",
|
||||
}),
|
||||
theme("apricot", "Apricot Edition", "flavour", "#f3911b", {
|
||||
primary: "#f3911b",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#d6417e",
|
||||
}),
|
||||
theme("ruby", "Ruby Edition", "flavour", "#b50045", {
|
||||
primary: "#b50045",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#a3e635",
|
||||
}),
|
||||
|
||||
theme("sugarfree", "Sugarfree", "sugarfree", "#009edf", {
|
||||
primary: "#009edf",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#e6301f",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-summer", "Summer Sugarfree", "sugarfree", "#f0e53b", {
|
||||
primary: "#f2e853",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-apple", "Apple Sugarfree", "sugarfree", "#bf1431", {
|
||||
primary: "#bf1431",
|
||||
secondary: "#f6c300",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-peach", "Peach Sugarfree", "sugarfree", "#e24585", {
|
||||
primary: "#e24585",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-ice", "Ice Sugarfree", "sugarfree", "#49adbe", {
|
||||
primary: "#53b2c2",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-lilac", "Lilac Sugarfree", "sugarfree", "#7d62ce", {
|
||||
primary: "#7d62ce",
|
||||
secondary: "#44c7b7",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-pink", "Pink Sugarfree", "sugarfree", "#e77bab", {
|
||||
primary: "#e77bab",
|
||||
secondary: "#8a1f3d",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-blue", "Blue Sugarfree", "sugarfree", "#0085c8", {
|
||||
primary: "#0085c8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-coconut", "Coconut Sugarfree", "sugarfree", "#0070b8", {
|
||||
primary: "#0070b8",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-green", "Green Sugarfree", "sugarfree", "#78b941", {
|
||||
primary: "#78b941",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-ruby", "Ruby Sugarfree", "sugarfree", "#b50045", {
|
||||
primary: "#b50045",
|
||||
secondary: "#efefef",
|
||||
tertiary: "#009edf",
|
||||
sugarFree: true,
|
||||
}),
|
||||
theme("sf-spring", "Spring Sugarfree", "sugarfree", "#f8d0e0", {
|
||||
primary: "#d07090",
|
||||
secondary: "#f8d0e0",
|
||||
tertiary: "#ffb3c6",
|
||||
sugarFree: true,
|
||||
}),
|
||||
];
|
||||
|
||||
export const THEME_CATEGORIES: Array<{ id: ThemeCategory; label: string }> = [
|
||||
{ id: "vocaloid", label: "Vocaloid & Pink" },
|
||||
{ id: "flavour", label: "Flavours" },
|
||||
{ id: "sugarfree", label: "Sugarfree" },
|
||||
];
|
||||
|
||||
export function getThemeById(id: string): AppTheme {
|
||||
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
|
||||
}
|
||||
|
||||
export function getThemeTokens(id: string, mode: "light" | "dark"): ThemeTokens {
|
||||
const entry = getThemeById(id);
|
||||
if (mode === "light") return entry.tokens;
|
||||
|
||||
// Dark mode drops the light-only explicit overrides; chart colours that
|
||||
// still read well on dark surfaces are carried over.
|
||||
const overrides = entry.seed.tokens;
|
||||
const chartOverrides: Partial<ThemeTokens> = {};
|
||||
if (overrides?.chartPrimary) chartOverrides.chartPrimary = overrides.chartPrimary;
|
||||
if (overrides?.chartSecondary) chartOverrides.chartSecondary = overrides.chartSecondary;
|
||||
if (overrides?.chartTertiary) chartOverrides.chartTertiary = overrides.chartTertiary;
|
||||
return buildThemeTokens({ ...entry.seed, dark: true, tokens: chartOverrides });
|
||||
}
|
||||
|
||||
export function normaliseThemeMode(value: string | null | undefined): ThemeMode {
|
||||
if (value === "light" || value === "dark" || value === "system") return value;
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function readStoredThemeMode(): ThemeMode {
|
||||
if (typeof window === "undefined") return "system";
|
||||
return normaliseThemeMode(localStorage.getItem(THEME_MODE_STORAGE_KEY));
|
||||
}
|
||||
|
||||
export function normaliseThemeId(id: string | null | undefined): string {
|
||||
if (!id) return DEFAULT_THEME_ID;
|
||||
if (APP_THEMES.some((entry) => entry.id === id)) return id;
|
||||
return OLD_THEME_MAP[id] ?? DEFAULT_THEME_ID;
|
||||
}
|
||||
|
||||
export function readStoredThemeId(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
||||
|
||||
const stored = normaliseThemeId(localStorage.getItem(THEME_STORAGE_KEY));
|
||||
if (stored !== DEFAULT_THEME_ID || localStorage.getItem(THEME_STORAGE_KEY)) return stored;
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
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);
|
||||
if (legacy && LEGACY_ACCENT_MAP[legacy]) {
|
||||
return LEGACY_ACCENT_MAP[legacy];
|
||||
}
|
||||
|
||||
return normaliseThemeId(localStorage.getItem(LEGACY_ACCENT_STORAGE_KEY));
|
||||
return DEFAULT_THEME_ID;
|
||||
}
|
||||
|
||||
+1118
-2003
File diff suppressed because it is too large
Load Diff
+20
-3
@@ -1,14 +1,17 @@
|
||||
import { Account, Channel, Client, ID, Permission, Query, Role, TablesDB } from "appwrite";
|
||||
import { Account, Channel, Client, ID, OAuthProvider, Permission, Query, Role, TablesDB } from "appwrite";
|
||||
|
||||
const env = import.meta.env;
|
||||
const currentOrigin = window.location.origin;
|
||||
|
||||
export const appwriteConfig = {
|
||||
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
|
||||
projectId: env.VITE_APPWRITE_PROJECT_ID!,
|
||||
projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138",
|
||||
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
|
||||
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
|
||||
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
|
||||
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
||||
};
|
||||
|
||||
const client = new Client()
|
||||
@@ -22,4 +25,18 @@ export async function pingAppwrite() {
|
||||
return client.ping();
|
||||
}
|
||||
|
||||
export { account, Channel, client, ID, Permission, Query, Role, tablesDB };
|
||||
export { account, Channel, client, ID, OAuthProvider, Permission, Query, Role, tablesDB };
|
||||
|
||||
function resolveOAuthUrl(value?: string) {
|
||||
if (!value) return currentOrigin;
|
||||
|
||||
const configured = new URL(value, currentOrigin);
|
||||
const current = new URL(currentOrigin);
|
||||
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
if (env.DEV && localHosts.has(configured.hostname) && localHosts.has(current.hostname)) {
|
||||
return currentOrigin;
|
||||
}
|
||||
|
||||
return configured.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
@@ -59,15 +59,10 @@ export async function createEntry(userId: string, draft: EntryDraft) {
|
||||
return fromRow(row);
|
||||
}
|
||||
|
||||
export async function createEntries(
|
||||
userId: string,
|
||||
drafts: EntryDraft[],
|
||||
onProgress?: (done: number, total: number) => void,
|
||||
) {
|
||||
export async function createEntries(userId: string, drafts: EntryDraft[]) {
|
||||
const saved: RedBullEntry[] = [];
|
||||
for (const [index, draft] of drafts.entries()) {
|
||||
for (const draft of drafts) {
|
||||
saved.push(await createEntry(userId, draft));
|
||||
onProgress?.(index + 1, drafts.length);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+27
-152
@@ -4,7 +4,6 @@ import {
|
||||
BrowserMultiFormatReader,
|
||||
type IScannerControls,
|
||||
} from "@zxing/browser";
|
||||
import { ensureBarcodeDetector, isAppleMobileDevice } from "./barcodeDetectorSupport";
|
||||
import { normalizeBarcode } from "./barcodeLookup";
|
||||
|
||||
export type BarcodeScannerErrorCode =
|
||||
@@ -55,7 +54,7 @@ const ZXING_FORMATS = [
|
||||
BarcodeFormat.UPC_A,
|
||||
BarcodeFormat.UPC_E,
|
||||
];
|
||||
const PREFERRED_SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
||||
const SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode: { ideal: "environment" },
|
||||
width: { ideal: 1280 },
|
||||
@@ -63,8 +62,6 @@ const PREFERRED_SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
||||
},
|
||||
audio: false,
|
||||
};
|
||||
const IOS_NATIVE_SCAN_INTERVAL_MS = 150;
|
||||
const VIDEO_READY_TIMEOUT_MS = 10_000;
|
||||
|
||||
export async function startBarcodeScanner(
|
||||
videoElement: HTMLVideoElement,
|
||||
@@ -75,8 +72,6 @@ export async function startBarcodeScanner(
|
||||
throw toScannerError(new Error("Camera access is not supported in this browser."));
|
||||
}
|
||||
|
||||
await ensureBarcodeDetector();
|
||||
|
||||
if (await supportsNativeBarcodeDetector()) {
|
||||
try {
|
||||
return await startNativeBarcodeScanner(videoElement, onResult);
|
||||
@@ -126,15 +121,15 @@ function startNativeBarcodeScanner(
|
||||
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);
|
||||
stream = await navigator.mediaDevices.getUserMedia(SCAN_CONSTRAINTS);
|
||||
videoElement.srcObject = stream;
|
||||
videoElement.setAttribute("playsinline", "true");
|
||||
videoElement.muted = true;
|
||||
await videoElement.play();
|
||||
|
||||
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
||||
if (!Detector) {
|
||||
@@ -145,15 +140,13 @@ function startNativeBarcodeScanner(
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
window.clearTimeout(scanTimeout);
|
||||
stopVideoStream(videoElement);
|
||||
};
|
||||
|
||||
const scan = async () => {
|
||||
if (stopped || scanning) return;
|
||||
scanning = true;
|
||||
if (stopped) return;
|
||||
try {
|
||||
if (isVideoFrameReady(videoElement)) {
|
||||
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
const barcodes = await detector.detect(videoElement);
|
||||
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
|
||||
if (barcode?.rawValue) {
|
||||
@@ -163,32 +156,12 @@ function startNativeBarcodeScanner(
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep scanning; transient frame errors are common on mobile Safari.
|
||||
} finally {
|
||||
scanning = false;
|
||||
if (!stopped) animationFrame = window.requestAnimationFrame(() => void scan());
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNextScan = () => {
|
||||
if (stopped) return;
|
||||
if (isAppleMobileDevice()) {
|
||||
scanTimeout = window.setTimeout(() => {
|
||||
void scan().finally(() => {
|
||||
if (!stopped) scheduleNextScan();
|
||||
});
|
||||
}, IOS_NATIVE_SCAN_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
animationFrame = window.requestAnimationFrame(() => {
|
||||
void scan().finally(() => {
|
||||
if (!stopped) scheduleNextScan();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
scheduleNextScan();
|
||||
|
||||
animationFrame = window.requestAnimationFrame(() => void scan());
|
||||
resolve({ mode: "native", stop });
|
||||
} catch (error) {
|
||||
if (stream) stream.getTracks().forEach((track) => track.stop());
|
||||
@@ -205,28 +178,26 @@ async function startZxingBarcodeScanner(
|
||||
onResult: (result: BarcodeScanResult) => void,
|
||||
onError: (error: BarcodeScannerError) => void,
|
||||
): Promise<BarcodeScannerController> {
|
||||
const reader = new BrowserMultiFormatReader(undefined, {
|
||||
delayBetweenScanAttempts: isAppleMobileDevice() ? 150 : 500,
|
||||
});
|
||||
const reader = new BrowserMultiFormatReader();
|
||||
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));
|
||||
}
|
||||
});
|
||||
const controls = await reader.decodeFromConstraints(
|
||||
SCAN_CONSTRAINTS,
|
||||
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",
|
||||
@@ -258,102 +229,6 @@ async function supportsNativeBarcodeDetector() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getCameraStream() {
|
||||
const attempts: MediaStreamConstraints[] = [
|
||||
PREFERRED_SCAN_CONSTRAINTS,
|
||||
{ video: { facingMode: { ideal: "environment" } }, audio: false },
|
||||
{ video: { facingMode: "environment" }, audio: false },
|
||||
{ video: true, audio: false },
|
||||
];
|
||||
|
||||
let lastError: unknown;
|
||||
for (const constraints of attempts) {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isCameraAccessError(error) && !(error instanceof DOMException && error.name === "OverconstrainedError")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Could not access the camera.");
|
||||
}
|
||||
|
||||
function prepareVideoElement(videoElement: HTMLVideoElement, stream: MediaStream) {
|
||||
videoElement.srcObject = stream;
|
||||
videoElement.setAttribute("playsinline", "true");
|
||||
videoElement.setAttribute("webkit-playsinline", "true");
|
||||
videoElement.setAttribute("autoplay", "true");
|
||||
videoElement.muted = true;
|
||||
}
|
||||
|
||||
function isVideoFrameReady(videoElement: HTMLVideoElement) {
|
||||
return videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && videoElement.videoWidth > 0;
|
||||
}
|
||||
|
||||
async function waitForVideoReady(videoElement: HTMLVideoElement) {
|
||||
if (isVideoFrameReady(videoElement)) {
|
||||
await playVideoElement(videoElement);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const settle = (action: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
action();
|
||||
};
|
||||
|
||||
const tryReady = () => {
|
||||
if (!isVideoFrameReady(videoElement)) return false;
|
||||
settle(() => {
|
||||
void playVideoElement(videoElement).then(resolve).catch(reject);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const onReady = () => {
|
||||
tryReady();
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
settle(() => reject(new Error("Camera preview failed to start.")));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
videoElement.removeEventListener("loadedmetadata", onReady);
|
||||
videoElement.removeEventListener("loadeddata", onReady);
|
||||
videoElement.removeEventListener("error", onError);
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
settle(() => reject(new Error("Camera preview timed out.")));
|
||||
}, VIDEO_READY_TIMEOUT_MS);
|
||||
|
||||
videoElement.addEventListener("loadedmetadata", onReady);
|
||||
videoElement.addEventListener("loadeddata", onReady);
|
||||
videoElement.addEventListener("error", onError, { once: true });
|
||||
|
||||
tryReady();
|
||||
});
|
||||
}
|
||||
|
||||
async function playVideoElement(videoElement: HTMLVideoElement) {
|
||||
try {
|
||||
await videoElement.play();
|
||||
} catch (error) {
|
||||
if (videoElement.paused) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isCameraAccessError(error: unknown) {
|
||||
if (!(error instanceof DOMException)) return false;
|
||||
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);
|
||||
|
||||
@@ -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,103 +0,0 @@
|
||||
import {
|
||||
caffeineFor,
|
||||
currency,
|
||||
currentStreak,
|
||||
daysSinceLast,
|
||||
entriesInRange,
|
||||
highestAveragePrice,
|
||||
oneDecimal,
|
||||
spendFor,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
sugarFor,
|
||||
sum,
|
||||
topByCans,
|
||||
trackedWeeks,
|
||||
wholeNumber,
|
||||
} from "./metrics";
|
||||
import type { RedBullEntry } from "../types";
|
||||
|
||||
export type Dashboard = ReturnType<typeof buildDashboard>;
|
||||
export type Insight = ReturnType<typeof buildInsights>[number];
|
||||
|
||||
export function buildDashboard(entries: RedBullEntry[]) {
|
||||
const now = new Date();
|
||||
const todayStart = startOfDay(now);
|
||||
const tomorrow = new Date(todayStart.getTime() + 86_400_000 - 1);
|
||||
const weekStart = startOfWeek(now);
|
||||
const monthStart = startOfMonth(now);
|
||||
const todayEntries = entriesInRange(entries, todayStart, tomorrow);
|
||||
const weekEntries = entriesInRange(entries, weekStart, now);
|
||||
const monthEntries = entriesInRange(entries, monthStart, now);
|
||||
const totalSpend = sum(entries, spendFor);
|
||||
const priceyFlavour = highestAveragePrice(entries, "flavour");
|
||||
const priceyStore = highestAveragePrice(entries, "store");
|
||||
|
||||
return {
|
||||
todayCans: oneDecimal.format(sum(todayEntries, (entry) => entry.cans)),
|
||||
weekCans: `${oneDecimal.format(sum(weekEntries, (entry) => entry.cans))} cans`,
|
||||
monthCans: oneDecimal.format(sum(monthEntries, (entry) => entry.cans)),
|
||||
allTimeCans: oneDecimal.format(sum(entries, (entry) => entry.cans)),
|
||||
totalSpend: currency.format(totalSpend),
|
||||
monthSpend: currency.format(sum(monthEntries, spendFor)),
|
||||
avgWeeklySpend: `${currency.format(totalSpend / trackedWeeks(entries))}`,
|
||||
todayCaffeine: `${wholeNumber.format(sum(todayEntries, caffeineFor))}mg`,
|
||||
monthCaffeine: `${wholeNumber.format(sum(monthEntries, caffeineFor))}mg`,
|
||||
todaySugar: `${oneDecimal.format(sum(todayEntries, sugarFor))}g`,
|
||||
monthSugar: `${oneDecimal.format(sum(monthEntries, sugarFor))}g`,
|
||||
favouriteFlavour: topByCans(entries),
|
||||
priciestFlavour: priceyFlavour ? `${priceyFlavour.label} ${currency.format(priceyFlavour.average)}` : "None yet",
|
||||
priciestStore: priceyStore ? `${priceyStore.label} ${currency.format(priceyStore.average)}` : "No store yet",
|
||||
currentStreak: `${currentStreak(entries)}`,
|
||||
daysWithoutRedBull: `${daysSinceLast(entries)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInsights(entries: RedBullEntry[]) {
|
||||
const now = new Date();
|
||||
const weekStart = startOfWeek(now);
|
||||
const previousWeekStart = new Date(weekStart.getTime() - 7 * 86_400_000);
|
||||
const previousWeekEnd = new Date(weekStart.getTime() - 1);
|
||||
const monthStart = startOfMonth(now);
|
||||
const previousMonthStart = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1);
|
||||
const previousMonthEnd = new Date(monthStart.getTime() - 1);
|
||||
|
||||
const thisMonthSpend = sum(entriesInRange(entries, monthStart, now), spendFor);
|
||||
const lastMonthSpend = sum(entriesInRange(entries, previousMonthStart, previousMonthEnd), spendFor);
|
||||
const thisWeekCans = sum(entriesInRange(entries, weekStart, now), (entry) => entry.cans);
|
||||
const lastWeekCans = sum(entriesInRange(entries, previousWeekStart, previousWeekEnd), (entry) => entry.cans);
|
||||
const sugarFreeCans = sum(entries.filter((entry) => entry.sugarFree), (entry) => entry.cans);
|
||||
const allCans = sum(entries, (entry) => entry.cans);
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Month spend",
|
||||
value: `You spent ${currency.format(thisMonthSpend)} this month`,
|
||||
detail:
|
||||
lastMonthSpend > 0
|
||||
? `${comparisonCopy(thisMonthSpend, lastMonthSpend, "vs last month")}`
|
||||
: "No previous-month baseline yet.",
|
||||
},
|
||||
{
|
||||
label: "Weekly pace",
|
||||
value: `${oneDecimal.format(thisWeekCans)} cans this week`,
|
||||
detail:
|
||||
lastWeekCans > 0
|
||||
? `${comparisonCopy(thisWeekCans, lastWeekCans, "vs last week")}`
|
||||
: "The weekly comparator wakes up after another week of data.",
|
||||
},
|
||||
{
|
||||
label: "Zero sugar mix",
|
||||
value: allCans ? `${oneDecimal.format((sugarFreeCans / allCans) * 100)}% sugar-free` : "No mix yet",
|
||||
detail: allCans ? `${oneDecimal.format(sugarFreeCans)} of ${oneDecimal.format(allCans)} cans flagged sugar-free.` : "Log a sugar-free entry to track the split.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function comparisonCopy(current: number, previous: number, suffix: string) {
|
||||
const difference = current - previous;
|
||||
const percent = previous === 0 ? 0 : (difference / previous) * 100;
|
||||
const direction = difference >= 0 ? "up" : "down";
|
||||
return `${direction} ${oneDecimal.format(Math.abs(percent))}% ${suffix}`;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { RedBullEntry } from "../types";
|
||||
|
||||
const ENTRY_CACHE_KEY_PREFIX = "red-bull-intake-tracker.entries.v1.";
|
||||
|
||||
function entryCacheKey(userId: string): string {
|
||||
return `${ENTRY_CACHE_KEY_PREFIX}${userId}`;
|
||||
}
|
||||
|
||||
// Best-effort read-through cache: entries persist per user so the app can
|
||||
// render the last synced state while a network refresh is still in flight.
|
||||
|
||||
export function readCachedEntries(userId: string): RedBullEntry[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(entryCacheKey(userId));
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const entries = parsed.filter(isEntryShape);
|
||||
return entries.length ? entries : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedEntries(userId: string, entries: RedBullEntry[]): void {
|
||||
try {
|
||||
localStorage.setItem(entryCacheKey(userId), JSON.stringify(entries));
|
||||
} catch {
|
||||
// Quota and serialization failures are non-fatal; the cache is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCachedEntries(userId: string): void {
|
||||
try {
|
||||
localStorage.removeItem(entryCacheKey(userId));
|
||||
} catch {
|
||||
// Storage failures are non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
function isEntryShape(value: unknown): value is RedBullEntry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entry = value as Partial<RedBullEntry>;
|
||||
return (
|
||||
typeof entry.id === "string" &&
|
||||
typeof entry.dateTime === "string" &&
|
||||
typeof entry.flavour === "string" &&
|
||||
typeof entry.cans === "number" &&
|
||||
typeof entry.sizeMl === "number" &&
|
||||
typeof entry.pricePerCan === "number"
|
||||
);
|
||||
}
|
||||
+1
-3
@@ -1,4 +1,4 @@
|
||||
import type ExcelJS from "exceljs";
|
||||
import ExcelJS from "exceljs";
|
||||
import { flavourMeta } from "../data/flavours";
|
||||
import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types";
|
||||
import {
|
||||
@@ -39,7 +39,6 @@ const ENTRY_COLUMNS = [
|
||||
] as const;
|
||||
|
||||
export async function createExcelExport(entries: RedBullEntry[]) {
|
||||
const { default: ExcelJS } = await import("exceljs");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = "Red Bull Intake Tracker";
|
||||
workbook.created = new Date();
|
||||
@@ -54,7 +53,6 @@ export async function createExcelExport(entries: RedBullEntry[]) {
|
||||
}
|
||||
|
||||
export async function parseExcelImport(file: File, existingEntries: RedBullEntry[]): Promise<ImportPreview> {
|
||||
const { default: ExcelJS } = await import("exceljs");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(await file.arrayBuffer());
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { startOfDay, startOfMonth, startOfWeek } from "./metrics";
|
||||
import type { Filters, RedBullEntry } from "../types";
|
||||
|
||||
export const DEFAULT_FILTERS: Filters = {
|
||||
flavour: "all",
|
||||
dateRange: "all",
|
||||
store: "",
|
||||
from: "",
|
||||
to: "",
|
||||
};
|
||||
|
||||
export function applyFilters(entries: RedBullEntry[], filters: Filters) {
|
||||
const now = new Date();
|
||||
let start: Date | null = null;
|
||||
let end: Date | null = null;
|
||||
|
||||
if (filters.dateRange === "today") {
|
||||
start = startOfDay(now);
|
||||
end = new Date(start.getTime() + 86_400_000 - 1);
|
||||
}
|
||||
if (filters.dateRange === "week") {
|
||||
start = startOfWeek(now);
|
||||
end = now;
|
||||
}
|
||||
if (filters.dateRange === "month") {
|
||||
start = startOfMonth(now);
|
||||
end = now;
|
||||
}
|
||||
if (filters.dateRange === "custom") {
|
||||
start = filters.from ? new Date(`${filters.from}T00:00:00`) : null;
|
||||
end = filters.to ? new Date(`${filters.to}T23:59:59`) : null;
|
||||
}
|
||||
|
||||
return entries.filter((entry) => {
|
||||
const date = new Date(entry.dateTime);
|
||||
const flavourMatch = filters.flavour === "all" || entry.flavour === filters.flavour;
|
||||
const storeMatch = !filters.store || entry.store?.toLowerCase().includes(filters.store.toLowerCase());
|
||||
const startMatch = !start || date >= start;
|
||||
const endMatch = !end || date <= end;
|
||||
return flavourMatch && storeMatch && startMatch && endMatch;
|
||||
});
|
||||
}
|
||||
+13
-8
@@ -20,7 +20,12 @@ type GreetingResult = {
|
||||
};
|
||||
|
||||
export function getBstHour(date = new Date()) {
|
||||
return date.getHours();
|
||||
const hour = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Europe/London",
|
||||
hour: "numeric",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
return Number.parseInt(hour, 10);
|
||||
}
|
||||
|
||||
export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
||||
@@ -37,7 +42,7 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
||||
if (cans === 0) {
|
||||
headline =
|
||||
streak > 0
|
||||
? `${input.name}, nothing logged yet today. ${streak}-day streak still alive.`
|
||||
? `${input.name}, nothing logged yet today — ${streak}-day streak still alive.`
|
||||
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
|
||||
} else if (cans === 1) {
|
||||
headline = `${input.name}, one Red Bull in so far today.`;
|
||||
@@ -45,14 +50,14 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
||||
if (cans >= input.dailyCanLimit) {
|
||||
headline = `${input.name}, you're at your ${input.dailyCanLimit}-can daily limit.`;
|
||||
} else if (cans >= input.dailyCanLimit - 1) {
|
||||
headline = `${input.name}, ${cans} Red Bulls today. One under your limit.`;
|
||||
headline = `${input.name}, ${cans} Red Bulls today — one under your limit.`;
|
||||
} else {
|
||||
headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
|
||||
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
||||
}
|
||||
} else if (cans <= 3) {
|
||||
headline = `${input.name}, ${cans} Red Bulls today. Steady pace.`;
|
||||
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
||||
} else {
|
||||
headline = `${input.name}, ${cans} Red Bulls today. Worth watching the caffeine curve.`;
|
||||
headline = `${input.name}, ${cans} Red Bulls today — worth watching the caffeine curve.`;
|
||||
}
|
||||
|
||||
const flavourLine = favourite
|
||||
@@ -71,9 +76,9 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
||||
(cans > 0 && input.todayCaffeineMg > 0
|
||||
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
|
||||
: hour >= 17 && cans === 0
|
||||
? "Evening reset. Clean slate if you want it."
|
||||
? "Evening reset — clean slate if you want it."
|
||||
: hour >= 22
|
||||
? "Late night. Pace yourself if you're still going."
|
||||
? "Late night — pace yourself if you're still going."
|
||||
: "Log an intake to unlock today's signals.");
|
||||
|
||||
const limitLine =
|
||||
|
||||
+1
-28
@@ -1,4 +1,4 @@
|
||||
import type { BuiltInSize, EntryDerived, RedBullEntry } from "../types";
|
||||
import type { RedBullEntry } from "../types";
|
||||
|
||||
export const CAFFEINE_PER_250ML = 80;
|
||||
export const SUGAR_PER_250ML = 27;
|
||||
@@ -8,21 +8,6 @@ export const STANDARD_CAN_VALUES = {
|
||||
473: { pricePerCan: 2.85, caffeineMg: 151 },
|
||||
} 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) {
|
||||
return entry.cans * entry.pricePerCan;
|
||||
}
|
||||
@@ -53,18 +38,6 @@ export function sugarFor(entry: RedBullEntry) {
|
||||
return entry.cans * (entry.sizeMl / 250) * SUGAR_PER_250ML;
|
||||
}
|
||||
|
||||
export function buildEntryDerived(entries: RedBullEntry[]) {
|
||||
const derived = new Map<string, EntryDerived>();
|
||||
entries.forEach((entry) => {
|
||||
derived.set(entry.id, {
|
||||
spend: spendFor(entry),
|
||||
caffeine: caffeineFor(entry),
|
||||
sugar: sugarFor(entry),
|
||||
});
|
||||
});
|
||||
return derived;
|
||||
}
|
||||
|
||||
export function startOfDay(date: Date) {
|
||||
const next = new Date(date);
|
||||
next.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Transition } from "framer-motion";
|
||||
|
||||
// Standard ease-out curve shared by every animated surface (A6 motion language).
|
||||
export const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
|
||||
|
||||
export const VIEW_TRANSITION: Transition = { duration: 0.2, ease: EASE_OUT };
|
||||
export const MODAL_TRANSITION: Transition = { duration: 0.22, ease: EASE_OUT };
|
||||
export const TOAST_TRANSITION: Transition = { duration: 0.18, ease: EASE_OUT };
|
||||
export const TILE_TRANSITION: Transition = { duration: 0.22, ease: EASE_OUT };
|
||||
+1
-28
@@ -1,6 +1,5 @@
|
||||
import { flavourMeta } from "../data/flavours";
|
||||
import { makeImportKey } from "./metrics";
|
||||
import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types";
|
||||
import type { EntryDraft, RedBullEntry } from "../types";
|
||||
|
||||
export function exportPayload(entries: RedBullEntry[]) {
|
||||
return JSON.stringify(
|
||||
@@ -29,32 +28,6 @@ export function parseImport(raw: string): EntryDraft[] {
|
||||
return valid;
|
||||
}
|
||||
|
||||
export function buildJsonImportPreview(
|
||||
drafts: EntryDraft[],
|
||||
existingEntries: RedBullEntry[],
|
||||
fileName: string,
|
||||
): ImportPreview {
|
||||
const seen = new Set(existingEntries.map((entry) => entry.importKey || makeImportKey(entry)));
|
||||
const rows: ImportPreviewRow[] = drafts.map((draft, index) => {
|
||||
const key = makeImportKey({
|
||||
...draft,
|
||||
dateTime: new Date(draft.dateTime).toISOString(),
|
||||
notes: draft.notes ?? "",
|
||||
store: draft.store ?? "",
|
||||
});
|
||||
const duplicate = seen.has(key);
|
||||
if (!duplicate) seen.add(key);
|
||||
return {
|
||||
rowNumber: index + 1,
|
||||
entry: draft,
|
||||
errors: [],
|
||||
duplicate,
|
||||
duplicateReason: duplicate ? "Matches an existing or earlier imported row." : undefined,
|
||||
};
|
||||
});
|
||||
return { fileName, rows };
|
||||
}
|
||||
|
||||
function coerceEntryDraft(value: unknown): EntryDraft | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const entry = value as Partial<RedBullEntry>;
|
||||
|
||||
@@ -17,14 +17,6 @@ export type ThemeTokens = {
|
||||
onError: string;
|
||||
errorContainer: string;
|
||||
onErrorContainer: string;
|
||||
warning: string;
|
||||
onWarning: string;
|
||||
warningContainer: string;
|
||||
onWarningContainer: string;
|
||||
success: string;
|
||||
onSuccess: string;
|
||||
successContainer: string;
|
||||
onSuccessContainer: string;
|
||||
bg: string;
|
||||
surface: string;
|
||||
surfaceContainerLowest: string;
|
||||
@@ -168,10 +160,6 @@ export function buildThemeTokens(seed: ThemeSeed): ThemeTokens {
|
||||
const tertiaryContainer = containerColor(tertiary);
|
||||
const error = "#ba1a1a";
|
||||
const errorContainer = "#ffdad6";
|
||||
const warning = dark ? "#fbbc04" : "#8d5700";
|
||||
const warningContainer = dark ? "#594400" : "#ffddb0";
|
||||
const success = dark ? "#81c995" : "#0d652d";
|
||||
const successContainer = dark ? "#0f5223" : "#ceead6";
|
||||
|
||||
const tokens: ThemeTokens = {
|
||||
primary,
|
||||
@@ -190,14 +178,6 @@ export function buildThemeTokens(seed: ThemeSeed): ThemeTokens {
|
||||
onError: "#ffffff",
|
||||
errorContainer,
|
||||
onErrorContainer: "#410002",
|
||||
warning,
|
||||
onWarning: dark ? "#3a2a00" : "#ffffff",
|
||||
warningContainer,
|
||||
onWarningContainer: dark ? "#ffe1a6" : "#291800",
|
||||
success,
|
||||
onSuccess: dark ? "#07391c" : "#ffffff",
|
||||
successContainer,
|
||||
onSuccessContainer: dark ? "#ceead6" : "#03210f",
|
||||
bg: surfaces.bg,
|
||||
surface: surfaces.surface,
|
||||
surfaceContainerLowest: surfaces.surfaceContainerLowest,
|
||||
@@ -240,14 +220,6 @@ export function themeTokensToStyle(tokens: ThemeTokens): CSSProperties {
|
||||
"--on-error": tokens.onError,
|
||||
"--error-container": tokens.errorContainer,
|
||||
"--on-error-container": tokens.onErrorContainer,
|
||||
"--warning": tokens.warning,
|
||||
"--on-warning": tokens.onWarning,
|
||||
"--warning-container": tokens.warningContainer,
|
||||
"--on-warning-container": tokens.onWarningContainer,
|
||||
"--success": tokens.success,
|
||||
"--on-success": tokens.onSuccess,
|
||||
"--success-container": tokens.successContainer,
|
||||
"--on-success-container": tokens.onSuccessContainer,
|
||||
"--bg": tokens.bg,
|
||||
"--surface": tokens.surface,
|
||||
"--surface-container-lowest": tokens.surfaceContainerLowest,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type ToastTone = "success" | "error" | "info";
|
||||
|
||||
export type ToastInput = {
|
||||
tone: ToastTone;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ToastContextValue = {
|
||||
pushToast: (toast: ToastInput) => void;
|
||||
};
|
||||
|
||||
export const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function useToasts(): ToastContextValue {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error("useToasts must be used within a ToastProvider.");
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Models } from "appwrite";
|
||||
import {
|
||||
chatStorageErrorMessage,
|
||||
createCoachChat,
|
||||
deleteCoachChat,
|
||||
listCoachChats,
|
||||
updateCoachChat,
|
||||
} from "./coachChats";
|
||||
import { buildFlavourHistorySummary, getBstHour } from "./greeting";
|
||||
import {
|
||||
caffeineFor,
|
||||
currency,
|
||||
humanDateTime,
|
||||
makeId,
|
||||
oneDecimal,
|
||||
spendFor,
|
||||
sugarFor,
|
||||
wholeNumber,
|
||||
} from "./metrics";
|
||||
import type { CoachChat, CoachMessage, LimitCheckResult, RedBullEntry, UserLimits } from "../types";
|
||||
import { limitsSummaryForCoach } from "./userLimits";
|
||||
|
||||
type AuthUser = Models.User<Models.Preferences>;
|
||||
|
||||
type Dashboard = {
|
||||
todayCans: string;
|
||||
todayCaffeine: string;
|
||||
todaySugar: string;
|
||||
favouriteFlavour: string;
|
||||
currentStreak: string;
|
||||
totalSpend: string;
|
||||
};
|
||||
|
||||
const OLLAMA_MODEL = "deepseek-v4-pro:cloud";
|
||||
const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat";
|
||||
|
||||
type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } };
|
||||
|
||||
export type CoachSession = ReturnType<typeof useCoachSession>;
|
||||
|
||||
export function useCoachSession(
|
||||
user: AuthUser,
|
||||
dashboard: Dashboard,
|
||||
entries: RedBullEntry[],
|
||||
userLimits: UserLimits = {},
|
||||
limitCheck?: LimitCheckResult,
|
||||
) {
|
||||
const [chats, setChats] = useState<CoachChat[]>([]);
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
||||
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => new Set());
|
||||
const [storageStatus, setStorageStatus] = useState("loading");
|
||||
const [storageReady, setStorageReady] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const queuedPromptRef = useRef<string | null>(null);
|
||||
|
||||
const activeChat = useMemo(() => chats.find((chat) => chat.id === activeChatId) ?? null, [chats, activeChatId]);
|
||||
const messages = useMemo(() => activeChat?.messages ?? [], [activeChat]);
|
||||
const visibleMessages = useMemo(() => messages.filter((message) => message.id !== "coach-welcome"), [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadChats() {
|
||||
if (!user.$id) return;
|
||||
setStorageStatus("loading");
|
||||
setError("");
|
||||
try {
|
||||
const savedChats = await listCoachChats(user.$id);
|
||||
if (cancelled) return;
|
||||
const initialChats = savedChats.length ? savedChats : [buildNewCoachChat(user, dashboard)];
|
||||
setChats(initialChats);
|
||||
setSavedChatIds(new Set(savedChats.map((chat) => chat.id)));
|
||||
setActiveChatId(initialChats[0].id);
|
||||
setStorageStatus(savedChats.length ? `${savedChats.length} synced` : "ready");
|
||||
setStorageReady(true);
|
||||
} catch (caught) {
|
||||
if (cancelled) return;
|
||||
setError(chatStorageErrorMessage(caught));
|
||||
const fallback = buildNewCoachChat(user, dashboard);
|
||||
setChats([fallback]);
|
||||
setActiveChatId(fallback.id);
|
||||
setStorageStatus("local only");
|
||||
setStorageReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
void loadChats();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user.$id]);
|
||||
|
||||
const upsertChatState = useCallback((chat: CoachChat) => {
|
||||
setChats((current) => {
|
||||
const exists = current.some((item) => item.id === chat.id);
|
||||
return exists ? current.map((item) => (item.id === chat.id ? chat : item)) : [chat, ...current];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const patchAssistantMessage = useCallback((chatId: string, messageId: string, patch: Partial<CoachMessage>) => {
|
||||
setChats((current) =>
|
||||
current.map((chat) =>
|
||||
chat.id === chatId
|
||||
? {
|
||||
...chat,
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)),
|
||||
}
|
||||
: chat,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const withAssistantMessage = useCallback((chat: CoachChat, messageId: string, patch: Partial<CoachMessage>): CoachChat => {
|
||||
return {
|
||||
...chat,
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistChat = useCallback(
|
||||
async (chat: CoachChat) => {
|
||||
try {
|
||||
const saved = savedChatIds.has(chat.id)
|
||||
? await updateCoachChat(user.$id, chat)
|
||||
: await createCoachChat(user.$id, chat);
|
||||
setSavedChatIds((current) => new Set(current).add(saved.id));
|
||||
upsertChatState(saved);
|
||||
setStorageStatus("synced");
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setStorageStatus("save pending");
|
||||
setError(chatStorageErrorMessage(caught));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[savedChatIds, upsertChatState, user.$id],
|
||||
);
|
||||
|
||||
const sendPrompt = useCallback(
|
||||
async (prompt: string, chatOverride?: CoachChat | null) => {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed || busy || !storageReady || !user.$id) return false;
|
||||
|
||||
const currentChat = chatOverride ?? activeChat ?? buildNewCoachChat(user, dashboard);
|
||||
const userMessage: CoachMessage = { id: makeId(), role: "user", content: trimmed };
|
||||
const assistantId = makeId();
|
||||
const assistantMessage: CoachMessage = { id: assistantId, role: "assistant", content: "", thinking: "", pending: true };
|
||||
const conversation = [...currentChat.messages, userMessage];
|
||||
const draftChat: CoachChat = {
|
||||
...currentChat,
|
||||
title: titleForChat(currentChat.title, trimmed),
|
||||
messages: [...conversation, assistantMessage],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
upsertChatState(draftChat);
|
||||
setActiveChatId(draftChat.id);
|
||||
setInput("");
|
||||
setBusy(true);
|
||||
setError("");
|
||||
|
||||
let streamedContent = "";
|
||||
let streamedThinking = "";
|
||||
const abortController = new AbortController();
|
||||
abortRef.current = abortController;
|
||||
|
||||
try {
|
||||
const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [
|
||||
{ role: "system", content: buildCoachSystemPrompt(user, dashboard, entries, userLimits, limitCheck) },
|
||||
...conversation
|
||||
.filter((message) => message.content.trim().length > 0)
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.thinking ? { thinking: message.thinking } : {}),
|
||||
})),
|
||||
];
|
||||
|
||||
const response = await fetch(OLLAMA_PROXY_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: OLLAMA_MODEL,
|
||||
messages: requestMessages,
|
||||
stream: true,
|
||||
think: true,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(parseCoachError(detail, response.status));
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("streaming response was empty.");
|
||||
}
|
||||
|
||||
await readOllamaStream(response.body, (chunk) => {
|
||||
if (chunk.error) throw new Error(chunk.error);
|
||||
if (chunk.message?.thinking) streamedThinking += chunk.message.thinking;
|
||||
if (chunk.message?.content) streamedContent += chunk.message.content.toLocaleLowerCase();
|
||||
|
||||
patchAssistantMessage(draftChat.id, assistantId, {
|
||||
content: streamedContent,
|
||||
thinking: streamedThinking,
|
||||
pending: !streamedContent,
|
||||
});
|
||||
});
|
||||
|
||||
const finalChat = withAssistantMessage(draftChat, assistantId, {
|
||||
content: streamedContent || "no answer returned.",
|
||||
thinking: streamedThinking,
|
||||
pending: false,
|
||||
});
|
||||
upsertChatState(finalChat);
|
||||
void persistChat(finalChat);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
const aborted = abortController.signal.aborted;
|
||||
const message = caught instanceof Error ? caught.message : "coach request failed.";
|
||||
const finalChat = withAssistantMessage(draftChat, assistantId, {
|
||||
content: aborted ? streamedContent || "stopped thinking." : `coach unavailable: ${message}`.toLocaleLowerCase(),
|
||||
thinking: streamedThinking,
|
||||
pending: false,
|
||||
stopped: aborted,
|
||||
});
|
||||
upsertChatState(finalChat);
|
||||
void persistChat(finalChat);
|
||||
if (!aborted) setError(message);
|
||||
return false;
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[activeChat, busy, dashboard, entries, limitCheck, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, userLimits, withAssistantMessage],
|
||||
);
|
||||
|
||||
const queuePrompt = useCallback((prompt: string) => {
|
||||
queuedPromptRef.current = prompt;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const prompt = queuedPromptRef.current;
|
||||
if (!storageReady || !prompt || busy) return;
|
||||
queuedPromptRef.current = null;
|
||||
void sendPrompt(prompt);
|
||||
}, [storageReady, busy, sendPrompt]);
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
const chat = buildNewCoachChat(user, dashboard);
|
||||
setChats((current) => [chat, ...current]);
|
||||
setActiveChatId(chat.id);
|
||||
setInput("");
|
||||
setError("");
|
||||
}, [dashboard, user]);
|
||||
|
||||
const removeChat = useCallback(
|
||||
async (chatId: string) => {
|
||||
if (busy) return;
|
||||
try {
|
||||
if (savedChatIds.has(chatId)) await deleteCoachChat(chatId);
|
||||
setSavedChatIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(chatId);
|
||||
return next;
|
||||
});
|
||||
setChats((current) => {
|
||||
const next = current.filter((chat) => chat.id !== chatId);
|
||||
const fallback = buildNewCoachChat(user, dashboard);
|
||||
setActiveChatId(next[0]?.id ?? fallback.id);
|
||||
return next.length ? next : [fallback];
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(chatStorageErrorMessage(caught));
|
||||
}
|
||||
},
|
||||
[busy, dashboard, savedChatIds, user],
|
||||
);
|
||||
|
||||
const stopThinking = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeChatId,
|
||||
busy,
|
||||
chats,
|
||||
error,
|
||||
input,
|
||||
queuePrompt,
|
||||
removeChat,
|
||||
sendPrompt,
|
||||
setActiveChatId,
|
||||
setError,
|
||||
setInput,
|
||||
startNewChat,
|
||||
stopThinking,
|
||||
storageReady,
|
||||
storageStatus,
|
||||
visibleMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function firstName(user: AuthUser) {
|
||||
const fallback = user.email?.split("@")[0] ?? "there";
|
||||
const value = (user.name || fallback).trim();
|
||||
return value.split(/\s+/)[0] || "there";
|
||||
}
|
||||
|
||||
function buildNewCoachChat(user: AuthUser, dashboard: Dashboard): CoachChat {
|
||||
const now = new Date().toISOString();
|
||||
const favourite = dashboard.favouriteFlavour === "None yet" ? "your patterns" : dashboard.favouriteFlavour;
|
||||
return {
|
||||
id: makeId(),
|
||||
userId: user.$id,
|
||||
title: "today",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messages: [
|
||||
{
|
||||
id: "coach-welcome",
|
||||
role: "assistant",
|
||||
content: `hey ${firstName(user).toLocaleLowerCase()}, ${dashboard.todayCans} cans logged today. ask about ${favourite}, caffeine pace, or spend.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function titleForChat(currentTitle: string, prompt: string) {
|
||||
if (currentTitle !== "today" && currentTitle !== "new chat") return currentTitle;
|
||||
const cleaned = prompt.trim().replace(/\s+/g, " ").toLocaleLowerCase();
|
||||
return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "today";
|
||||
}
|
||||
|
||||
function buildCoachSystemPrompt(
|
||||
user: AuthUser,
|
||||
dashboard: Dashboard,
|
||||
entries: RedBullEntry[],
|
||||
userLimits: UserLimits,
|
||||
limitCheck?: LimitCheckResult,
|
||||
) {
|
||||
const recent = entries
|
||||
.slice(0, 12)
|
||||
.map(
|
||||
(entry) =>
|
||||
`- ${humanDateTime(entry.dateTime)}: ${entry.cans} can(s), ${entry.flavour}, ${entry.sizeMl}ml, ${currency.format(spendFor(entry))}, ${wholeNumber.format(caffeineFor(entry))}mg caffeine, ${oneDecimal.format(sugarFor(entry))}g sugar`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
"You are an upbeat Red Bull intake coach inside a tracking app.",
|
||||
"Respond entirely in lower case.",
|
||||
"Give concise, practical suggestions based only on the logged data provided.",
|
||||
"When asked about favourite flavour historically, use the flavour history breakdown below.",
|
||||
"Do not give medical advice.",
|
||||
`User: ${user.name || user.email || "Appwrite user"}`,
|
||||
`Current time (BST): ${getBstHour()}:00.`,
|
||||
`Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`,
|
||||
`Personal limits: ${limitsSummaryForCoach(userLimits, limitCheck ?? { violations: [], projectedCans: 0, projectedSpend: 0, todayCans: 0, todaySpend: 0, pastStopTime: false })}`,
|
||||
`All-time favourite: ${dashboard.favouriteFlavour}. Streak: ${dashboard.currentStreak} day(s). Spend: ${dashboard.totalSpend}.`,
|
||||
`Flavour history:\n${buildFlavourHistorySummary(entries)}`,
|
||||
`Recent entries:\n${recent || "No entries logged yet."}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseCoachError(detail: string, status: number) {
|
||||
const trimmed = detail.trim();
|
||||
if (trimmed.startsWith("<") || /nginx|405 not allowed/i.test(trimmed)) {
|
||||
return `coach api unavailable (${status}). run npm run dev with OLLAMA_API_KEY set, or proxy POST /api/ollama-chat on your host.`;
|
||||
}
|
||||
return trimmed || `request failed (${status}).`;
|
||||
}
|
||||
|
||||
async function readOllamaStream(body: ReadableStream<Uint8Array>, onChunk: (chunk: OllamaStreamChunk) => void) {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const chunk = parseOllamaLine(line);
|
||||
if (chunk) onChunk(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) {
|
||||
const chunk = parseOllamaLine(buffer);
|
||||
if (chunk) onChunk(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
function parseOllamaLine(line: string): OllamaStreamChunk | null {
|
||||
const trimmed = line.trim().replace(/^data:\s*/, "");
|
||||
if (!trimmed || trimmed === "[DONE]") return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as OllamaStreamChunk;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { OLLAMA_MODEL };
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
export function userInitial(user: AuthUser | null) {
|
||||
const value = user?.name || user?.email || "r";
|
||||
return value.trim().charAt(0).toUpperCase();
|
||||
}
|
||||
+40
-17
@@ -1,15 +1,12 @@
|
||||
import type { EntryDraft, LimitCheckResult, LimitViolation, RedBullEntry, UserLimits } from "../types";
|
||||
import { getBstHour } from "./greeting";
|
||||
import { currency, formatDateKey, spendFor, sum } from "./metrics";
|
||||
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 };
|
||||
@@ -19,12 +16,9 @@ export function parseUserLimits(prefs: Record<string, unknown> | null | undefine
|
||||
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;
|
||||
}
|
||||
@@ -40,9 +34,6 @@ export function serializeUserLimits(limits: UserLimits): Record<string, unknown>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -54,16 +45,29 @@ export function mergePrefsWithLimits(
|
||||
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 formatDateKey(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()) {
|
||||
return date.getHours() * 60 + date.getMinutes();
|
||||
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) {
|
||||
@@ -87,7 +91,7 @@ export function formatStopTimeLabel(stopTime: string) {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function entriesToday(entries: RedBullEntry[], ref = new Date()) {
|
||||
function entriesTodayBst(entries: RedBullEntry[], ref = new Date()) {
|
||||
const key = formatBstDateKey(ref);
|
||||
return entries.filter((entry) => formatBstDateKey(new Date(entry.dateTime)) === key);
|
||||
}
|
||||
@@ -97,7 +101,7 @@ function spendForDraft(draft: EntryDraft) {
|
||||
}
|
||||
|
||||
function todayTotals(entries: RedBullEntry[], excludeEntryId?: string, ref = new Date()) {
|
||||
const todayEntries = entriesToday(entries, ref).filter((entry) => entry.id !== excludeEntryId);
|
||||
const todayEntries = entriesTodayBst(entries, ref).filter((entry) => entry.id !== excludeEntryId);
|
||||
return {
|
||||
todayCans: sum(todayEntries, (entry) => entry.cans),
|
||||
todaySpend: sum(todayEntries, spendFor),
|
||||
@@ -157,7 +161,7 @@ export function limitStatusMessage(
|
||||
|
||||
if (violations.includes("cans") && limits.dailyCanLimit != null) {
|
||||
lines.push(
|
||||
`This would bring you to ${check.projectedCans.toFixed(1)}/${limits.dailyCanLimit} cans today.`,
|
||||
`This would bring you to ${check.projectedCans.toFixed(1)}/${limits.dailyCanLimit} cans today (BST).`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,12 +172,31 @@ export function limitStatusMessage(
|
||||
}
|
||||
|
||||
if (violations.includes("stopTime") && limits.stopTime) {
|
||||
lines.push(`You're past your stop time (${formatStopTimeLabel(limits.stopTime)}).`);
|
||||
lines.push(`You're past your stop time (${formatStopTimeLabel(limits.stopTime)} BST).`);
|
||||
}
|
||||
|
||||
return lines.join(" ");
|
||||
}
|
||||
|
||||
export function limitsSummaryForCoach(limits: UserLimits, check: LimitCheckResult): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (limits.dailyCanLimit != null) {
|
||||
parts.push(`daily can limit: ${limits.dailyCanLimit} (${check.todayCans} logged today)`);
|
||||
}
|
||||
if (limits.dailySpendLimit != null) {
|
||||
parts.push(`daily spend limit: ${currency.format(limits.dailySpendLimit)} (${currency.format(check.todaySpend)} today)`);
|
||||
}
|
||||
if (limits.stopTime) {
|
||||
parts.push(
|
||||
`stop drinking by: ${formatStopTimeLabel(limits.stopTime)} bst (${check.pastStopTime ? "past stop time now" : "before stop time"})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parts.length) return "no personal daily limits configured yet.";
|
||||
return parts.join(". ");
|
||||
}
|
||||
|
||||
export function hasAnyLimit(limits: UserLimits) {
|
||||
return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime);
|
||||
}
|
||||
|
||||
+1
-7
@@ -1,16 +1,10 @@
|
||||
import { MotionConfig } from "framer-motion";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./components/Toasts";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</MotionConfig>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
+17
-37
@@ -1,13 +1,5 @@
|
||||
import type { Models } from "appwrite";
|
||||
|
||||
export type BuiltInSize = 250 | 355 | 473;
|
||||
|
||||
export type AppView = "overview" | "logbook" | "trends" | "settings";
|
||||
|
||||
export type AuthUser = Models.User<Models.Preferences>;
|
||||
|
||||
export type SetupStatus = { state: "checking" | "ok" | "error"; message: string };
|
||||
|
||||
export type RedBullEntry = {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -114,35 +106,6 @@ export type ImportPreview = {
|
||||
rows: ImportPreviewRow[];
|
||||
};
|
||||
|
||||
export type ImportProgress = {
|
||||
done: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type EntryDerived = {
|
||||
spend: number;
|
||||
caffeine: number;
|
||||
sugar: number;
|
||||
};
|
||||
|
||||
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 CoachMessage = {
|
||||
@@ -162,3 +125,20 @@ export type CoachChat = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type UserLimits = {
|
||||
dailyCanLimit?: number;
|
||||
dailySpendLimit?: number;
|
||||
stopTime?: string;
|
||||
};
|
||||
|
||||
export type LimitViolation = "cans" | "spend" | "stopTime";
|
||||
|
||||
export type LimitCheckResult = {
|
||||
violations: LimitViolation[];
|
||||
projectedCans: number;
|
||||
projectedSpend: number;
|
||||
todayCans: number;
|
||||
todaySpend: number;
|
||||
pastStopTime: boolean;
|
||||
};
|
||||
|
||||
Vendored
+2
@@ -5,9 +5,11 @@ interface ImportMetaEnv {
|
||||
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
||||
readonly VITE_APPWRITE_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string;
|
||||
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
|
||||
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
||||
readonly VITE_OLLAMA_PROXY_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
+13
-12
@@ -6,20 +6,21 @@ export default {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: [
|
||||
"SF Pro Display",
|
||||
"SF Pro Text",
|
||||
"Google Sans",
|
||||
"Google Sans Text",
|
||||
"Product Sans",
|
||||
"Roboto",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Avenir Next",
|
||||
"Helvetica Neue",
|
||||
"sans-serif",
|
||||
],
|
||||
body: [
|
||||
"SF Pro Text",
|
||||
"Google Sans",
|
||||
"Google Sans Text",
|
||||
"Product Sans",
|
||||
"Roboto",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Avenir Next",
|
||||
"Helvetica Neue",
|
||||
"sans-serif",
|
||||
],
|
||||
},
|
||||
@@ -38,11 +39,11 @@ export default {
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
apple: "0 18px 55px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18)",
|
||||
fridge: "0 18px 70px rgba(0, 0, 0, 0.34), 0 1px 2px rgba(255, 255, 255, 0.06)",
|
||||
can: "0 10px 24px rgba(57, 213, 255, 0.12)",
|
||||
redline: "0 12px 28px rgba(255, 52, 72, 0.26)",
|
||||
cyan: "0 14px 32px rgba(57, 213, 255, 0.18)",
|
||||
apple: "0 1px 2px rgba(69, 54, 62, 0.14), 0 2px 6px rgba(69, 54, 62, 0.08)",
|
||||
fridge: "0 2px 6px rgba(69, 54, 62, 0.12), 0 8px 18px rgba(69, 54, 62, 0.08)",
|
||||
can: "0 1px 2px rgba(156, 65, 104, 0.18), 0 3px 8px rgba(156, 65, 104, 0.10)",
|
||||
redline: "0 2px 8px rgba(186, 26, 26, 0.20)",
|
||||
cyan: "0 1px 2px rgba(156, 65, 104, 0.16), 0 4px 12px rgba(156, 65, 104, 0.10)",
|
||||
},
|
||||
backgroundImage: {
|
||||
"carbon-grid":
|
||||
|
||||
+127
-24
@@ -1,37 +1,140 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [react(), deploymentHtml(command === "build")],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 700,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
charts: ["recharts"],
|
||||
motion: ["framer-motion"],
|
||||
icons: ["lucide-react"],
|
||||
const DEFAULT_MODEL = "deepseek-v4-pro:cloud";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const ollamaProxy = {
|
||||
target: "https://ollama.com",
|
||||
changeOrigin: true,
|
||||
rewrite: () => "/api/chat",
|
||||
configure(proxy: { on: (event: "proxyReq", handler: (proxyReq: { setHeader: (name: string, value: string) => void }) => void) => void }) {
|
||||
proxy.on("proxyReq", (proxyReq) => {
|
||||
if (env.OLLAMA_API_KEY) {
|
||||
proxyReq.setHeader("Authorization", `Bearer ${env.OLLAMA_API_KEY}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
plugins: [react(), ollamaProxyPlugin(env)],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api/ollama-chat": ollamaProxy,
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
proxy: {
|
||||
"/api/ollama-chat": ollamaProxy,
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 700,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
charts: ["recharts"],
|
||||
motion: ["framer-motion"],
|
||||
icons: ["lucide-react"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
function deploymentHtml(enabled: boolean): Plugin {
|
||||
function ollamaProxyPlugin(env: Record<string, string>): Plugin {
|
||||
return {
|
||||
name: "deployment-html",
|
||||
transformIndexHtml(html) {
|
||||
if (!enabled) return html;
|
||||
return html
|
||||
.replace("</head>", `${readOptional(".deploy/head.html")}</head>`)
|
||||
.replace("</body>", `${readOptional(".deploy/body-end.html")}</body>`);
|
||||
name: "ollama-proxy",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use("/api/ollama-chat", createOllamaHandler(env));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readOptional(path: string) {
|
||||
if (!existsSync(path)) return "";
|
||||
return `\n${readFileSync(path, "utf8").trim()}\n`;
|
||||
function createOllamaHandler(env: Record<string, string>) {
|
||||
return (req: IncomingMessage, res: ServerResponse) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
void handleOllamaProxy(req, res, env);
|
||||
};
|
||||
}
|
||||
|
||||
async function handleOllamaProxy(req: IncomingMessage, res: ServerResponse, env: Record<string, string>) {
|
||||
const apiKey = env.OLLAMA_API_KEY;
|
||||
if (!apiKey) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("OLLAMA_API_KEY is not configured on the server.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await readJsonBody(req);
|
||||
const upstream = await fetch("https://ollama.com/api/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
model: payload.model || env.OLLAMA_MODEL || DEFAULT_MODEL,
|
||||
stream: payload.stream !== false,
|
||||
}),
|
||||
});
|
||||
|
||||
res.statusCode = upstream.status;
|
||||
res.setHeader("Content-Type", upstream.headers.get("content-type") || "application/x-ndjson");
|
||||
|
||||
if (!upstream.ok) {
|
||||
res.end(await upstream.text());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!upstream.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = upstream.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(Buffer.from(value));
|
||||
}
|
||||
res.end();
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(error instanceof Error ? error.message : "Ollama proxy failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage) {
|
||||
let raw = "";
|
||||
for await (const chunk of req) raw += chunk;
|
||||
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user