intial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
|
||||||
|
VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138
|
||||||
|
VITE_APPWRITE_DATABASE_ID=redbull_tracker
|
||||||
|
VITE_APPWRITE_COLLECTION_ID=intake_entries
|
||||||
|
|
||||||
|
# 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=
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Red Bull Intake Tracker Setup
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev app runs at `http://localhost:5173` unless that port is already taken.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env.local` and adjust IDs if you choose different Appwrite resource IDs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
This app uses only the Appwrite browser SDK. Do not add an API key to the frontend.
|
||||||
|
|
||||||
|
Configured defaults:
|
||||||
|
|
||||||
|
- Endpoint: `https://fra.cloud.appwrite.io/v1`
|
||||||
|
- Project ID: `6a0752ee001fb2ef7138`
|
||||||
|
- Project name: `Red Bull Tracker App`
|
||||||
|
- Database ID: `redbull_tracker`
|
||||||
|
- Collection ID: `intake_entries`
|
||||||
|
|
||||||
|
`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Enable these auth methods in Appwrite Console:
|
||||||
|
|
||||||
|
- Email/password
|
||||||
|
- GitHub OAuth
|
||||||
|
- Google OAuth
|
||||||
|
|
||||||
|
Add a Web platform in Appwrite Console for local development:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Create a database with ID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
redbull_tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a collection with ID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
intake_entries
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable document-level permissions on the collection.
|
||||||
|
|
||||||
|
Recommended collection-level permissions:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## Component Structure
|
||||||
|
|
||||||
|
- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/data views, modals, and action state.
|
||||||
|
- `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper.
|
||||||
|
- `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures.
|
||||||
|
- `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview.
|
||||||
|
- `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks.
|
||||||
|
- `src/lib/storage.ts`: JSON backup export/import parser.
|
||||||
|
- `src/data/flavours.ts`: Built-in flavours and accent metadata.
|
||||||
|
|
||||||
|
## Nutrition Defaults
|
||||||
|
|
||||||
|
- 250ml: `£1.75`, `80mg` caffeine
|
||||||
|
- 355ml: `£2.20`, `114mg` caffeine
|
||||||
|
- 473ml: `£2.85`, `151mg` caffeine
|
||||||
|
- Custom sizes: caffeine is proportional from 250ml unless a custom override is entered
|
||||||
|
|
||||||
|
The UI shows this disclaimer:
|
||||||
|
|
||||||
|
> Caffeine and sugar values are estimates. Check the can label for exact nutritional information.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: {
|
||||||
|
Blob: "readonly",
|
||||||
|
File: "readonly",
|
||||||
|
KeyboardEvent: "readonly",
|
||||||
|
React: "readonly",
|
||||||
|
URL: "readonly",
|
||||||
|
crypto: "readonly",
|
||||||
|
document: "readonly",
|
||||||
|
localStorage: "readonly",
|
||||||
|
window: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A local-first Red Bull intake web app for tracking cans, spending, caffeine, sugar, flavours, and trends."
|
||||||
|
/>
|
||||||
|
<title>Red Bull Intake Tracker</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+5468
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "red-bull-intake-tracker",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"appwrite": "^25.0.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"framer-motion": "^11.18.2",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^2.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"typescript-eslint": "^8.18.2",
|
||||||
|
"vite": "^6.0.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
+2313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
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: "Blueberry", accent: "#496DFF" },
|
||||||
|
{ name: "Coconut Berry", accent: "#D8F9FF" },
|
||||||
|
{ name: "Peach", accent: "#FF9B63" },
|
||||||
|
{ name: "Juneberry", accent: "#9C73FF" },
|
||||||
|
{ name: "Dragon Fruit", accent: "#FF3DBD" },
|
||||||
|
{ name: "Curuba Elderflower", accent: "#B7FF4A" },
|
||||||
|
{ name: "Winter Edition", accent: "#7CE7FF" },
|
||||||
|
{ name: "Summer Edition", accent: "#f0e53b" },
|
||||||
|
{ name: "Other", accent: "#AEB9C7" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_FLAVOUR = BUILT_IN_FLAVOURS[0];
|
||||||
|
|
||||||
|
const fallbackAccents = [
|
||||||
|
"#00F2FF",
|
||||||
|
"#FF2C38",
|
||||||
|
"#FFC247",
|
||||||
|
"#B7FF4A",
|
||||||
|
"#FF73D1",
|
||||||
|
"#AEB9C7",
|
||||||
|
"#7CE7FF",
|
||||||
|
"#FF9B63",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function flavourMeta(name: string): Flavour {
|
||||||
|
return BUILT_IN_FLAVOURS.find((flavour) => flavour.name === name) ?? {
|
||||||
|
name,
|
||||||
|
accent: accentForCustomFlavour(name),
|
||||||
|
sugarFree: /sugar\s*free|zero/i.test(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accentForCustomFlavour(name: string) {
|
||||||
|
const total = [...name].reduce((sum, letter) => sum + letter.charCodeAt(0), 0);
|
||||||
|
return fallbackAccents[total % fallbackAccents.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergedFlavours(entryFlavours: string[]) {
|
||||||
|
const names = new Set(BUILT_IN_FLAVOURS.map((flavour) => flavour.name));
|
||||||
|
const custom = entryFlavours
|
||||||
|
.filter((name) => name.trim().length > 0 && !names.has(name))
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map((name) => flavourMeta(name));
|
||||||
|
|
||||||
|
return [...BUILT_IN_FLAVOURS, ...custom];
|
||||||
|
}
|
||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif;
|
||||||
|
background: #f5fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
background: #f5fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #f5fbff;
|
||||||
|
color: #193042;
|
||||||
|
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Avenir Next", "Helvetica Neue", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-strong, #74c7ec);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in srgb, var(--accent, #bdeeff) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(230, 244, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(116, 155, 184, 0.45);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-panel {
|
||||||
|
@apply rounded-lg border shadow-fridge backdrop-blur-2xl;
|
||||||
|
background: color-mix(in srgb, var(--panel) 86%, white);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.can-panel {
|
||||||
|
@apply rounded-lg border shadow-cyan backdrop-blur-2xl;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, color-mix(in srgb, var(--accent) 42%, white), rgba(255, 255, 255, 0.96) 52%, color-mix(in srgb, var(--accent-soft) 76%, white));
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.can-emblem {
|
||||||
|
@apply flex h-11 w-11 items-center justify-center rounded-lg border text-[#193042] shadow-cyan;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #ffffff 58%, var(--accent-warm));
|
||||||
|
border-color: color-mix(in srgb, var(--accent-strong) 35%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-button {
|
||||||
|
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 font-display text-sm font-semibold shadow-sm transition active:scale-[0.99];
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-semibold text-[#193042] shadow-cyan transition disabled:cursor-not-allowed;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-strong) 42%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 text-sm font-semibold shadow-sm transition disabled:cursor-not-allowed;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.excel-button {
|
||||||
|
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-semibold text-[#193042] shadow-cyan transition hover:brightness-105 disabled:cursor-not-allowed;
|
||||||
|
background: linear-gradient(135deg, #ffe1ef, var(--accent));
|
||||||
|
border-color: color-mix(in srgb, var(--accent-strong) 35%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-red-300 bg-red-500/90 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-red-400 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-slate-200 disabled:text-slate-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
@apply flex min-h-11 items-center gap-3 rounded-md px-3 text-sm font-medium transition;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-active {
|
||||||
|
@apply shadow-cyan;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
@apply inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-white shadow-sm transition;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add {
|
||||||
|
@apply inline-flex min-h-12 items-center gap-2 rounded-md border bg-white px-3 font-display text-sm font-semibold shadow-sm transition hover:border-[var(--accent)];
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
@apply grid gap-2 text-sm font-medium;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control {
|
||||||
|
@apply w-full rounded-md border bg-white px-3 py-3 text-base font-normal shadow-sm transition;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
@apply max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg border bg-white/95 p-5 shadow-fridge backdrop-blur-2xl sm:p-6;
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-row {
|
||||||
|
@apply grid gap-3 rounded-lg border bg-white/80 p-4 transition sm:grid-cols-[1fr_auto] sm:items-center;
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-button {
|
||||||
|
@apply flex min-h-11 items-center justify-between rounded-lg border bg-white px-3 text-sm font-semibold transition;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
@apply flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm backdrop-blur-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-picker {
|
||||||
|
@apply inline-flex min-h-11 items-center gap-1 rounded-md border bg-white/80 p-1 shadow-sm;
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-picker button {
|
||||||
|
@apply inline-flex min-h-9 items-center gap-2 rounded px-3 text-sm font-semibold transition;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-picker button:hover,
|
||||||
|
.accent-picker-active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-swatch {
|
||||||
|
@apply h-3 w-3 rounded-full border border-white shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-swatch-blue {
|
||||||
|
background: #bdeeff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-swatch-pink {
|
||||||
|
background: #ffd6e8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
--accent: #bdeeff;
|
||||||
|
--accent-soft: #e7f8ff;
|
||||||
|
--accent-strong: #4aa8d6;
|
||||||
|
--accent-warm: #ffe2ef;
|
||||||
|
--bg: #f5fbff;
|
||||||
|
--panel: #f8fcff;
|
||||||
|
--panel-strong: #ffffff;
|
||||||
|
--border: rgba(104, 164, 198, 0.24);
|
||||||
|
--text: #193042;
|
||||||
|
--muted: #607587;
|
||||||
|
--subtle: #7e93a3;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg) !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-accent="pink"] {
|
||||||
|
--accent: #ffd6e8;
|
||||||
|
--accent-soft: #fff0f7;
|
||||||
|
--accent-strong: #d46c9d;
|
||||||
|
--accent-warm: #dff6ff;
|
||||||
|
--bg: #fff8fc;
|
||||||
|
--panel: #fffbfd;
|
||||||
|
--border: rgba(210, 108, 157, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop-wash {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 14% 12%, color-mix(in srgb, var(--accent) 48%, transparent), transparent 32%),
|
||||||
|
radial-gradient(circle at 82% 6%, color-mix(in srgb, var(--accent-warm) 72%, transparent), transparent 34%),
|
||||||
|
linear-gradient(180deg, var(--bg) 0%, #ffffff 46%, color-mix(in srgb, var(--accent-soft) 66%, white) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop-grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, color-mix(in srgb, var(--accent-strong) 12%, transparent) 1px, transparent 1px);
|
||||||
|
background-size: 42px 42px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop-rail {
|
||||||
|
background: linear-gradient(90deg, var(--accent), #ffffff, var(--accent-warm), var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .text-white,
|
||||||
|
.app-shell .text-slate-50,
|
||||||
|
.app-shell .text-slate-100,
|
||||||
|
.app-shell .text-slate-200 {
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .text-slate-300,
|
||||||
|
.app-shell .text-slate-400,
|
||||||
|
.app-shell .text-slate-500,
|
||||||
|
.app-shell .text-slate-600 {
|
||||||
|
color: var(--muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .text-cyan-50,
|
||||||
|
.app-shell .text-cyan-100,
|
||||||
|
.app-shell .text-cyan-200 {
|
||||||
|
color: var(--accent-strong) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .text-emerald-200 {
|
||||||
|
color: #16845c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .text-amber-100,
|
||||||
|
.app-shell .text-amber-200 {
|
||||||
|
color: #8a5a00 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .text-red-100,
|
||||||
|
.app-shell .text-red-200 {
|
||||||
|
color: #b4233c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .text-\[\#07101f\] {
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .bg-\[\#050711\],
|
||||||
|
.app-shell .bg-\[\#090f22\]\/90,
|
||||||
|
.app-shell .bg-\[\#0d142c\],
|
||||||
|
.app-shell .bg-\[\#080d1f\]\/95,
|
||||||
|
.app-shell .bg-\[\#070d1f\]\/90 {
|
||||||
|
background: var(--panel-strong) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .bg-cyan-300,
|
||||||
|
.app-shell .bg-cyan-200,
|
||||||
|
.app-shell .bg-cyan-300\/10,
|
||||||
|
.app-shell .bg-cyan-200\/10 {
|
||||||
|
background-color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .bg-pink-200,
|
||||||
|
.app-shell .bg-pink-200\/10 {
|
||||||
|
background-color: var(--accent-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .bg-white\/5,
|
||||||
|
.app-shell .bg-white\/\[0\.05\],
|
||||||
|
.app-shell .bg-white\/\[0\.06\],
|
||||||
|
.app-shell .bg-white\/\[0\.08\],
|
||||||
|
.app-shell .bg-white\/10,
|
||||||
|
.app-shell .bg-white\/\[0\.10\],
|
||||||
|
.app-shell .hover\:bg-white\/10:hover,
|
||||||
|
.app-shell .hover\:bg-white\/\[0\.10\]:hover,
|
||||||
|
.app-shell .hover\:bg-white\/\[0\.12\]:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--accent-soft) 52%, white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .border-white\/10,
|
||||||
|
.app-shell .border-cyan-200\/20,
|
||||||
|
.app-shell .border-cyan-200\/25,
|
||||||
|
.app-shell .border-cyan-200\/30,
|
||||||
|
.app-shell .border-cyan-300\/30,
|
||||||
|
.app-shell .border-pink-200\/30,
|
||||||
|
.app-shell .border-pink-200\/40 {
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .shadow-fridge,
|
||||||
|
.app-shell .shadow-cyan,
|
||||||
|
.app-shell .shadow-sm {
|
||||||
|
box-shadow: 0 18px 55px rgba(83, 139, 174, 0.14), 0 1px 2px rgba(83, 139, 174, 0.10) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .danger-button,
|
||||||
|
.app-shell .danger-button * {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .field-control::placeholder {
|
||||||
|
color: color-mix(in srgb, var(--muted) 58%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell .modal-panel {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 || "6a0752ee001fb2ef7138",
|
||||||
|
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||||
|
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||||
|
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
|
||||||
|
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new Client()
|
||||||
|
.setEndpoint(appwriteConfig.endpoint)
|
||||||
|
.setProject(appwriteConfig.projectId);
|
||||||
|
|
||||||
|
const account = new Account(client);
|
||||||
|
const tablesDB = new TablesDB(client);
|
||||||
|
|
||||||
|
export async function pingAppwrite() {
|
||||||
|
return client.ping();
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/\/$/, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import type { Models } from "appwrite";
|
||||||
|
import { flavourMeta } from "../data/flavours";
|
||||||
|
import type { EntryDraft, RedBullEntry } from "../types";
|
||||||
|
import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite";
|
||||||
|
import { makeId, makeImportKey } from "./metrics";
|
||||||
|
|
||||||
|
type EntryRow = Models.Row & {
|
||||||
|
userId: string;
|
||||||
|
cans: number;
|
||||||
|
flavour: string;
|
||||||
|
flavourAccent: string;
|
||||||
|
sizeMl: number;
|
||||||
|
pricePerCan: number;
|
||||||
|
dateTime: string;
|
||||||
|
notes?: string;
|
||||||
|
store?: string;
|
||||||
|
sugarFree: boolean;
|
||||||
|
caffeineMgPerCan?: number;
|
||||||
|
importKey: string;
|
||||||
|
source: RedBullEntry["source"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listEntries(userId: string) {
|
||||||
|
const rows: EntryRow[] = [];
|
||||||
|
const limit = 100;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await tablesDB.listRows<EntryRow>({
|
||||||
|
databaseId: appwriteConfig.databaseId,
|
||||||
|
tableId: appwriteConfig.collectionId,
|
||||||
|
queries: [
|
||||||
|
Query.equal("userId", userId),
|
||||||
|
Query.orderDesc("dateTime"),
|
||||||
|
Query.limit(limit),
|
||||||
|
Query.offset(offset),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push(...response.rows);
|
||||||
|
if (response.rows.length < limit) break;
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map(fromRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEntry(userId: string, draft: EntryDraft) {
|
||||||
|
const entry = buildEntry(userId, draft);
|
||||||
|
|
||||||
|
const row = await tablesDB.createRow<EntryRow>({
|
||||||
|
databaseId: appwriteConfig.databaseId,
|
||||||
|
tableId: appwriteConfig.collectionId,
|
||||||
|
rowId: ID.custom(entry.id),
|
||||||
|
data: toRowData(entry),
|
||||||
|
permissions: userRowPermissions(userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return fromRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEntries(userId: string, drafts: EntryDraft[]) {
|
||||||
|
const saved: RedBullEntry[] = [];
|
||||||
|
for (const draft of drafts) {
|
||||||
|
saved.push(await createEntry(userId, draft));
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEntry(userId: string, id: string, draft: EntryDraft) {
|
||||||
|
const entry = buildEntry(userId, draft, id);
|
||||||
|
const row = await tablesDB.updateRow<EntryRow>({
|
||||||
|
databaseId: appwriteConfig.databaseId,
|
||||||
|
tableId: appwriteConfig.collectionId,
|
||||||
|
rowId: id,
|
||||||
|
data: toRowData(entry),
|
||||||
|
permissions: userRowPermissions(userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return fromRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEntry(id: string) {
|
||||||
|
await tablesDB.deleteRow({
|
||||||
|
databaseId: appwriteConfig.databaseId,
|
||||||
|
tableId: appwriteConfig.collectionId,
|
||||||
|
rowId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEntry(userId: string, draft: EntryDraft, id: string = makeId()): RedBullEntry {
|
||||||
|
const meta = flavourMeta(draft.flavour);
|
||||||
|
const entry: RedBullEntry = {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
cans: draft.cans,
|
||||||
|
flavour: draft.flavour,
|
||||||
|
flavourAccent: draft.flavourAccent || meta.accent,
|
||||||
|
sizeMl: draft.sizeMl,
|
||||||
|
pricePerCan: draft.pricePerCan,
|
||||||
|
dateTime: new Date(draft.dateTime).toISOString(),
|
||||||
|
notes: draft.notes ?? "",
|
||||||
|
store: draft.store ?? "",
|
||||||
|
sugarFree: draft.sugarFree || Boolean(meta.sugarFree),
|
||||||
|
caffeineMgPerCan: draft.caffeineMgPerCan,
|
||||||
|
importKey: "",
|
||||||
|
source: draft.source ?? "manual",
|
||||||
|
};
|
||||||
|
|
||||||
|
entry.importKey = makeImportKey(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDuplicateDraft(existing: RedBullEntry[], draft: EntryDraft) {
|
||||||
|
const key = makeImportKey({
|
||||||
|
...draft,
|
||||||
|
dateTime: new Date(draft.dateTime).toISOString(),
|
||||||
|
notes: draft.notes ?? "",
|
||||||
|
store: draft.store ?? "",
|
||||||
|
});
|
||||||
|
return existing.some((entry) => entry.importKey === key || makeImportKey(entry) === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appwriteErrorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (/permissions?.*create|action 'create'|create.*permissions?/i.test(error.message)) {
|
||||||
|
return "Appwrite table permissions need Users -> Create, with Row Security enabled on intake_entries.";
|
||||||
|
}
|
||||||
|
if (/not authorized|401|unauthorized/i.test(error.message)) {
|
||||||
|
return "Appwrite denied the table request. Enable Row Security on intake_entries and grant table-level Users -> Create; rows are then read by per-user row permissions.";
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "Appwrite request failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRow(row: EntryRow): RedBullEntry {
|
||||||
|
return {
|
||||||
|
id: row.$id,
|
||||||
|
userId: row.userId,
|
||||||
|
cans: row.cans,
|
||||||
|
flavour: row.flavour,
|
||||||
|
flavourAccent: row.flavourAccent,
|
||||||
|
sizeMl: row.sizeMl,
|
||||||
|
pricePerCan: row.pricePerCan,
|
||||||
|
dateTime: row.dateTime,
|
||||||
|
notes: row.notes ?? "",
|
||||||
|
store: row.store ?? "",
|
||||||
|
sugarFree: row.sugarFree,
|
||||||
|
caffeineMgPerCan: row.caffeineMgPerCan,
|
||||||
|
importKey: row.importKey || makeImportKey(row),
|
||||||
|
source: row.source ?? "manual",
|
||||||
|
createdAt: row.$createdAt,
|
||||||
|
updatedAt: row.$updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRowData(entry: RedBullEntry) {
|
||||||
|
return {
|
||||||
|
userId: entry.userId,
|
||||||
|
cans: entry.cans,
|
||||||
|
flavour: entry.flavour,
|
||||||
|
flavourAccent: entry.flavourAccent,
|
||||||
|
sizeMl: entry.sizeMl,
|
||||||
|
pricePerCan: entry.pricePerCan,
|
||||||
|
dateTime: entry.dateTime,
|
||||||
|
notes: entry.notes ?? "",
|
||||||
|
store: entry.store ?? "",
|
||||||
|
sugarFree: entry.sugarFree,
|
||||||
|
caffeineMgPerCan: entry.caffeineMgPerCan,
|
||||||
|
importKey: entry.importKey,
|
||||||
|
source: entry.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function userRowPermissions(userId: string) {
|
||||||
|
const role = Role.user(userId);
|
||||||
|
return [Permission.read(role), Permission.update(role), Permission.delete(role)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { flavourMeta } from "../data/flavours";
|
||||||
|
import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types";
|
||||||
|
import {
|
||||||
|
caffeineFor,
|
||||||
|
caffeinePerCan,
|
||||||
|
currency,
|
||||||
|
makeImportKey,
|
||||||
|
oneDecimal,
|
||||||
|
spendFor,
|
||||||
|
sugarFor,
|
||||||
|
sum,
|
||||||
|
topByCans,
|
||||||
|
wholeNumber,
|
||||||
|
} from "./metrics";
|
||||||
|
|
||||||
|
const WORKBOOK_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
const ENTRIES_SHEET = "Intake Entries";
|
||||||
|
const SUMMARY_SHEET = "Summary";
|
||||||
|
const MIKU_BLUE = "FF39D5FF";
|
||||||
|
const PASTEL_PINK = "FFFFB7D9";
|
||||||
|
const MIDNIGHT = "FF0B1022";
|
||||||
|
const CHROME = "FFE8ECF4";
|
||||||
|
const RED_BULL_RED = "FFFF3448";
|
||||||
|
const RED_BULL_YELLOW = "FFFFD84D";
|
||||||
|
|
||||||
|
const ENTRY_COLUMNS = [
|
||||||
|
{ header: "Date", key: "date", width: 14 },
|
||||||
|
{ header: "Time", key: "time", width: 12 },
|
||||||
|
{ header: "Flavour", key: "flavour", width: 22 },
|
||||||
|
{ header: "Size", key: "size", width: 12 },
|
||||||
|
{ header: "Cans", key: "cans", width: 10 },
|
||||||
|
{ header: "Price per can", key: "pricePerCan", width: 16 },
|
||||||
|
{ header: "Total cost", key: "totalCost", width: 15 },
|
||||||
|
{ header: "Caffeine", key: "caffeine", width: 15 },
|
||||||
|
{ header: "Sugar estimate", key: "sugar", width: 17 },
|
||||||
|
{ header: "Store/location", key: "store", width: 24 },
|
||||||
|
{ header: "Notes", key: "notes", width: 36 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export async function createExcelExport(entries: RedBullEntry[]) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.creator = "Red Bull Intake Tracker";
|
||||||
|
workbook.created = new Date();
|
||||||
|
workbook.modified = new Date();
|
||||||
|
workbook.properties.date1904 = false;
|
||||||
|
|
||||||
|
addEntriesSheet(workbook, entries);
|
||||||
|
addSummarySheet(workbook, entries);
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
return new Blob([buffer], { type: WORKBOOK_MIME });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseExcelImport(file: File, existingEntries: RedBullEntry[]): Promise<ImportPreview> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(await file.arrayBuffer());
|
||||||
|
|
||||||
|
const worksheet = workbook.getWorksheet(ENTRIES_SHEET) ?? workbook.worksheets[0];
|
||||||
|
if (!worksheet) {
|
||||||
|
throw new Error("No worksheet found in that Excel file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = headerMap(worksheet.getRow(1));
|
||||||
|
const rows: ImportPreviewRow[] = [];
|
||||||
|
const seen = new Set(existingEntries.map((entry) => entry.importKey || makeImportKey(entry)));
|
||||||
|
|
||||||
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber === 1) return;
|
||||||
|
if (rowIsBlank(row)) return;
|
||||||
|
const label = stringCell(row.getCell(headers.date ?? 1).value).trim().toLowerCase();
|
||||||
|
if (label === "totals" || label === "total") return;
|
||||||
|
|
||||||
|
const result = parseEntryRow(row, headers, rowNumber);
|
||||||
|
if (!result.entry || result.errors.length) {
|
||||||
|
rows.push(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = makeImportKey({
|
||||||
|
...result.entry,
|
||||||
|
dateTime: new Date(result.entry.dateTime).toISOString(),
|
||||||
|
notes: result.entry.notes ?? "",
|
||||||
|
store: result.entry.store ?? "",
|
||||||
|
});
|
||||||
|
const duplicate = seen.has(key);
|
||||||
|
rows.push({
|
||||||
|
...result,
|
||||||
|
duplicate,
|
||||||
|
duplicateReason: duplicate ? "Matches an existing or earlier imported row." : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!duplicate) seen.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fileName: file.name, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob(blob: Blob, fileName: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEntriesSheet(workbook: ExcelJS.Workbook, entries: RedBullEntry[]) {
|
||||||
|
const worksheet = workbook.addWorksheet(ENTRIES_SHEET, {
|
||||||
|
views: [{ state: "frozen", ySplit: 1 }],
|
||||||
|
properties: { defaultRowHeight: 22 },
|
||||||
|
});
|
||||||
|
worksheet.columns = [...ENTRY_COLUMNS];
|
||||||
|
|
||||||
|
const header = worksheet.getRow(1);
|
||||||
|
header.height = 28;
|
||||||
|
header.eachCell((cell, index) => {
|
||||||
|
styleHeaderCell(cell, index % 2 === 0 ? PASTEL_PINK : MIKU_BLUE);
|
||||||
|
});
|
||||||
|
|
||||||
|
entries
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => new Date(left.dateTime).getTime() - new Date(right.dateTime).getTime())
|
||||||
|
.forEach((entry) => {
|
||||||
|
const date = new Date(entry.dateTime);
|
||||||
|
worksheet.addRow({
|
||||||
|
date: toDateLabel(date),
|
||||||
|
time: toTimeLabel(date),
|
||||||
|
flavour: entry.flavour,
|
||||||
|
size: `${entry.sizeMl}ml`,
|
||||||
|
cans: entry.cans,
|
||||||
|
pricePerCan: entry.pricePerCan,
|
||||||
|
totalCost: spendFor(entry),
|
||||||
|
caffeine: caffeineFor(entry),
|
||||||
|
sugar: sugarFor(entry),
|
||||||
|
store: entry.store ?? "",
|
||||||
|
notes: entry.notes ?? "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totals = worksheet.addRow({
|
||||||
|
date: "Totals",
|
||||||
|
cans: sum(entries, (entry) => entry.cans),
|
||||||
|
totalCost: sum(entries, spendFor),
|
||||||
|
caffeine: sum(entries, caffeineFor),
|
||||||
|
sugar: sum(entries, sugarFor),
|
||||||
|
});
|
||||||
|
totals.font = { bold: true, color: { argb: MIDNIGHT } };
|
||||||
|
totals.fill = { type: "pattern", pattern: "solid", fgColor: { argb: CHROME } };
|
||||||
|
|
||||||
|
worksheet.getColumn("pricePerCan").numFmt = '"£"#,##0.00';
|
||||||
|
worksheet.getColumn("totalCost").numFmt = '"£"#,##0.00';
|
||||||
|
worksheet.getColumn("caffeine").numFmt = '0"mg"';
|
||||||
|
worksheet.getColumn("sugar").numFmt = '0.0"g"';
|
||||||
|
worksheet.getColumn("cans").numFmt = "0.00";
|
||||||
|
worksheet.autoFilter = {
|
||||||
|
from: "A1",
|
||||||
|
to: `K${Math.max(1, worksheet.rowCount)}`,
|
||||||
|
};
|
||||||
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber === 1) return;
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
cell.border = lightBorder();
|
||||||
|
cell.alignment = { vertical: "middle", wrapText: true };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
autoWidth(worksheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSummarySheet(workbook: ExcelJS.Workbook, entries: RedBullEntry[]) {
|
||||||
|
const worksheet = workbook.addWorksheet(SUMMARY_SHEET, {
|
||||||
|
views: [{ state: "frozen", ySplit: 1 }],
|
||||||
|
properties: { defaultRowHeight: 24 },
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.columns = [
|
||||||
|
{ header: "Metric", key: "metric", width: 28 },
|
||||||
|
{ header: "Value", key: "value", width: 26 },
|
||||||
|
];
|
||||||
|
worksheet.getRow(1).eachCell((cell, index) => {
|
||||||
|
styleHeaderCell(cell, index === 1 ? MIKU_BLUE : PASTEL_PINK);
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryRows = [
|
||||||
|
["Exported at", new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short" }).format(new Date())],
|
||||||
|
["Entries", entries.length],
|
||||||
|
["Total cans", oneDecimal.format(sum(entries, (entry) => entry.cans))],
|
||||||
|
["Total cost", currency.format(sum(entries, spendFor))],
|
||||||
|
["Estimated caffeine", `${wholeNumber.format(sum(entries, caffeineFor))}mg`],
|
||||||
|
["Estimated sugar", `${oneDecimal.format(sum(entries, sugarFor))}g`],
|
||||||
|
["Favourite flavour", topByCans(entries)],
|
||||||
|
];
|
||||||
|
|
||||||
|
summaryRows.forEach(([metric, value]) => worksheet.addRow({ metric, value }));
|
||||||
|
|
||||||
|
worksheet.addRow({});
|
||||||
|
const byFlavourHeader = worksheet.addRow({ metric: "Flavour", value: "Cans" });
|
||||||
|
byFlavourHeader.eachCell((cell, index) => styleHeaderCell(cell, index === 1 ? RED_BULL_RED : RED_BULL_YELLOW));
|
||||||
|
|
||||||
|
const flavourTotals = new Map<string, number>();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
flavourTotals.set(entry.flavour, (flavourTotals.get(entry.flavour) ?? 0) + entry.cans);
|
||||||
|
});
|
||||||
|
[...flavourTotals.entries()]
|
||||||
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.forEach(([metric, value]) => worksheet.addRow({ metric, value: oneDecimal.format(value) }));
|
||||||
|
|
||||||
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber === 1) return;
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
cell.border = lightBorder();
|
||||||
|
cell.alignment = { vertical: "middle", wrapText: true };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
autoWidth(worksheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntryRow(row: ExcelJS.Row, headers: Record<string, number>, rowNumber: number): ImportPreviewRow {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const dateValue = cellAt(row, headers.date);
|
||||||
|
const timeValue = cellAt(row, headers.time);
|
||||||
|
const flavour = stringCell(cellAt(row, headers.flavour)).trim();
|
||||||
|
const sizeMl = parseSize(stringCell(cellAt(row, headers.size)));
|
||||||
|
const cans = parseNumber(cellAt(row, headers.cans));
|
||||||
|
const pricePerCan = parseNumber(cellAt(row, headers.pricePerCan));
|
||||||
|
const caffeineTotal = parseNumber(cellAt(row, headers.caffeine));
|
||||||
|
const store = stringCell(cellAt(row, headers.store)).trim();
|
||||||
|
const notes = stringCell(cellAt(row, headers.notes)).trim();
|
||||||
|
const dateTime = parseDateTime(dateValue, timeValue);
|
||||||
|
|
||||||
|
if (!dateTime) errors.push("Date/time is invalid or missing.");
|
||||||
|
if (!flavour) errors.push("Flavour is required.");
|
||||||
|
if (!Number.isFinite(sizeMl) || sizeMl <= 0) errors.push("Size must be a positive ml value.");
|
||||||
|
if (!Number.isFinite(cans) || cans <= 0) errors.push("Cans must be greater than zero.");
|
||||||
|
if (!Number.isFinite(pricePerCan) || pricePerCan < 0) errors.push("Price per can must be zero or more.");
|
||||||
|
|
||||||
|
if (errors.length || !dateTime) {
|
||||||
|
return { rowNumber, errors, duplicate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = flavourMeta(flavour);
|
||||||
|
const caffeineOverride = Number.isFinite(caffeineTotal) && caffeineTotal > 0 ? caffeineTotal / cans : undefined;
|
||||||
|
const entry: EntryDraft = {
|
||||||
|
cans,
|
||||||
|
flavour,
|
||||||
|
flavourAccent: meta.accent,
|
||||||
|
sizeMl,
|
||||||
|
pricePerCan,
|
||||||
|
dateTime,
|
||||||
|
notes,
|
||||||
|
store,
|
||||||
|
sugarFree: Boolean(meta.sugarFree),
|
||||||
|
caffeineMgPerCan: caffeineOverride && Math.abs(caffeineOverride - caffeinePerCan(sizeMl)) > 0.5 ? caffeineOverride : undefined,
|
||||||
|
source: "excel",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { rowNumber, entry, errors, duplicate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerMap(row: ExcelJS.Row) {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
row.eachCell((cell, columnNumber) => {
|
||||||
|
const key = normaliseHeader(stringCell(cell.value));
|
||||||
|
if (key) map[key] = columnNumber;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseHeader(value: string) {
|
||||||
|
const clean = value.toLowerCase().replace(/[^a-z]/g, "");
|
||||||
|
const aliases: Record<string, string> = {
|
||||||
|
date: "date",
|
||||||
|
time: "time",
|
||||||
|
flavour: "flavour",
|
||||||
|
flavor: "flavour",
|
||||||
|
size: "size",
|
||||||
|
cans: "cans",
|
||||||
|
pricepercan: "pricePerCan",
|
||||||
|
totalcost: "totalCost",
|
||||||
|
caffeine: "caffeine",
|
||||||
|
sugarestimate: "sugar",
|
||||||
|
storelocation: "store",
|
||||||
|
store: "store",
|
||||||
|
location: "store",
|
||||||
|
notes: "notes",
|
||||||
|
};
|
||||||
|
return aliases[clean];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowIsBlank(row: ExcelJS.Row) {
|
||||||
|
let hasValue = false;
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
if (stringCell(cell.value).trim()) hasValue = true;
|
||||||
|
});
|
||||||
|
return !hasValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateTime(dateValue: ExcelJS.CellValue, timeValue: ExcelJS.CellValue) {
|
||||||
|
const dateString = stringCell(dateValue).trim();
|
||||||
|
const timeString = stringCell(timeValue).trim();
|
||||||
|
|
||||||
|
if (!dateString) return null;
|
||||||
|
if (dateValue instanceof Date) {
|
||||||
|
const date = new Date(dateValue);
|
||||||
|
const time = parseTimeParts(timeValue);
|
||||||
|
if (time) date.setHours(time.hours, time.minutes, 0, 0);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoDate = dateString.includes("T") ? dateString : `${dateString}T${timeString || "00:00"}`;
|
||||||
|
const parsed = new Date(isoDate);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString();
|
||||||
|
|
||||||
|
const gbParts = dateString.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||||
|
if (gbParts) {
|
||||||
|
const [, day, month, year] = gbParts;
|
||||||
|
const parsedGb = new Date(`${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}T${timeString || "00:00"}`);
|
||||||
|
if (!Number.isNaN(parsedGb.getTime())) return parsedGb.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeParts(value: ExcelJS.CellValue) {
|
||||||
|
if (value instanceof Date) return { hours: value.getHours(), minutes: value.getMinutes() };
|
||||||
|
const text = stringCell(value).trim();
|
||||||
|
const match = text.match(/^(\d{1,2}):(\d{2})/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { hours: Number(match[1]), minutes: Number(match[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSize(value: string) {
|
||||||
|
return parseNumber(value.replace(/ml/i, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: ExcelJS.CellValue | string) {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
const text = typeof value === "string" ? value : stringCell(value);
|
||||||
|
const clean = text.replace(/[£,$mg]/gi, "").replace(/g$/i, "").trim();
|
||||||
|
const parsed = Number(clean);
|
||||||
|
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellAt(row: ExcelJS.Row, index: number | undefined) {
|
||||||
|
return index ? row.getCell(index).value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringCell(value: ExcelJS.CellValue): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (value instanceof Date) return toDateLabel(value);
|
||||||
|
if (typeof value === "object") {
|
||||||
|
if ("result" in value) return stringCell(value.result as ExcelJS.CellValue);
|
||||||
|
if ("text" in value) return value.text;
|
||||||
|
if ("richText" in value) return value.richText.map((part) => part.text).join("");
|
||||||
|
if ("hyperlink" in value && "text" in value) return String(value.text);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateLabel(date: Date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimeLabel(date: Date) {
|
||||||
|
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleHeaderCell(cell: ExcelJS.Cell, fill: string) {
|
||||||
|
cell.font = { bold: true, color: { argb: MIDNIGHT } };
|
||||||
|
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: fill } };
|
||||||
|
cell.border = lightBorder();
|
||||||
|
cell.alignment = { horizontal: "center", vertical: "middle", wrapText: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightBorder() {
|
||||||
|
return {
|
||||||
|
top: { style: "thin", color: { argb: "FFD6E4F0" } },
|
||||||
|
left: { style: "thin", color: { argb: "FFD6E4F0" } },
|
||||||
|
bottom: { style: "thin", color: { argb: "FFD6E4F0" } },
|
||||||
|
right: { style: "thin", color: { argb: "FFD6E4F0" } },
|
||||||
|
} satisfies Partial<ExcelJS.Borders>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoWidth(worksheet: ExcelJS.Worksheet) {
|
||||||
|
worksheet.columns.forEach((column) => {
|
||||||
|
let maxLength = 10;
|
||||||
|
column.eachCell?.({ includeEmpty: true }, (cell) => {
|
||||||
|
maxLength = Math.max(maxLength, stringCell(cell.value).length);
|
||||||
|
});
|
||||||
|
column.width = Math.min(Math.max(maxLength + 2, column.width ?? 12), 44);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import type { RedBullEntry } from "../types";
|
||||||
|
|
||||||
|
export const CAFFEINE_PER_250ML = 80;
|
||||||
|
export const SUGAR_PER_250ML = 27;
|
||||||
|
export const STANDARD_CAN_VALUES = {
|
||||||
|
250: { pricePerCan: 1.75, caffeineMg: 80 },
|
||||||
|
355: { pricePerCan: 2.2, caffeineMg: 114 },
|
||||||
|
473: { pricePerCan: 2.85, caffeineMg: 151 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function spendFor(entry: RedBullEntry) {
|
||||||
|
return entry.cans * entry.pricePerCan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultPriceForSize(sizeMl: number) {
|
||||||
|
if (sizeMl === 250 || sizeMl === 355 || sizeMl === 473) {
|
||||||
|
return STANDARD_CAN_VALUES[sizeMl].pricePerCan;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caffeinePerCan(sizeMl: number, override?: number) {
|
||||||
|
if (typeof override === "number" && Number.isFinite(override) && override >= 0) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
if (sizeMl === 250 || sizeMl === 355 || sizeMl === 473) {
|
||||||
|
return STANDARD_CAN_VALUES[sizeMl].caffeineMg;
|
||||||
|
}
|
||||||
|
return (sizeMl / 250) * CAFFEINE_PER_250ML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caffeineFor(entry: RedBullEntry) {
|
||||||
|
return entry.cans * caffeinePerCan(entry.sizeMl, entry.caffeineMgPerCan);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sugarFor(entry: RedBullEntry) {
|
||||||
|
if (entry.sugarFree) return 0;
|
||||||
|
return entry.cans * (entry.sizeMl / 250) * SUGAR_PER_250ML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfDay(date: Date) {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setHours(0, 0, 0, 0);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfWeek(date: Date) {
|
||||||
|
const next = startOfDay(date);
|
||||||
|
const day = next.getDay();
|
||||||
|
const diff = day === 0 ? -6 : 1 - day;
|
||||||
|
next.setDate(next.getDate() + diff);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfMonth(date: Date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameDay(left: Date, right: Date) {
|
||||||
|
return startOfDay(left).getTime() === startOfDay(right).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWithin(date: Date, start: Date, end: Date) {
|
||||||
|
return date.getTime() >= start.getTime() && date.getTime() <= end.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateKey(date: Date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||||
|
const day = `${date.getDate()}`.padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocalInput(date: Date) {
|
||||||
|
const offset = date.getTimezoneOffset();
|
||||||
|
const local = new Date(date.getTime() - offset * 60_000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function humanDateTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currency = new Intl.NumberFormat("en-GB", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "GBP",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const wholeNumber = new Intl.NumberFormat("en-GB", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const oneDecimal = new Intl.NumberFormat("en-GB", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function sum(entries: RedBullEntry[], selector: (entry: RedBullEntry) => number) {
|
||||||
|
return entries.reduce((total, entry) => total + selector(entry), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entriesInRange(entries: RedBullEntry[], start: Date, end: Date) {
|
||||||
|
return entries.filter((entry) => isWithin(new Date(entry.dateTime), start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysBetween(left: Date, right: Date) {
|
||||||
|
return Math.floor(
|
||||||
|
(startOfDay(right).getTime() - startOfDay(left).getTime()) / 86_400_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackedWeeks(entries: RedBullEntry[]) {
|
||||||
|
if (!entries.length) return 1;
|
||||||
|
const first = entries
|
||||||
|
.map((entry) => new Date(entry.dateTime))
|
||||||
|
.sort((a, b) => a.getTime() - b.getTime())[0];
|
||||||
|
return Math.max(1, Math.ceil((Date.now() - first.getTime()) / (7 * 86_400_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByDay(entries: RedBullEntry[]) {
|
||||||
|
const grouped = new Map<
|
||||||
|
string,
|
||||||
|
{ label: string; spend: number; cans: number; caffeine: number; sugar: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const date = new Date(entry.dateTime);
|
||||||
|
const key = formatDateKey(date);
|
||||||
|
const existing =
|
||||||
|
grouped.get(key) ??
|
||||||
|
({
|
||||||
|
label: new Intl.DateTimeFormat("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
}).format(date),
|
||||||
|
spend: 0,
|
||||||
|
cans: 0,
|
||||||
|
caffeine: 0,
|
||||||
|
sugar: 0,
|
||||||
|
} satisfies { label: string; spend: number; cans: number; caffeine: number; sugar: number });
|
||||||
|
|
||||||
|
existing.spend += spendFor(entry);
|
||||||
|
existing.cans += entry.cans;
|
||||||
|
existing.caffeine += caffeineFor(entry);
|
||||||
|
existing.sugar += sugarFor(entry);
|
||||||
|
grouped.set(key, existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...grouped.entries()]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.slice(-30)
|
||||||
|
.map(([, value]) => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByWeek(entries: RedBullEntry[]) {
|
||||||
|
const grouped = new Map<string, { label: string; spend: number; cans: number }>();
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const date = new Date(entry.dateTime);
|
||||||
|
const week = startOfWeek(date);
|
||||||
|
const key = formatDateKey(week);
|
||||||
|
const label = `W/C ${new Intl.DateTimeFormat("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
}).format(week)}`;
|
||||||
|
const existing = grouped.get(key) ?? { label, spend: 0, cans: 0 };
|
||||||
|
existing.spend += spendFor(entry);
|
||||||
|
existing.cans += entry.cans;
|
||||||
|
grouped.set(key, existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...grouped.entries()]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.slice(-10)
|
||||||
|
.map(([, value]) => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByFlavour(entries: RedBullEntry[]) {
|
||||||
|
const grouped = new Map<string, { name: string; value: number; spend: number; accent: string }>();
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const existing =
|
||||||
|
grouped.get(entry.flavour) ??
|
||||||
|
({
|
||||||
|
name: entry.flavour,
|
||||||
|
value: 0,
|
||||||
|
spend: 0,
|
||||||
|
accent: entry.flavourAccent,
|
||||||
|
} satisfies { name: string; value: number; spend: number; accent: string });
|
||||||
|
existing.value += entry.cans;
|
||||||
|
existing.spend += spendFor(entry);
|
||||||
|
grouped.set(entry.flavour, existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...grouped.values()].sort((a, b) => b.value - a.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function topByCans(entries: RedBullEntry[]) {
|
||||||
|
return groupByFlavour(entries)[0]?.name ?? "None yet";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highestAveragePrice(entries: RedBullEntry[], key: "flavour" | "store") {
|
||||||
|
const grouped = new Map<string, { total: number; cans: number }>();
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const label = key === "flavour" ? entry.flavour : entry.store?.trim();
|
||||||
|
if (!label) return;
|
||||||
|
const existing = grouped.get(label) ?? { total: 0, cans: 0 };
|
||||||
|
existing.total += spendFor(entry);
|
||||||
|
existing.cans += entry.cans;
|
||||||
|
grouped.set(label, existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...grouped.entries()]
|
||||||
|
.map(([label, value]) => ({
|
||||||
|
label,
|
||||||
|
average: value.cans > 0 ? value.total / value.cans : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.average - a.average)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentStreak(entries: RedBullEntry[]) {
|
||||||
|
if (!entries.length) return 0;
|
||||||
|
const days = new Set(entries.map((entry) => formatDateKey(startOfDay(new Date(entry.dateTime)))));
|
||||||
|
let cursor = startOfDay(new Date());
|
||||||
|
let streak = 0;
|
||||||
|
|
||||||
|
while (days.has(formatDateKey(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysSinceLast(entries: RedBullEntry[]) {
|
||||||
|
if (!entries.length) return 0;
|
||||||
|
const latest = entries
|
||||||
|
.map((entry) => new Date(entry.dateTime))
|
||||||
|
.sort((a, b) => b.getTime() - a.getTime())[0];
|
||||||
|
return Math.max(0, daysBetween(latest, new Date()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeId() {
|
||||||
|
return crypto.randomUUID?.() ?? `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeImportKey(entry: Pick<RedBullEntry, "dateTime" | "flavour" | "sizeMl" | "cans" | "pricePerCan" | "store" | "notes">) {
|
||||||
|
return [
|
||||||
|
new Date(entry.dateTime).toISOString(),
|
||||||
|
entry.flavour.trim().toLowerCase(),
|
||||||
|
entry.sizeMl,
|
||||||
|
Number(entry.cans).toFixed(3),
|
||||||
|
Number(entry.pricePerCan).toFixed(2),
|
||||||
|
(entry.store ?? "").trim().toLowerCase(),
|
||||||
|
(entry.notes ?? "").trim().toLowerCase(),
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { flavourMeta } from "../data/flavours";
|
||||||
|
import type { EntryDraft, RedBullEntry } from "../types";
|
||||||
|
|
||||||
|
export function exportPayload(entries: RedBullEntry[]) {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
app: "Red Bull Intake Tracker",
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
entries,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseImport(raw: string): EntryDraft[] {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const entries = Array.isArray(parsed) ? parsed : parsed?.entries;
|
||||||
|
if (!Array.isArray(entries)) {
|
||||||
|
throw new Error("Import file does not contain an entries array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = entries.map(coerceEntryDraft).filter(Boolean) as EntryDraft[];
|
||||||
|
if (!valid.length && entries.length) {
|
||||||
|
throw new Error("No valid Red Bull entries were found in that file.");
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceEntryDraft(value: unknown): EntryDraft | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const entry = value as Partial<RedBullEntry>;
|
||||||
|
if (
|
||||||
|
typeof entry.cans !== "number" ||
|
||||||
|
typeof entry.flavour !== "string" ||
|
||||||
|
typeof entry.sizeMl !== "number" ||
|
||||||
|
typeof entry.pricePerCan !== "number" ||
|
||||||
|
typeof entry.dateTime !== "string"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = flavourMeta(entry.flavour);
|
||||||
|
const draft: EntryDraft = {
|
||||||
|
cans: entry.cans,
|
||||||
|
flavour: entry.flavour,
|
||||||
|
flavourAccent: entry.flavourAccent ?? meta.accent,
|
||||||
|
sizeMl: entry.sizeMl,
|
||||||
|
pricePerCan: entry.pricePerCan,
|
||||||
|
dateTime: entry.dateTime,
|
||||||
|
notes: entry.notes ?? "",
|
||||||
|
store: entry.store ?? "",
|
||||||
|
sugarFree: entry.sugarFree ?? Boolean(meta.sugarFree),
|
||||||
|
caffeineMgPerCan: entry.caffeineMgPerCan,
|
||||||
|
source: "json",
|
||||||
|
};
|
||||||
|
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type BuiltInSize = 250 | 355 | 473;
|
||||||
|
|
||||||
|
export type RedBullEntry = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
cans: number;
|
||||||
|
flavour: string;
|
||||||
|
flavourAccent: string;
|
||||||
|
sizeMl: number;
|
||||||
|
pricePerCan: number;
|
||||||
|
dateTime: string;
|
||||||
|
notes?: string;
|
||||||
|
store?: string;
|
||||||
|
sugarFree: boolean;
|
||||||
|
caffeineMgPerCan?: number;
|
||||||
|
importKey: string;
|
||||||
|
source: "manual" | "quick-add" | "excel" | "json";
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Flavour = {
|
||||||
|
name: string;
|
||||||
|
accent: string;
|
||||||
|
sugarFree?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DateFilter = "all" | "today" | "week" | "month" | "custom";
|
||||||
|
|
||||||
|
export type EntryDraft = Omit<
|
||||||
|
RedBullEntry,
|
||||||
|
"id" | "userId" | "importKey" | "source" | "createdAt" | "updatedAt"
|
||||||
|
> & {
|
||||||
|
source?: RedBullEntry["source"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Filters = {
|
||||||
|
flavour: string;
|
||||||
|
dateRange: DateFilter;
|
||||||
|
store: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportPreviewRow = {
|
||||||
|
rowNumber: number;
|
||||||
|
entry?: EntryDraft;
|
||||||
|
errors: string[];
|
||||||
|
duplicate: boolean;
|
||||||
|
duplicateReason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportPreview = {
|
||||||
|
fileName: string;
|
||||||
|
rows: ImportPreviewRow[];
|
||||||
|
};
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_APPWRITE_ENDPOINT?: string;
|
||||||
|
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||||
|
readonly VITE_APPWRITE_DATABASE_ID?: string;
|
||||||
|
readonly VITE_APPWRITE_COLLECTION_ID?: string;
|
||||||
|
readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string;
|
||||||
|
readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
display: [
|
||||||
|
"SF Pro Display",
|
||||||
|
"SF Pro Text",
|
||||||
|
"-apple-system",
|
||||||
|
"BlinkMacSystemFont",
|
||||||
|
"Avenir Next",
|
||||||
|
"Helvetica Neue",
|
||||||
|
"sans-serif",
|
||||||
|
],
|
||||||
|
body: [
|
||||||
|
"SF Pro Text",
|
||||||
|
"-apple-system",
|
||||||
|
"BlinkMacSystemFont",
|
||||||
|
"Avenir Next",
|
||||||
|
"Helvetica Neue",
|
||||||
|
"sans-serif",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
bull: {
|
||||||
|
midnight: "#050711",
|
||||||
|
panel: "#0A1024",
|
||||||
|
steel: "#94A3B8",
|
||||||
|
chrome: "#E8ECF4",
|
||||||
|
blue: "#1A73E8",
|
||||||
|
cyan: "#39D5FF",
|
||||||
|
pink: "#FFB7D9",
|
||||||
|
red: "#FF3448",
|
||||||
|
amber: "#FFD84D",
|
||||||
|
lime: "#34D399",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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)",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
"carbon-grid":
|
||||||
|
"linear-gradient(135deg, rgba(255,255,255,0.045) 25%, transparent 25%), linear-gradient(225deg, rgba(255,255,255,0.045) 25%, transparent 25%), linear-gradient(45deg, rgba(0,0,0,0.22) 25%, transparent 25%), linear-gradient(315deg, rgba(0,0,0,0.22) 25%, #070A0F 25%)",
|
||||||
|
"scan-line":
|
||||||
|
"repeating-linear-gradient(0deg, rgba(255,255,255,0.055) 0px, rgba(255,255,255,0.055) 1px, transparent 1px, transparent 7px)",
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"pulse-rail": "pulseRail 2.4s ease-in-out infinite",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
pulseRail: {
|
||||||
|
"0%, 100%": { opacity: "0.45", transform: "scaleX(0.82)" },
|
||||||
|
"50%": { opacity: "1", transform: "scaleX(1)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "tailwind.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 700,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
charts: ["recharts"],
|
||||||
|
motion: ["framer-motion"],
|
||||||
|
icons: ["lucide-react"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user