Compare commits
184 Commits
1ea0899bee
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
f2f40dcd2d
|
|||
|
e7772e7cc9
|
|||
|
e6e50c7fe2
|
|||
|
092ec26cd4
|
|||
|
3ab339042b
|
|||
|
1d798c94bf
|
|||
|
61681dd556
|
|||
|
84b45a6d2a
|
|||
|
6c160bc6cf
|
|||
|
63a10df7c5
|
|||
|
6e48cfd27c
|
|||
|
5416110e81
|
|||
|
49665c94db
|
|||
|
be7880304c
|
|||
|
8023a907de
|
|||
|
35a98f6a0a
|
|||
|
b4da24b572
|
|||
|
a5daae3fc9
|
|||
|
3b11cb9878
|
|||
|
b209f6e936
|
|||
|
9527c253ed
|
|||
|
8591e5cff7
|
|||
|
0b86f72d92
|
|||
|
e0b932127d
|
|||
|
8cb3d3c4eb
|
|||
|
3b42d6b01d
|
|||
|
ae33591529
|
|||
|
77badc6a36
|
|||
|
46d7833e75
|
|||
|
1265647963
|
|||
|
ee4eda7a32
|
|||
|
72a77b9dc3
|
|||
|
b7444e8bc7
|
|||
|
637a918dd8
|
|||
|
82a27c3f51
|
|||
|
b7c66b6f07
|
|||
|
0ad72ddf24
|
|||
|
89b3202dc5
|
|||
|
0ae16ddd6d
|
|||
|
fd137bc519
|
|||
|
1472451ac4
|
|||
|
2dfed11fd6
|
|||
|
68349fbf46
|
|||
|
b5cbc3f74b
|
|||
|
62515b95f0
|
|||
|
830a1f98a9
|
|||
|
396174fd34
|
|||
|
6e2fdce7f3
|
|||
|
6c44758e55
|
|||
|
a1b80862f5
|
|||
|
385e21b109
|
|||
|
72b49baeab
|
|||
|
04284a7238
|
|||
|
133d121f84
|
|||
|
9d8d1ec41f
|
|||
|
dff8bccae1
|
|||
|
48e277cf19
|
|||
|
b287affeb2
|
|||
|
aaeb0d1083
|
|||
|
38860df660
|
|||
|
62b5c4c240
|
|||
|
54baf9eddb
|
|||
|
54b3bc6309
|
|||
|
9150d1b8da
|
|||
|
6e161dc677
|
|||
|
340b4f6023
|
|||
|
111fa91427
|
|||
|
a54c11145d
|
|||
|
6ad394d3a5
|
|||
|
74d5562fed
|
|||
|
61d80fe0bc
|
|||
|
41a9a0828c
|
|||
|
a5de45f56a
|
|||
|
eadc391b1a
|
|||
|
20368131c5
|
|||
|
6a41d5fd3e
|
|||
|
3ce60c21de
|
|||
|
0aa5b9c1c2
|
|||
|
294d9e6c8d
|
|||
|
bfb582379a
|
|||
|
af5e67a385
|
|||
|
a854f5141a
|
|||
|
470d000125
|
|||
|
3bd80e60e1
|
|||
|
a74bd15a57
|
|||
|
cb35a5c3dc
|
|||
|
a82430371d
|
|||
|
565b35154f
|
|||
|
ca5a2f67c5
|
|||
|
ddb3f9e5cd
|
|||
|
52d278bcd8
|
|||
|
4fe828e228
|
|||
|
fc31c208ef
|
|||
|
738875e89f
|
|||
|
d9f2a27700
|
|||
|
48beb50466
|
|||
|
e43bc9b067
|
|||
|
b65a30591c
|
|||
|
753180acf1
|
|||
|
2dce83de55
|
|||
|
8e4ba896e1
|
|||
|
a432e6ebd5
|
|||
|
d21162c10c
|
|||
|
dd526ead0f
|
|||
|
8364a4fb23
|
|||
|
593f211252
|
|||
|
0874283146
|
|||
|
d0e123018a
|
|||
|
3e8340fde1
|
|||
|
d10946774d
|
|||
|
201847400e
|
|||
|
09cd410eaa
|
|||
|
b2e271c3ea
|
|||
|
fbf888ab31
|
|||
|
c5710ff72d
|
|||
|
082202b71c
|
|||
|
c86d6f487a
|
|||
|
0d2c8f8190
|
|||
|
c2510855c5
|
|||
|
43c8b3da2e
|
|||
|
c5d54acd0d
|
|||
|
8f31cf94a8
|
|||
|
7539d17d0a
|
|||
|
6548ff5016
|
|||
|
c316cb533c
|
|||
|
c7b652bba4
|
|||
|
3b0b1d08e4
|
|||
|
3daa5b65c5
|
|||
|
b7397898e3
|
|||
|
c4420b73d2
|
|||
|
4a0cddf4b7
|
|||
|
97969f8151
|
|||
|
9b2325a0cb
|
|||
|
d462a6ae1b
|
|||
|
88f3909634
|
|||
|
8a14230d00
|
|||
|
f386032716
|
|||
|
eda8502568
|
|||
|
cee20f6bb3
|
|||
|
eda87a8231
|
|||
|
433477e2c6
|
|||
|
61336823b3
|
|||
|
7935ac6b75
|
|||
|
4f77f29a27
|
|||
|
705a10bb3a
|
|||
|
866d2e9fff
|
|||
|
381012db98
|
|||
|
7ebebe5d11
|
|||
|
4f17ad56fa
|
|||
|
a588b8ee84
|
|||
|
21a1f9b976
|
|||
|
9027dd9881
|
|||
|
7e1181461e
|
|||
|
3cafe8955a
|
|||
|
29763ffaa9
|
|||
|
27a29b6f69
|
|||
|
5bed3f3781
|
|||
|
660fec44c2
|
|||
|
5b3b2e5e80
|
|||
|
7f6dcd83a8
|
|||
|
91582d6009
|
|||
|
c41a916947
|
|||
|
3349187ebf
|
|||
|
9e95179175
|
|||
|
c997e74806
|
|||
|
c8dafa7c8a
|
|||
|
5e520122b9
|
|||
|
8b63812734
|
|||
|
d75e2354f6
|
|||
|
a5f2a1d6de
|
|||
|
3b805861cf
|
|||
|
0263a18c5f
|
|||
|
1e0dd27fa3
|
|||
|
9caa0fbc24
|
|||
|
f3a89d2590
|
|||
|
81bb3a2428
|
|||
|
f9f8761c7b
|
|||
|
10233a3804
|
|||
|
d1fbb1c826
|
|||
|
e9de9a35c8
|
|||
|
6685e5731c
|
|||
|
fe49c5b997
|
|||
|
8fff5f14b5
|
|||
|
28b2494a08
|
10
.env.example
@@ -1,10 +1,6 @@
|
||||
# Database Configuration
|
||||
MONGO_URL="mongodb://user:password@host:port/database?authSource=admin"
|
||||
|
||||
# Redis Cache Configuration (optional - falls back to direct DB queries if unavailable)
|
||||
REDIS_HOST="localhost" # Redis server hostname
|
||||
REDIS_PORT="6379" # Redis server port
|
||||
|
||||
# Authentication Secrets (runtime only - not embedded in build)
|
||||
AUTHENTIK_ID="your-authentik-client-id"
|
||||
AUTHENTIK_SECRET="your-authentik-client-secret"
|
||||
@@ -33,3 +29,9 @@ DEEPL_API_URL="https://api-free.deepl.com/v2/translate" # Use https://api.deepl
|
||||
|
||||
# AI Vision Service (Ollama for Alt Text Generation)
|
||||
OLLAMA_URL="http://localhost:11434" # Local Ollama server URL
|
||||
|
||||
# HuggingFace Transformers Model Cache (for nutrition embedding models)
|
||||
TRANSFORMERS_CACHE="/var/cache/transformers" # Must be writable by build and runtime user
|
||||
|
||||
# ExerciseDB v2 API (RapidAPI) - for scraping exercise data
|
||||
RAPIDAPI_KEY="your-rapidapi-key"
|
||||
|
||||
@@ -33,7 +33,6 @@ jobs:
|
||||
git reset --hard origin/master
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build
|
||||
redis-cli KEYS 'recipes:*' | xargs -r redis-cli DEL
|
||||
sudo systemctl stop homepage.service
|
||||
mkdir -p dist
|
||||
rm -rf dist/*
|
||||
|
||||
8
.gitignore
vendored
@@ -10,6 +10,12 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
src-tauri/gen/
|
||||
# USDA bulk data downloads (regenerated by scripts/import-usda-nutrition.ts)
|
||||
data/usda/
|
||||
src-tauri/target/
|
||||
src-tauri/*.keystore
|
||||
# Android: ignore build output and caches, track source files
|
||||
src-tauri/gen/android/.gradle/
|
||||
src-tauri/gen/android/app/build/
|
||||
src-tauri/gen/android/buildSrc/.gradle/
|
||||
src-tauri/gen/android/buildSrc/build/
|
||||
|
||||
108
CLAUDE.md
@@ -17,7 +17,115 @@ After calling the list-sections tool, you MUST analyze the returned documentatio
|
||||
Analyzes Svelte code and returns issues and suggestions.
|
||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||
|
||||
## Common Svelte 5 Pitfalls
|
||||
|
||||
### `{@const}` placement
|
||||
`{@const}` can ONLY be the immediate child of `{#snippet}`, `{#if}`, `{:else if}`, `{:else}`, `{#each}`, `{:then}`, `{:catch}`, `<svelte:fragment>`, `<svelte:boundary>` or `<Component>`. It CANNOT be used directly inside regular HTML elements like `<div>`, `<header>`, etc. Use `$derived` in the `<script>` block instead.
|
||||
|
||||
### Event modifiers removed
|
||||
Svelte 5 removed event modifiers like `on:click|preventDefault`. Use inline handlers instead: `onclick={e => { e.preventDefault(); handler(); }}`.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
|
||||
## Theming Rules
|
||||
|
||||
### Semantic CSS Variables (ALWAYS use these, NEVER hardcode Nord values for themed properties)
|
||||
|
||||
| Purpose | Variable | Light resolves to | Dark resolves to |
|
||||
|---|---|---|---|
|
||||
| Page background | `--color-bg-primary` | white/light | dark |
|
||||
| Card/section bg | `--color-surface` | nord6-ish | nord1-ish |
|
||||
| Secondary bg | `--color-bg-secondary` | slightly darker | slightly lighter |
|
||||
| Tertiary bg (inputs, insets) | `--color-bg-tertiary` | nord5-ish | nord2-ish |
|
||||
| Hover/elevated bg | `--color-bg-elevated` | nord4-ish | nord3-ish |
|
||||
| Primary text | `--color-text-primary` | dark text | light text |
|
||||
| Secondary text (labels, muted) | `--color-text-secondary` | nord3 | nord4 |
|
||||
| Tertiary text (descriptions) | `--color-text-tertiary` | nord2 | nord5 |
|
||||
| Borders | `--color-border` | nord4 | nord2/3 |
|
||||
|
||||
### What NOT to do
|
||||
- **NEVER** use `var(--nord0)` through `var(--nord6)` for backgrounds, text, or borders — these don't adapt to theme
|
||||
- **NEVER** write `@media (prefers-color-scheme: dark)` or `:global(:root[data-theme="dark"])` override blocks — semantic variables handle both themes automatically
|
||||
- **NEVER** use `var(--font-default-dark)` or `var(--accent-dark)` — these are legacy
|
||||
|
||||
### Primary interactive elements
|
||||
- Background: `var(--color-primary)` (nord10 light / nord8 dark)
|
||||
- Hover: `var(--color-primary-hover)`
|
||||
- Active: `var(--color-primary-active)`
|
||||
- Text on primary bg: `var(--color-text-on-primary)`
|
||||
|
||||
### Accent colors (OK to use directly, they work in both themes)
|
||||
- `var(--blue)`, `var(--red)`, `var(--green)`, `var(--orange)` — named accent colors
|
||||
- `var(--nord10)`, `var(--nord11)`, `var(--nord12)`, `var(--nord14)` — OK for hover states of accent-colored buttons only
|
||||
|
||||
### Chart.js theme reactivity
|
||||
Charts don't use CSS variables. Use the `isDark()` pattern from `FitnessChart.svelte`:
|
||||
```js
|
||||
function isDark() {
|
||||
const theme = document.documentElement.getAttribute('data-theme');
|
||||
if (theme === 'dark') return true;
|
||||
if (theme === 'light') return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
const textColor = isDark() ? '#D8DEE9' : '#2E3440';
|
||||
```
|
||||
Re-create the chart on theme change via `MutationObserver` on `data-theme` + `matchMedia` listener.
|
||||
|
||||
### Form inputs
|
||||
- Background: `var(--color-bg-tertiary)`
|
||||
- Border: `var(--color-border)`
|
||||
- Text: `var(--color-text-primary)`
|
||||
- Label: `var(--color-text-secondary)`
|
||||
|
||||
### Toggle component
|
||||
Use `Toggle.svelte` (iOS-style) instead of raw `<input type="checkbox">` for user-facing boolean switches.
|
||||
|
||||
## Site-Wide Design Language
|
||||
|
||||
### Layout & Spacing
|
||||
- Max content width: `1000px`–`1200px` with `margin-inline: auto`
|
||||
- Card/grid gaps: `2rem` desktop, `1rem` tablet, `0.5rem` mobile
|
||||
- Breakpoints: `410px` (small mobile), `560px` (tablet), `900px` (rosary), `1024px` (desktop)
|
||||
|
||||
### Border Radius Tokens
|
||||
- `--radius-pill: 1000px` — nav bar, pill buttons
|
||||
- `--radius-card: 20px` — major cards (recipe cards)
|
||||
- `--radius-lg: 0.75rem` — medium rounded elements
|
||||
- `--radius-md: 0.5rem` — standard rounding
|
||||
- `--radius-sm: 0.3rem` — small elements
|
||||
|
||||
### Shadow Tokens
|
||||
- `--shadow-sm` / `--shadow-md` / `--shadow-lg` / `--shadow-hover` — use these, don't hardcode
|
||||
- Shadows are spread-based (`0 0 Xem Yem`) not offset-based
|
||||
|
||||
### Hover & Interaction Patterns
|
||||
- Cards/links: `scale: 1.02` + shadow elevation on hover
|
||||
- Tags/pills: `scale: 1.05` with `--transition-fast` (100ms)
|
||||
- Standard transitions: `--transition-normal` (200ms)
|
||||
- Nav bar: glassmorphism (`backdrop-filter: blur(16px)`, semi-transparent bg)
|
||||
|
||||
### Typography
|
||||
- Font stack: Helvetica, Arial, "Noto Sans", sans-serif
|
||||
- Size tokens: `--text-sm` through `--text-3xl`
|
||||
- Headings in grids: `1.5rem` desktop → `1.2rem` tablet → `0.95rem` mobile
|
||||
|
||||
### Surfaces & Cards
|
||||
- Use `--color-surface` / `--color-surface-hover` for card backgrounds
|
||||
- Use `--color-bg-elevated` for hover/active states
|
||||
- Recipe cards: 300px wide, `--radius-card` corners
|
||||
- Global utility classes: `.g-icon-badge` (circular), `.g-pill` (pill-shaped)
|
||||
|
||||
## Versioning
|
||||
|
||||
When committing, bump version numbers as appropriate using semver:
|
||||
|
||||
- **patch** (x.y.Z): bug fixes, minor styling tweaks, small corrections
|
||||
- **minor** (x.Y.0): new features, significant UI changes, new pages/routes
|
||||
- **major** (X.0.0): breaking changes, major redesigns, data model changes
|
||||
|
||||
Version files to update:
|
||||
- `package.json` — site version (bump on every commit)
|
||||
- `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` — Tauri/Android app version. Only bump these when the Tauri app codebase itself changes (e.g. `src-tauri/` files), NOT for website-only changes.
|
||||
|
||||
@@ -10,6 +10,11 @@ Bilingual recipe collection with search, category filtering, and seasonal recomm
|
||||
### Faith (`/glaube` · `/faith`)
|
||||
Catholic prayer collection in German, English, and Latin. Includes an interactive Rosary with scroll-synced SVG bead visualization, mystery images (sticky column on desktop, draggable PiP on mobile), decade progress tracking, and a daily streak counter. Adapts prayers for liturgical seasons like Eastertide.
|
||||
|
||||
### Fitness (`/fitness`)
|
||||
Workout tracker with template-based training plans, set logging with RPE, rest timers synced across devices via SSE, workout history with statistics, and body measurement tracking. Cardio exercises support native GPS tracking via the Android app with background location recording.
|
||||
|
||||
**Android app**: [Download APK](https://bocken.org/static/Bocken.apk) — Tauri v2 shell with native GPS foreground service for screen-off tracking, live notification with elapsed time, distance, and pace.
|
||||
|
||||
### Expense Sharing (`/cospend`)
|
||||
Shared expense tracker with balance dashboards, debt breakdowns, monthly bar charts with category filtering, and payment management.
|
||||
|
||||
|
||||
50
package.json
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.0.0",
|
||||
"version": "1.34.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts",
|
||||
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -20,43 +20,51 @@
|
||||
"test:e2e:docker:down": "docker compose -f docker-compose.test.yml down -v",
|
||||
"test:e2e:docker": "docker compose -f docker-compose.test.yml up -d && playwright test; docker compose -f docker-compose.test.yml down -v",
|
||||
"test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"deploy:dry": "bash scripts/deploy.sh --dry-run",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
"@sveltejs/kit": "^2.37.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.1.3",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/kit": "^2.56.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tauri-apps/cli": "^2.10.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.2.9",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@vitest/ui": "^4.0.10",
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"jsdom": "^27.2.0",
|
||||
"svelte": "^5.38.6",
|
||||
"svelte-check": "^4.0.0",
|
||||
"terser": "^5.46.0",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^7.1.3",
|
||||
"vite-node": "^5.3.0",
|
||||
"vitest": "^4.0.10"
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.4",
|
||||
"vite-node": "^6.0.0",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/sveltekit": "^1.11.1",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@huggingface/transformers": "^4.0.1",
|
||||
"@lucide/svelte": "^1.7.0",
|
||||
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3",
|
||||
"@romcal/calendar.general-roman": "3.0.0-dev.125",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tauri-apps/plugin-geolocation": "^2.3.2",
|
||||
"chart.js": "^4.5.0",
|
||||
"barcode-detector": "^3.1.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"file-type": "^19.0.0",
|
||||
"ioredis": "^5.9.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-svelte": "^0.575.0",
|
||||
"mongoose": "^8.0.0",
|
||||
"mongoose": "^9.4.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"sharp": "^0.33.0"
|
||||
"romcal": "github:AlexBocken/romcal1962#e4731a8",
|
||||
"sharp": "^0.34.5",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
2518
pnpm-lock.yaml
generated
@@ -10,13 +10,19 @@ APK_DIR="src-tauri/gen/android/app/build/outputs/apk/universal/release"
|
||||
APK_UNSIGNED="$APK_DIR/app-universal-release-unsigned.apk"
|
||||
APK_SIGNED="$APK_DIR/app-universal-release-signed.apk"
|
||||
KEYSTORE="src-tauri/debug.keystore"
|
||||
PACKAGE="org.bocken.fitness"
|
||||
PACKAGE="org.bocken.app"
|
||||
|
||||
MANIFEST="src-tauri/gen/android/app/src/main/AndroidManifest.xml"
|
||||
TAURI_CONF="src-tauri/tauri.conf.json"
|
||||
DEV_SERVER="http://192.168.1.4:5173"
|
||||
PROD_DIST="https://bocken.org"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [build|deploy|run]"
|
||||
echo "Usage: $0 [build|deploy|run|debug]"
|
||||
echo " build - Build and sign the APK"
|
||||
echo " deploy - Build + install on connected device"
|
||||
echo " run - Build + install + launch on device"
|
||||
echo " debug - Deploy pointing at local dev server (cleartext enabled)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -30,7 +36,19 @@ ensure_keystore() {
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_android_project() {
|
||||
local id_path
|
||||
id_path="src-tauri/gen/android/app/src/main/java/$(echo "$PACKAGE" | tr '.' '/')"
|
||||
if [ ! -d "$id_path" ]; then
|
||||
echo ":: Android project missing or identifier changed, regenerating..."
|
||||
rm -rf src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
fi
|
||||
}
|
||||
|
||||
build() {
|
||||
ensure_android_project
|
||||
|
||||
echo ":: Building Android APK..."
|
||||
pnpm tauri android build --apk
|
||||
|
||||
@@ -70,9 +88,28 @@ run() {
|
||||
echo ":: App launched."
|
||||
}
|
||||
|
||||
enable_debug() {
|
||||
echo ":: Enabling debug config (cleartext + local dev server)..."
|
||||
sed -i 's|\${usesCleartextTraffic}|true|' "$MANIFEST"
|
||||
sed -i "s|\"frontendDist\": \"$PROD_DIST\"|\"frontendDist\": \"$DEV_SERVER\"|" "$TAURI_CONF"
|
||||
}
|
||||
|
||||
restore_release() {
|
||||
echo ":: Restoring release config..."
|
||||
sed -i 's|android:usesCleartextTraffic="true"|android:usesCleartextTraffic="${usesCleartextTraffic}"|' "$MANIFEST"
|
||||
sed -i "s|\"frontendDist\": \"$DEV_SERVER\"|\"frontendDist\": \"$PROD_DIST\"|" "$TAURI_CONF"
|
||||
}
|
||||
|
||||
debug() {
|
||||
enable_debug
|
||||
trap restore_release EXIT
|
||||
deploy
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
build) build ;;
|
||||
deploy) deploy ;;
|
||||
run) run ;;
|
||||
debug) debug ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
74
scripts/assign-icon-categories.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Pre-assign each Bring catalog icon to a shopping category using embeddings.
|
||||
* This enables category-scoped icon search at runtime.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/assign-icon-categories.ts
|
||||
*/
|
||||
import { pipeline } from '@huggingface/transformers';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const MODEL_NAME = 'Xenova/multilingual-e5-base';
|
||||
const CATEGORY_EMBEDDINGS_PATH = resolve('src/lib/data/shoppingCategoryEmbeddings.json');
|
||||
const CATALOG_PATH = resolve('static/shopping-icons/catalog.json');
|
||||
const OUTPUT_PATH = resolve('src/lib/data/shoppingIconCategories.json');
|
||||
|
||||
function cosineSimilarity(a: number[], b: number[]): number {
|
||||
let dot = 0, normA = 0, normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const catData = JSON.parse(readFileSync(CATEGORY_EMBEDDINGS_PATH, 'utf-8'));
|
||||
const catalog: Record<string, string> = JSON.parse(readFileSync(CATALOG_PATH, 'utf-8'));
|
||||
|
||||
console.log(`Loading model ${MODEL_NAME}...`);
|
||||
const embedder = await pipeline('feature-extraction', MODEL_NAME, { dtype: 'q8' });
|
||||
|
||||
const iconNames = Object.keys(catalog);
|
||||
console.log(`Assigning ${iconNames.length} icons to categories...`);
|
||||
|
||||
const assignments: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < iconNames.length; i++) {
|
||||
const name = iconNames[i];
|
||||
const result = await embedder(`query: ${name.toLowerCase()}`, { pooling: 'mean', normalize: true });
|
||||
const qv = Array.from(result.data as Float32Array);
|
||||
|
||||
let bestCategory = 'Sonstiges';
|
||||
let bestScore = -1;
|
||||
for (const entry of catData.entries) {
|
||||
const score = cosineSimilarity(qv, entry.vector);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestCategory = entry.category;
|
||||
}
|
||||
}
|
||||
|
||||
assignments[name] = bestCategory;
|
||||
|
||||
if ((i + 1) % 50 === 0) {
|
||||
console.log(` ${i + 1}/${iconNames.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(OUTPUT_PATH, JSON.stringify(assignments, null, 2), 'utf-8');
|
||||
console.log(`Written ${OUTPUT_PATH} (${iconNames.length} entries)`);
|
||||
|
||||
// Print summary
|
||||
const counts: Record<string, number> = {};
|
||||
for (const cat of Object.values(assignments)) {
|
||||
counts[cat] = (counts[cat] || 0) + 1;
|
||||
}
|
||||
console.log('\nCategory distribution:');
|
||||
for (const [cat, count] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${cat}: ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
107
scripts/download-bring-icons.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Downloads all Bring! shopping list item icons locally.
|
||||
* Icons are stored at static/shopping-icons/{key}.png
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/download-bring-icons.ts
|
||||
*/
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const CATALOG_URL = 'https://web.getbring.com/locale/articles.de-DE.json';
|
||||
const ICON_BASE = 'https://web.getbring.com/assets/images/items/';
|
||||
const OUTPUT_DIR = resolve('static/shopping-icons');
|
||||
|
||||
/** Normalize key to icon filename (matches Bring's normalizeStringPath) */
|
||||
function normalizeKey(key: string): string {
|
||||
return key
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/é/g, 'e')
|
||||
.replace(/è/g, 'e')
|
||||
.replace(/ê/g, 'e')
|
||||
.replace(/à/g, 'a')
|
||||
.replace(/!/g, '')
|
||||
.replace(/[\s\-]+/g, '_');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Fetching catalog...');
|
||||
const res = await fetch(CATALOG_URL);
|
||||
const catalog: Record<string, string> = await res.json();
|
||||
|
||||
// Filter out category headers and meta entries
|
||||
const SKIP = [
|
||||
'Früchte & Gemüse', 'Fleisch & Fisch', 'Milch & Käse', 'Brot & Gebäck',
|
||||
'Getreideprodukte', 'Snacks & Süsswaren', 'Getränke & Tabak', 'Getränke',
|
||||
'Haushalt & Gesundheit', 'Fertig- & Tiefkühlprodukte', 'Zutaten & Gewürze',
|
||||
'Baumarkt & Garten', 'Tierbedarf', 'Eigene Artikel', 'Zuletzt verwendet',
|
||||
'Bring!', 'Vielen Dank', 'Früchte', 'Fleisch', 'Gemüse',
|
||||
];
|
||||
|
||||
const items = Object.keys(catalog).filter(k => !SKIP.includes(k));
|
||||
console.log(`Found ${items.length} items to download`);
|
||||
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
// Also download letter fallbacks a-z
|
||||
const allKeys = [
|
||||
...items.map(k => ({ original: k, normalized: normalizeKey(k) })),
|
||||
...'abcdefghijklmnopqrstuvwxyz'.split('').map(l => ({ original: l, normalized: l })),
|
||||
];
|
||||
|
||||
let downloaded = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const { original, normalized } of allKeys) {
|
||||
const outPath = resolve(OUTPUT_DIR, `${normalized}.png`);
|
||||
|
||||
if (existsSync(outPath)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = `${ICON_BASE}${normalized}.png`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
writeFileSync(outPath, buffer);
|
||||
downloaded++;
|
||||
} else {
|
||||
console.warn(` ✗ ${original} (${normalized}.png) → ${res.status}`);
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ✗ ${original} (${normalized}.png) → ${err}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if ((downloaded + skipped + failed) % 50 === 0) {
|
||||
console.log(` ${downloaded + skipped + failed}/${allKeys.length} (${downloaded} new, ${skipped} cached, ${failed} failed)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save the catalog mapping (key → normalized filename) for runtime lookup
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const item of items) {
|
||||
mapping[item.toLowerCase()] = normalizeKey(item);
|
||||
}
|
||||
// Also add the display names as lookups
|
||||
for (const [key, displayName] of Object.entries(catalog)) {
|
||||
if (!SKIP.includes(key)) {
|
||||
mapping[displayName.toLowerCase()] = normalizeKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
const mappingPath = resolve(OUTPUT_DIR, 'catalog.json');
|
||||
writeFileSync(mappingPath, JSON.stringify(mapping, null, 2));
|
||||
|
||||
console.log(`\nDone: ${downloaded} downloaded, ${skipped} cached, ${failed} failed`);
|
||||
console.log(`Catalog: ${Object.keys(mapping).length} entries → ${mappingPath}`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
117
scripts/download-exercise-media.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Downloads all exercise images and videos from the ExerciseDB CDN.
|
||||
*
|
||||
* Run with: pnpm exec vite-node scripts/download-exercise-media.ts
|
||||
*
|
||||
* Reads: src/lib/data/exercisedb-raw.json
|
||||
* Outputs: static/fitness/exercises/<exerciseId>/
|
||||
* - images: 360p.jpg, 480p.jpg, 720p.jpg, 1080p.jpg
|
||||
* - video: video.mp4
|
||||
*
|
||||
* Resumes automatically — skips files that already exist on disk.
|
||||
*/
|
||||
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { resolve, extname } from 'path';
|
||||
|
||||
const RAW_PATH = resolve('src/lib/data/exercisedb-raw.json');
|
||||
const OUT_DIR = resolve('static/fitness/exercises');
|
||||
const CONCURRENCY = 10;
|
||||
|
||||
interface DownloadTask {
|
||||
url: string;
|
||||
dest: string;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function download(url: string, dest: string, retries = 3): Promise<boolean> {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
writeFileSync(dest, buf);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (attempt === retries) {
|
||||
console.error(` FAILED ${url}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
await sleep(1000 * attempt);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runQueue(tasks: DownloadTask[]) {
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
const total = tasks.length;
|
||||
|
||||
async function worker() {
|
||||
while (tasks.length > 0) {
|
||||
const task = tasks.shift()!;
|
||||
const ok = await download(task.url, task.dest);
|
||||
if (!ok) failed++;
|
||||
done++;
|
||||
if (done % 50 === 0 || done === total) {
|
||||
console.log(` ${done}/${total} downloaded${failed ? ` (${failed} failed)` : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: CONCURRENCY }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return { done, failed };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Exercise Media Downloader ===\n');
|
||||
|
||||
if (!existsSync(RAW_PATH)) {
|
||||
console.error(`Missing ${RAW_PATH} — run scrape-exercises.ts first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = JSON.parse(readFileSync(RAW_PATH, 'utf-8'));
|
||||
const exercises: any[] = data.exercises;
|
||||
console.log(`${exercises.length} exercises in raw data\n`);
|
||||
|
||||
const tasks: DownloadTask[] = [];
|
||||
|
||||
for (const ex of exercises) {
|
||||
const dir = resolve(OUT_DIR, ex.exerciseId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Multi-resolution images
|
||||
if (ex.imageUrls) {
|
||||
for (const [res, url] of Object.entries(ex.imageUrls as Record<string, string>)) {
|
||||
const ext = extname(new URL(url).pathname) || '.jpg';
|
||||
const dest = resolve(dir, `${res}${ext}`);
|
||||
if (!existsSync(dest)) tasks.push({ url, dest });
|
||||
}
|
||||
}
|
||||
|
||||
// Video
|
||||
if (ex.videoUrl) {
|
||||
const dest = resolve(dir, 'video.mp4');
|
||||
if (!existsSync(dest)) tasks.push({ url: ex.videoUrl, dest });
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log('All media already downloaded!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${tasks.length} files to download (skipping existing)\n`);
|
||||
const { done, failed } = await runQueue(tasks);
|
||||
console.log(`\nDone! ${done - failed} downloaded, ${failed} failed.`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
18
scripts/download-models.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Pre-downloads HuggingFace transformer models so they're cached for runtime.
|
||||
* Run with: pnpm exec vite-node scripts/download-models.ts
|
||||
*/
|
||||
import { pipeline } from '@huggingface/transformers';
|
||||
|
||||
const MODELS = [
|
||||
'Xenova/all-MiniLM-L6-v2',
|
||||
'Xenova/multilingual-e5-small',
|
||||
'Xenova/multilingual-e5-base',
|
||||
];
|
||||
|
||||
for (const name of MODELS) {
|
||||
console.log(`Downloading ${name}...`);
|
||||
const p = await pipeline('feature-extraction', name, { dtype: 'q8' });
|
||||
await p.dispose();
|
||||
console.log(` done`);
|
||||
}
|
||||
61
scripts/embed-bls-db.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Pre-compute sentence embeddings for BLS German food names.
|
||||
* Uses multilingual-e5-small for good German language understanding.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/embed-bls-db.ts
|
||||
*/
|
||||
import { pipeline } from '@huggingface/transformers';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// Dynamic import of blsDb (generated file)
|
||||
const { BLS_DB } = await import('../src/lib/data/blsDb');
|
||||
|
||||
const MODEL_NAME = 'Xenova/multilingual-e5-small';
|
||||
const OUTPUT_FILE = resolve('src/lib/data/blsEmbeddings.json');
|
||||
|
||||
async function main() {
|
||||
console.log(`Loading model ${MODEL_NAME}...`);
|
||||
const embedder = await pipeline('feature-extraction', MODEL_NAME, {
|
||||
dtype: 'q8',
|
||||
});
|
||||
|
||||
console.log(`Embedding ${BLS_DB.length} BLS entries...`);
|
||||
|
||||
const entries: { blsCode: string; name: string; vector: number[] }[] = [];
|
||||
const batchSize = 32;
|
||||
|
||||
for (let i = 0; i < BLS_DB.length; i += batchSize) {
|
||||
const batch = BLS_DB.slice(i, i + batchSize);
|
||||
// e5 models require "passage: " prefix for documents
|
||||
const texts = batch.map(e => `passage: ${e.nameDe}`);
|
||||
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const result = await embedder(texts[j], { pooling: 'mean', normalize: true });
|
||||
const vector = Array.from(result.data as Float32Array).map(v => Math.round(v * 10000) / 10000);
|
||||
|
||||
entries.push({
|
||||
blsCode: batch[j].blsCode,
|
||||
name: batch[j].nameDe,
|
||||
vector,
|
||||
});
|
||||
}
|
||||
|
||||
if ((i + batchSize) % 500 < batchSize) {
|
||||
console.log(` ${Math.min(i + batchSize, BLS_DB.length)}/${BLS_DB.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
model: MODEL_NAME,
|
||||
dimensions: entries[0]?.vector.length || 384,
|
||||
count: entries.length,
|
||||
entries,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(output);
|
||||
writeFileSync(OUTPUT_FILE, json, 'utf-8');
|
||||
console.log(`Written ${OUTPUT_FILE} (${(json.length / 1024 / 1024).toFixed(1)}MB, ${entries.length} entries)`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
60
scripts/embed-nutrition-db.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Pre-computes sentence embeddings for all USDA nutrition DB entries using
|
||||
* all-MiniLM-L6-v2 via @huggingface/transformers.
|
||||
*
|
||||
* Run with: pnpm exec vite-node scripts/embed-nutrition-db.ts
|
||||
*
|
||||
* Outputs: src/lib/data/nutritionEmbeddings.json
|
||||
* Format: { entries: [{ fdcId, name, vector: number[384] }] }
|
||||
*/
|
||||
import { writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { pipeline } from '@huggingface/transformers';
|
||||
import { NUTRITION_DB } from '../src/lib/data/nutritionDb';
|
||||
|
||||
const OUTPUT_PATH = resolve('src/lib/data/nutritionEmbeddings.json');
|
||||
const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2';
|
||||
const BATCH_SIZE = 64;
|
||||
|
||||
async function main() {
|
||||
console.log('=== Nutrition DB Embedding Generation ===\n');
|
||||
console.log(`Entries to embed: ${NUTRITION_DB.length}`);
|
||||
console.log(`Model: ${MODEL_NAME}`);
|
||||
console.log(`Loading model (first run downloads ~23MB)...\n`);
|
||||
|
||||
const embedder = await pipeline('feature-extraction', MODEL_NAME, {
|
||||
dtype: 'q8',
|
||||
});
|
||||
|
||||
const entries: { fdcId: number; name: string; vector: number[] }[] = [];
|
||||
const totalBatches = Math.ceil(NUTRITION_DB.length / BATCH_SIZE);
|
||||
|
||||
for (let i = 0; i < NUTRITION_DB.length; i += BATCH_SIZE) {
|
||||
const batch = NUTRITION_DB.slice(i, i + BATCH_SIZE);
|
||||
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
||||
process.stdout.write(`\r Batch ${batchNum}/${totalBatches} (${i + batch.length}/${NUTRITION_DB.length})`);
|
||||
|
||||
// Embed all names in this batch
|
||||
for (const item of batch) {
|
||||
const result = await embedder(item.name, { pooling: 'mean', normalize: true });
|
||||
// result.data is a Float32Array — truncate to 4 decimal places to save space
|
||||
const vector = Array.from(result.data as Float32Array).map(v => Math.round(v * 10000) / 10000);
|
||||
entries.push({ fdcId: item.fdcId, name: item.name, vector });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n\nWriting embeddings...');
|
||||
|
||||
const output = { model: MODEL_NAME, dimensions: 384, count: entries.length, entries };
|
||||
writeFileSync(OUTPUT_PATH, JSON.stringify(output), 'utf-8');
|
||||
|
||||
const fileSizeMB = (Buffer.byteLength(JSON.stringify(output)) / 1024 / 1024).toFixed(1);
|
||||
console.log(`Written ${entries.length} embeddings to ${OUTPUT_PATH} (${fileSizeMB}MB)`);
|
||||
|
||||
await embedder.dispose();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Embedding generation failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
55
scripts/embed-shopping-categories.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Pre-compute sentence embeddings for shopping category representative items.
|
||||
* Uses multilingual-e5-base for good DE/EN understanding.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/embed-shopping-categories.ts
|
||||
*/
|
||||
import { pipeline } from '@huggingface/transformers';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const { CATEGORY_ITEMS } = await import('../src/lib/data/shoppingCategoryItems');
|
||||
|
||||
const MODEL_NAME = 'Xenova/multilingual-e5-base';
|
||||
const OUTPUT_FILE = resolve('src/lib/data/shoppingCategoryEmbeddings.json');
|
||||
|
||||
async function main() {
|
||||
console.log(`Loading model ${MODEL_NAME}...`);
|
||||
const embedder = await pipeline('feature-extraction', MODEL_NAME, {
|
||||
dtype: 'q8',
|
||||
});
|
||||
|
||||
console.log(`Embedding ${CATEGORY_ITEMS.length} category items...`);
|
||||
|
||||
const entries: { name: string; category: string; vector: number[] }[] = [];
|
||||
|
||||
for (let i = 0; i < CATEGORY_ITEMS.length; i++) {
|
||||
const item = CATEGORY_ITEMS[i];
|
||||
// e5 models require "passage: " prefix for documents
|
||||
const result = await embedder(`passage: ${item.name}`, { pooling: 'mean', normalize: true });
|
||||
const vector = Array.from(result.data as Float32Array).map(v => Math.round(v * 10000) / 10000);
|
||||
|
||||
entries.push({
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
vector,
|
||||
});
|
||||
|
||||
if ((i + 1) % 50 === 0) {
|
||||
console.log(` ${i + 1}/${CATEGORY_ITEMS.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
model: MODEL_NAME,
|
||||
dimensions: entries[0]?.vector.length || 768,
|
||||
count: entries.length,
|
||||
entries,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(output);
|
||||
writeFileSync(OUTPUT_FILE, json, 'utf-8');
|
||||
console.log(`Written ${OUTPUT_FILE} (${(json.length / 1024).toFixed(1)}KB, ${entries.length} entries)`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
55
scripts/embed-shopping-icons.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Pre-compute embeddings for Bring! catalog items to enable icon matching.
|
||||
* Maps item names to their icon filenames via semantic similarity.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/embed-shopping-icons.ts
|
||||
*/
|
||||
import { pipeline } from '@huggingface/transformers';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const MODEL_NAME = 'Xenova/multilingual-e5-base';
|
||||
const CATALOG_PATH = resolve('static/shopping-icons/catalog.json');
|
||||
const OUTPUT_FILE = resolve('src/lib/data/shoppingIconEmbeddings.json');
|
||||
|
||||
async function main() {
|
||||
const catalog: Record<string, string> = JSON.parse(readFileSync(CATALOG_PATH, 'utf-8'));
|
||||
|
||||
// Deduplicate: multiple display names can map to the same icon
|
||||
// We want one embedding per unique display name
|
||||
const uniqueItems = new Map<string, string>();
|
||||
for (const [name, iconFile] of Object.entries(catalog)) {
|
||||
uniqueItems.set(name, iconFile);
|
||||
}
|
||||
|
||||
const items = [...uniqueItems.entries()];
|
||||
console.log(`Loading model ${MODEL_NAME}...`);
|
||||
const embedder = await pipeline('feature-extraction', MODEL_NAME, { dtype: 'q8' });
|
||||
|
||||
console.log(`Embedding ${items.length} catalog items...`);
|
||||
const entries: { name: string; icon: string; vector: number[] }[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const [name, icon] = items[i];
|
||||
const result = await embedder(`passage: ${name}`, { pooling: 'mean', normalize: true });
|
||||
const vector = Array.from(result.data as Float32Array).map(v => Math.round(v * 10000) / 10000);
|
||||
entries.push({ name, icon, vector });
|
||||
|
||||
if ((i + 1) % 50 === 0) {
|
||||
console.log(` ${i + 1}/${items.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
model: MODEL_NAME,
|
||||
dimensions: entries[0]?.vector.length || 768,
|
||||
count: entries.length,
|
||||
entries,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(output);
|
||||
writeFileSync(OUTPUT_FILE, json, 'utf-8');
|
||||
console.log(`Written ${OUTPUT_FILE} (${(json.length / 1024).toFixed(1)}KB, ${entries.length} entries)`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
182
scripts/import-bls-nutrition.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Import BLS 4.0 (Bundeslebensmittelschlüssel) nutrition data from CSV.
|
||||
* Pre-convert the xlsx to CSV first (one-time):
|
||||
* node -e "const X=require('xlsx');const w=X.readFile('BLS_4_0_2025_DE/BLS_4_0_Daten_2025_DE.xlsx');
|
||||
* require('fs').writeFileSync('BLS_4_0_2025_DE/BLS_4_0_Daten_2025_DE.csv',X.utils.sheet_to_csv(w.Sheets[w.SheetNames[0]]))"
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/import-bls-nutrition.ts
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/** Parse CSV handling quoted fields with commas */
|
||||
function parseCSV(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const row: string[] = [];
|
||||
while (i < text.length && text[i] !== '\n') {
|
||||
if (text[i] === '"') {
|
||||
i++; // skip opening quote
|
||||
let field = '';
|
||||
while (i < text.length) {
|
||||
if (text[i] === '"') {
|
||||
if (text[i + 1] === '"') { field += '"'; i += 2; }
|
||||
else { i++; break; }
|
||||
} else { field += text[i]; i++; }
|
||||
}
|
||||
row.push(field);
|
||||
if (text[i] === ',') i++;
|
||||
} else {
|
||||
const next = text.indexOf(',', i);
|
||||
const nl = text.indexOf('\n', i);
|
||||
const end = (next === -1 || (nl !== -1 && nl < next)) ? (nl === -1 ? text.length : nl) : next;
|
||||
row.push(text.substring(i, end));
|
||||
i = end;
|
||||
if (text[i] === ',') i++;
|
||||
}
|
||||
}
|
||||
if (text[i] === '\n') i++;
|
||||
if (row.length > 0) rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const BLS_CSV = resolve('BLS_4_0_2025_DE/BLS_4_0_Daten_2025_DE.csv');
|
||||
const OUTPUT_FILE = resolve('src/lib/data/blsDb.ts');
|
||||
|
||||
// BLS nutrient code → our per100g field name
|
||||
const NUTRIENT_MAP: Record<string, { field: string; divisor?: number }> = {
|
||||
ENERCC: { field: 'calories' },
|
||||
PROT625: { field: 'protein' },
|
||||
FAT: { field: 'fat' },
|
||||
FASAT: { field: 'saturatedFat' },
|
||||
CHO: { field: 'carbs' },
|
||||
FIBT: { field: 'fiber' },
|
||||
SUGAR: { field: 'sugars' },
|
||||
CA: { field: 'calcium' },
|
||||
FE: { field: 'iron' },
|
||||
MG: { field: 'magnesium' },
|
||||
P: { field: 'phosphorus' },
|
||||
K: { field: 'potassium' },
|
||||
NA: { field: 'sodium' },
|
||||
ZN: { field: 'zinc' },
|
||||
VITA: { field: 'vitaminA' },
|
||||
VITC: { field: 'vitaminC' },
|
||||
VITD: { field: 'vitaminD' },
|
||||
VITE: { field: 'vitaminE' },
|
||||
VITK: { field: 'vitaminK' },
|
||||
THIA: { field: 'thiamin' },
|
||||
RIBF: { field: 'riboflavin' },
|
||||
NIA: { field: 'niacin' },
|
||||
VITB6: { field: 'vitaminB6', divisor: 1000 }, // BLS: µg → mg
|
||||
VITB12: { field: 'vitaminB12' },
|
||||
FOL: { field: 'folate' },
|
||||
CHORL: { field: 'cholesterol' },
|
||||
// Amino acids (all g/100g)
|
||||
ILE: { field: 'isoleucine' },
|
||||
LEU: { field: 'leucine' },
|
||||
LYS: { field: 'lysine' },
|
||||
MET: { field: 'methionine' },
|
||||
PHE: { field: 'phenylalanine' },
|
||||
THR: { field: 'threonine' },
|
||||
TRP: { field: 'tryptophan' },
|
||||
VAL: { field: 'valine' },
|
||||
HIS: { field: 'histidine' },
|
||||
ALA: { field: 'alanine' },
|
||||
ARG: { field: 'arginine' },
|
||||
ASP: { field: 'asparticAcid' },
|
||||
CYSTE: { field: 'cysteine' },
|
||||
GLU: { field: 'glutamicAcid' },
|
||||
GLY: { field: 'glycine' },
|
||||
PRO: { field: 'proline' },
|
||||
SER: { field: 'serine' },
|
||||
TYR: { field: 'tyrosine' },
|
||||
};
|
||||
|
||||
// BLS 4.0 code first letter → category (Hauptlebensmittelgruppen)
|
||||
const CATEGORY_MAP: Record<string, string> = {
|
||||
B: 'Brot & Backwaren', C: 'Getreide', D: 'Dauerbackwaren & Kekse',
|
||||
E: 'Teigwaren & Nudeln', F: 'Obst & Früchte', G: 'Gemüse',
|
||||
H: 'Hülsenfrüchte & Sojaprodukte', K: 'Kartoffeln & Stärke',
|
||||
M: 'Milch & Milchprodukte', N: 'Getränke (alkoholfrei)',
|
||||
P: 'Alkoholische Getränke', Q: 'Fette & Öle',
|
||||
R: 'Gewürze & Würzmittel', S: 'Zucker & Honig',
|
||||
T: 'Fisch & Meeresfrüchte', U: 'Fleisch',
|
||||
V: 'Wild & Kaninchen', W: 'Wurstwaren',
|
||||
X: 'Brühen & Fertiggerichte', Y: 'Gerichte & Rezepte',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('Reading BLS CSV...');
|
||||
const csvText = readFileSync(BLS_CSV, 'utf-8');
|
||||
const rows: string[][] = parseCSV(csvText);
|
||||
|
||||
const headers = rows[0];
|
||||
console.log(`Headers: ${headers.length} columns, ${rows.length - 1} data rows`);
|
||||
|
||||
// Build column index: BLS nutrient code → column index of the value column
|
||||
const codeToCol = new Map<string, number>();
|
||||
for (let c = 3; c < headers.length; c += 3) {
|
||||
const code = headers[c]?.split(' ')[0];
|
||||
if (code) codeToCol.set(code, c);
|
||||
}
|
||||
|
||||
const entries: any[] = [];
|
||||
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
const blsCode = row[0]?.trim();
|
||||
const nameDe = row[1]?.trim();
|
||||
const nameEn = row[2]?.trim() || '';
|
||||
|
||||
if (!blsCode || !nameDe) continue;
|
||||
|
||||
const category = CATEGORY_MAP[blsCode[0]] || 'Sonstiges';
|
||||
const per100g: Record<string, number> = {};
|
||||
|
||||
for (const [blsNutrientCode, mapping] of Object.entries(NUTRIENT_MAP)) {
|
||||
const col = codeToCol.get(blsNutrientCode);
|
||||
if (col === undefined) {
|
||||
per100g[mapping.field] = 0;
|
||||
continue;
|
||||
}
|
||||
let value = parseFloat(row[col] || '0');
|
||||
if (isNaN(value)) value = 0;
|
||||
if (mapping.divisor) value /= mapping.divisor;
|
||||
per100g[mapping.field] = Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
entries.push({ blsCode, nameDe, nameEn, category, per100g });
|
||||
}
|
||||
|
||||
console.log(`Parsed ${entries.length} BLS entries`);
|
||||
|
||||
// Sample entries
|
||||
const sample = entries.slice(0, 3);
|
||||
for (const e of sample) {
|
||||
console.log(` ${e.blsCode} | ${e.nameDe} | ${e.per100g.calories} kcal | protein ${e.per100g.protein}g`);
|
||||
}
|
||||
|
||||
const output = `// Auto-generated from BLS 4.0 (Bundeslebensmittelschlüssel)
|
||||
// Generated: ${new Date().toISOString().split('T')[0]}
|
||||
// Do not edit manually — regenerate with: pnpm exec vite-node scripts/import-bls-nutrition.ts
|
||||
|
||||
import type { NutritionPer100g } from '$types/types';
|
||||
|
||||
export type BlsEntry = {
|
||||
blsCode: string;
|
||||
nameDe: string;
|
||||
nameEn: string;
|
||||
category: string;
|
||||
per100g: NutritionPer100g;
|
||||
};
|
||||
|
||||
export const BLS_DB: BlsEntry[] = ${JSON.stringify(entries, null, 0)};
|
||||
`;
|
||||
|
||||
writeFileSync(OUTPUT_FILE, output, 'utf-8');
|
||||
console.log(`Written ${OUTPUT_FILE} (${(output.length / 1024 / 1024).toFixed(1)}MB, ${entries.length} entries)`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
278
scripts/import-openfoodfacts.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Import OpenFoodFacts MongoDB dump into a lean `openfoodfacts` collection.
|
||||
*
|
||||
* This script:
|
||||
* 0. Downloads the OFF MongoDB dump if not present locally
|
||||
* 1. Runs `mongorestore` to load the raw dump into a temporary `off_products` collection
|
||||
* 2. Transforms each document, extracting only the fields we need
|
||||
* 3. Inserts into the `openfoodfacts` collection with proper indexes
|
||||
* 4. Drops the temporary `off_products` collection
|
||||
*
|
||||
* Reads MONGO_URL from .env (via dotenv).
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec vite-node scripts/import-openfoodfacts.ts [path-to-dump.gz]
|
||||
*
|
||||
* Default dump path: ./openfoodfacts-mongodbdump.gz
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const OFF_DUMP_URL = 'https://static.openfoodfacts.org/data/openfoodfacts-mongodbdump.gz';
|
||||
|
||||
// --- Load MONGO_URL from .env ---
|
||||
const envPath = resolve(import.meta.dirname ?? '.', '..', '.env');
|
||||
const envText = readFileSync(envPath, 'utf-8');
|
||||
const mongoMatch = envText.match(/^MONGO_URL="?([^"\n]+)"?/m);
|
||||
if (!mongoMatch) { console.error('MONGO_URL not found in .env'); process.exit(1); }
|
||||
const MONGO_URL = mongoMatch[1];
|
||||
|
||||
// Parse components for mongorestore URI (needs root DB, not /recipes)
|
||||
const parsed = new URL(MONGO_URL);
|
||||
const RESTORE_URI = `mongodb://${parsed.username}:${parsed.password}@${parsed.host}/?authSource=${new URLSearchParams(parsed.search).get('authSource') || 'admin'}`;
|
||||
const DB_NAME = parsed.pathname.replace(/^\//, '') || 'recipes';
|
||||
|
||||
const BATCH_SIZE = 5000;
|
||||
|
||||
// --- Resolve dump file path, download if missing ---
|
||||
const dumpPath = resolve(process.argv[2] || './openfoodfacts-mongodbdump.gz');
|
||||
if (!existsSync(dumpPath)) {
|
||||
console.log(`\nDump file not found at ${dumpPath}`);
|
||||
console.log(`Downloading from ${OFF_DUMP_URL} (~13 GB)…\n`);
|
||||
try {
|
||||
execSync(`curl -L -o "${dumpPath}" --progress-bar "${OFF_DUMP_URL}"`, { stdio: 'inherit' });
|
||||
} catch (err: any) {
|
||||
console.error('Download failed:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Download complete.\n');
|
||||
}
|
||||
|
||||
// Map OFF nutriment keys → our per100g field names
|
||||
const NUTRIENT_MAP: Record<string, string> = {
|
||||
'energy-kcal_100g': 'calories',
|
||||
'proteins_100g': 'protein',
|
||||
'fat_100g': 'fat',
|
||||
'saturated-fat_100g': 'saturatedFat',
|
||||
'carbohydrates_100g': 'carbs',
|
||||
'fiber_100g': 'fiber',
|
||||
'sugars_100g': 'sugars',
|
||||
'calcium_100g': 'calcium',
|
||||
'iron_100g': 'iron',
|
||||
'magnesium_100g': 'magnesium',
|
||||
'phosphorus_100g': 'phosphorus',
|
||||
'potassium_100g': 'potassium',
|
||||
'sodium_100g': 'sodium',
|
||||
'zinc_100g': 'zinc',
|
||||
'vitamin-a_100g': 'vitaminA',
|
||||
'vitamin-c_100g': 'vitaminC',
|
||||
'vitamin-d_100g': 'vitaminD',
|
||||
'vitamin-e_100g': 'vitaminE',
|
||||
'vitamin-k_100g': 'vitaminK',
|
||||
'vitamin-b1_100g': 'thiamin',
|
||||
'vitamin-b2_100g': 'riboflavin',
|
||||
'vitamin-pp_100g': 'niacin',
|
||||
'vitamin-b6_100g': 'vitaminB6',
|
||||
'vitamin-b12_100g': 'vitaminB12',
|
||||
'folates_100g': 'folate',
|
||||
'cholesterol_100g': 'cholesterol',
|
||||
};
|
||||
|
||||
function extractPer100g(nutriments: any): Record<string, number> | null {
|
||||
if (!nutriments) return null;
|
||||
const out: Record<string, number> = {};
|
||||
let hasAny = false;
|
||||
for (const [offKey, ourKey] of Object.entries(NUTRIENT_MAP)) {
|
||||
const v = Number(nutriments[offKey]);
|
||||
if (!isNaN(v) && v >= 0) {
|
||||
out[ourKey] = v;
|
||||
if (ourKey === 'calories' || ourKey === 'protein' || ourKey === 'fat' || ourKey === 'carbs') {
|
||||
hasAny = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to kJ → kcal if energy-kcal_100g was missing
|
||||
if (!out.calories) {
|
||||
const kj = Number(nutriments['energy_100g']);
|
||||
if (!isNaN(kj) && kj > 0) {
|
||||
out.calories = Math.round(kj / 4.184 * 10) / 10;
|
||||
hasAny = true;
|
||||
}
|
||||
}
|
||||
return hasAny ? out : null;
|
||||
}
|
||||
|
||||
function pickName(doc: any): { name: string; nameDe?: string } | null {
|
||||
const en = doc.product_name_en?.trim();
|
||||
const de = doc.product_name_de?.trim();
|
||||
const generic = doc.product_name?.trim();
|
||||
const fr = doc.product_name_fr?.trim();
|
||||
|
||||
const name = en || generic || fr;
|
||||
if (!name) return null;
|
||||
|
||||
return { name, ...(de && de !== name ? { nameDe: de } : {}) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// --- Step 1: mongorestore (skip if off_products already has data) ---
|
||||
await mongoose.connect(MONGO_URL);
|
||||
let existingCount = await mongoose.connection.db!.collection('off_products').estimatedDocumentCount();
|
||||
|
||||
if (existingCount > 100000) {
|
||||
console.log(`\n=== Step 1: SKIPPED — off_products already has ~${existingCount.toLocaleString()} documents ===\n`);
|
||||
} else {
|
||||
console.log(`\n=== Step 1: mongorestore from ${dumpPath} ===\n`);
|
||||
await mongoose.disconnect();
|
||||
const restoreCmd = [
|
||||
'mongorestore', '--gzip',
|
||||
`--archive=${dumpPath}`,
|
||||
`--uri="${RESTORE_URI}"`,
|
||||
`--nsFrom='off.products'`,
|
||||
`--nsTo='${DB_NAME}.off_products'`,
|
||||
'--drop', '--noIndexRestore',
|
||||
].join(' ');
|
||||
console.log(`Running: ${restoreCmd.replace(parsed.password, '***')}\n`);
|
||||
try {
|
||||
execSync(restoreCmd, { stdio: 'inherit', shell: '/bin/sh' });
|
||||
} catch (err: any) {
|
||||
console.error('mongorestore failed:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
await mongoose.connect(MONGO_URL);
|
||||
}
|
||||
|
||||
const db = mongoose.connection.db!;
|
||||
|
||||
// --- Step 2: Transform ---
|
||||
console.log('\n=== Step 2: Transform off_products → openfoodfacts ===\n');
|
||||
|
||||
const src = db.collection('off_products');
|
||||
const dst = db.collection('openfoodfacts');
|
||||
|
||||
const srcCount = await src.estimatedDocumentCount();
|
||||
console.log(`Source off_products: ~${srcCount.toLocaleString()} documents`);
|
||||
|
||||
try { await dst.drop(); } catch {}
|
||||
|
||||
console.log('Transforming…');
|
||||
let processed = 0;
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
let batch: any[] = [];
|
||||
|
||||
const cursor = src.find(
|
||||
{ code: { $exists: true, $ne: '' }, $or: [{ 'nutriments.energy-kcal_100g': { $gt: 0 } }, { 'nutriments.energy_100g': { $gt: 0 } }] },
|
||||
{
|
||||
projection: {
|
||||
code: 1, product_name: 1, product_name_en: 1, product_name_de: 1,
|
||||
product_name_fr: 1, brands: 1, quantity: 1, serving_size: 1,
|
||||
serving_quantity: 1, nutriments: 1, nutriscore_grade: 1,
|
||||
categories_tags: 1, product_quantity: 1,
|
||||
}
|
||||
}
|
||||
).batchSize(BATCH_SIZE);
|
||||
|
||||
for await (const doc of cursor) {
|
||||
processed++;
|
||||
|
||||
const names = pickName(doc);
|
||||
if (!names) { skipped++; continue; }
|
||||
|
||||
const per100g = extractPer100g(doc.nutriments);
|
||||
if (!per100g) { skipped++; continue; }
|
||||
|
||||
const barcode = String(doc.code).trim();
|
||||
if (!barcode || barcode.length < 4) { skipped++; continue; }
|
||||
|
||||
const entry: any = { barcode, name: names.name, per100g };
|
||||
|
||||
if (names.nameDe) entry.nameDe = names.nameDe;
|
||||
|
||||
const brands = typeof doc.brands === 'string' ? doc.brands.trim() : '';
|
||||
if (brands) entry.brands = brands;
|
||||
|
||||
const servingG = Number(doc.serving_quantity);
|
||||
const servingDesc = typeof doc.serving_size === 'string' ? doc.serving_size.trim() : '';
|
||||
if (servingG > 0 && servingDesc) {
|
||||
entry.serving = { description: servingDesc, grams: servingG };
|
||||
}
|
||||
|
||||
const pq = Number(doc.product_quantity);
|
||||
if (pq > 0) entry.productQuantityG = pq;
|
||||
|
||||
if (typeof doc.nutriscore_grade === 'string' && /^[a-e]$/.test(doc.nutriscore_grade)) {
|
||||
entry.nutriscore = doc.nutriscore_grade;
|
||||
}
|
||||
|
||||
if (Array.isArray(doc.categories_tags) && doc.categories_tags.length > 0) {
|
||||
const cat = String(doc.categories_tags[doc.categories_tags.length - 1])
|
||||
.replace(/^en:/, '').replace(/-/g, ' ');
|
||||
entry.category = cat;
|
||||
}
|
||||
|
||||
batch.push(entry);
|
||||
|
||||
if (batch.length >= BATCH_SIZE) {
|
||||
try {
|
||||
await dst.insertMany(batch, { ordered: false });
|
||||
inserted += batch.length;
|
||||
} catch (bulkErr: any) {
|
||||
// Duplicate key errors are expected (duplicate barcodes in OFF data)
|
||||
inserted += bulkErr.insertedCount ?? 0;
|
||||
}
|
||||
batch = [];
|
||||
if (processed % 100000 === 0) {
|
||||
console.log(` ${processed.toLocaleString()} processed, ${inserted.toLocaleString()} inserted, ${skipped.toLocaleString()} skipped`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
try {
|
||||
await dst.insertMany(batch, { ordered: false });
|
||||
inserted += batch.length;
|
||||
} catch (bulkErr: any) {
|
||||
inserted += bulkErr.insertedCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTransform complete: ${processed.toLocaleString()} processed → ${inserted.toLocaleString()} inserted, ${skipped.toLocaleString()} skipped`);
|
||||
|
||||
// --- Step 3: Deduplicate & create indexes ---
|
||||
console.log('\n=== Step 3: Deduplicate & create indexes ===\n');
|
||||
|
||||
// Remove duplicate barcodes (keep first inserted)
|
||||
const dupes = await dst.aggregate([
|
||||
{ $group: { _id: '$barcode', ids: { $push: '$_id' }, count: { $sum: 1 } } },
|
||||
{ $match: { count: { $gt: 1 } } },
|
||||
]).toArray();
|
||||
if (dupes.length > 0) {
|
||||
const idsToRemove = dupes.flatMap(d => d.ids.slice(1));
|
||||
await dst.deleteMany({ _id: { $in: idsToRemove } });
|
||||
console.log(` ✓ removed ${idsToRemove.length} duplicate barcodes`);
|
||||
}
|
||||
|
||||
await dst.createIndex({ barcode: 1 }, { unique: true });
|
||||
console.log(' ✓ barcode (unique)');
|
||||
await dst.createIndex({ name: 'text', nameDe: 'text', brands: 'text' });
|
||||
console.log(' ✓ text (name, nameDe, brands)');
|
||||
|
||||
// --- Step 4: Cleanup (manual) ---
|
||||
// To drop the large off_products temp collection after verifying results:
|
||||
// db.off_products.drop()
|
||||
console.log('\n=== Step 4: Skipping off_products cleanup (run manually when satisfied) ===');
|
||||
|
||||
const finalCount = await dst.countDocuments();
|
||||
console.log(`\n=== Done: openfoodfacts collection has ${finalCount.toLocaleString()} documents ===\n`);
|
||||
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
371
scripts/import-usda-nutrition.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Imports USDA FoodData Central data (SR Legacy + Foundation Foods) and generates
|
||||
* a typed nutrition database for the recipe calorie calculator.
|
||||
*
|
||||
* Run with: pnpm exec vite-node scripts/import-usda-nutrition.ts
|
||||
*
|
||||
* Downloads bulk CSV data from USDA FDC, filters to relevant food categories,
|
||||
* extracts macro/micronutrient data per 100g, and outputs src/lib/data/nutritionDb.ts
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const DATA_DIR = resolve('data/usda');
|
||||
const OUTPUT_PATH = resolve('src/lib/data/nutritionDb.ts');
|
||||
|
||||
// USDA FDC bulk download URLs
|
||||
const USDA_URLS = {
|
||||
srLegacy: 'https://fdc.nal.usda.gov/fdc-datasets/FoodData_Central_sr_legacy_food_csv_2018-04.zip',
|
||||
foundation: 'https://fdc.nal.usda.gov/fdc-datasets/FoodData_Central_foundation_food_csv_2024-10-31.zip',
|
||||
};
|
||||
|
||||
// Nutrient IDs we care about
|
||||
const NUTRIENT_IDS: Record<number, string> = {
|
||||
1008: 'calories',
|
||||
1003: 'protein',
|
||||
1004: 'fat',
|
||||
1258: 'saturatedFat',
|
||||
1005: 'carbs',
|
||||
1079: 'fiber',
|
||||
1063: 'sugars',
|
||||
// Minerals
|
||||
1087: 'calcium',
|
||||
1089: 'iron',
|
||||
1090: 'magnesium',
|
||||
1091: 'phosphorus',
|
||||
1092: 'potassium',
|
||||
1093: 'sodium',
|
||||
1095: 'zinc',
|
||||
// Vitamins
|
||||
1106: 'vitaminA', // RAE (mcg)
|
||||
1162: 'vitaminC',
|
||||
1114: 'vitaminD', // D2+D3 (mcg)
|
||||
1109: 'vitaminE',
|
||||
1185: 'vitaminK',
|
||||
1165: 'thiamin',
|
||||
1166: 'riboflavin',
|
||||
1167: 'niacin',
|
||||
1175: 'vitaminB6',
|
||||
1178: 'vitaminB12',
|
||||
1177: 'folate',
|
||||
// Other
|
||||
1253: 'cholesterol',
|
||||
// Amino acids (g/100g)
|
||||
1212: 'isoleucine',
|
||||
1213: 'leucine',
|
||||
1214: 'lysine',
|
||||
1215: 'methionine',
|
||||
1217: 'phenylalanine',
|
||||
1211: 'threonine',
|
||||
1210: 'tryptophan',
|
||||
1219: 'valine',
|
||||
1221: 'histidine',
|
||||
1222: 'alanine',
|
||||
1220: 'arginine',
|
||||
1223: 'asparticAcid',
|
||||
1216: 'cysteine',
|
||||
1224: 'glutamicAcid',
|
||||
1225: 'glycine',
|
||||
1226: 'proline',
|
||||
1227: 'serine',
|
||||
1218: 'tyrosine',
|
||||
};
|
||||
|
||||
// Food categories to include (SR Legacy food_category_id descriptions)
|
||||
const INCLUDED_CATEGORIES = new Set([
|
||||
'Dairy and Egg Products',
|
||||
'Spices and Herbs',
|
||||
'Baby Foods',
|
||||
'Fats and Oils',
|
||||
'Poultry Products',
|
||||
'Soups, Sauces, and Gravies',
|
||||
'Sausages and Luncheon Meats',
|
||||
'Breakfast Cereals',
|
||||
'Fruits and Fruit Juices',
|
||||
'Pork Products',
|
||||
'Vegetables and Vegetable Products',
|
||||
'Nut and Seed Products',
|
||||
'Beef Products',
|
||||
'Beverages',
|
||||
'Finfish and Shellfish Products',
|
||||
'Legumes and Legume Products',
|
||||
'Lamb, Veal, and Game Products',
|
||||
'Baked Products',
|
||||
'Sweets',
|
||||
'Cereal Grains and Pasta',
|
||||
'Snacks',
|
||||
'Restaurant Foods',
|
||||
]);
|
||||
|
||||
type NutrientData = Record<string, number>;
|
||||
|
||||
interface RawFood {
|
||||
fdcId: number;
|
||||
description: string;
|
||||
categoryId: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface Portion {
|
||||
description: string;
|
||||
grams: number;
|
||||
}
|
||||
|
||||
// Simple CSV line parser that handles quoted fields
|
||||
function parseCSVLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') {
|
||||
if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
fields.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
fields.push(current);
|
||||
return fields;
|
||||
}
|
||||
|
||||
async function readCSV(filePath: string): Promise<Record<string, string>[]> {
|
||||
if (!existsSync(filePath)) {
|
||||
console.warn(` File not found: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const headers = parseCSVLine(lines[0]);
|
||||
const rows: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const fields = parseCSVLine(lines[i]);
|
||||
const row: Record<string, string> = {};
|
||||
for (let j = 0; j < headers.length; j++) {
|
||||
row[headers[j]] = fields[j] || '';
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function downloadAndExtract(url: string, targetDir: string): Promise<void> {
|
||||
const zipName = url.split('/').pop()!;
|
||||
const zipPath = resolve(DATA_DIR, zipName);
|
||||
|
||||
if (existsSync(targetDir) && readFileSync(resolve(targetDir, '.done'), 'utf-8').trim() === 'ok') {
|
||||
console.log(` Already extracted: ${targetDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
if (!existsSync(zipPath)) {
|
||||
console.log(` Downloading ${zipName}...`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
writeFileSync(zipPath, buffer);
|
||||
console.log(` Downloaded ${(buffer.length / 1024 / 1024).toFixed(1)}MB`);
|
||||
}
|
||||
|
||||
console.log(` Extracting to ${targetDir}...`);
|
||||
const { execSync } = await import('child_process');
|
||||
execSync(`unzip -o -j "${zipPath}" -d "${targetDir}"`, { stdio: 'pipe' });
|
||||
writeFileSync(resolve(targetDir, '.done'), 'ok');
|
||||
}
|
||||
|
||||
async function importDataset(datasetDir: string, label: string) {
|
||||
console.log(`\nProcessing ${label}...`);
|
||||
|
||||
// Read category mapping
|
||||
const categoryRows = await readCSV(resolve(datasetDir, 'food_category.csv'));
|
||||
const categoryMap = new Map<string, string>();
|
||||
for (const row of categoryRows) {
|
||||
categoryMap.set(row['id'], row['description']);
|
||||
}
|
||||
|
||||
// Read foods
|
||||
const foodRows = await readCSV(resolve(datasetDir, 'food.csv'));
|
||||
const foods = new Map<number, RawFood>();
|
||||
|
||||
for (const row of foodRows) {
|
||||
const catId = parseInt(row['food_category_id'] || '0');
|
||||
const category = categoryMap.get(row['food_category_id']) || '';
|
||||
|
||||
if (!INCLUDED_CATEGORIES.has(category)) continue;
|
||||
|
||||
const fdcId = parseInt(row['fdc_id']);
|
||||
foods.set(fdcId, {
|
||||
fdcId,
|
||||
description: row['description'],
|
||||
categoryId: catId,
|
||||
category,
|
||||
});
|
||||
}
|
||||
console.log(` Found ${foods.size} foods in included categories`);
|
||||
|
||||
// Read nutrients
|
||||
const nutrientRows = await readCSV(resolve(datasetDir, 'food_nutrient.csv'));
|
||||
const nutrients = new Map<number, NutrientData>();
|
||||
|
||||
for (const row of nutrientRows) {
|
||||
const fdcId = parseInt(row['fdc_id']);
|
||||
if (!foods.has(fdcId)) continue;
|
||||
|
||||
const nutrientId = parseInt(row['nutrient_id']);
|
||||
const fieldName = NUTRIENT_IDS[nutrientId];
|
||||
if (!fieldName) continue;
|
||||
|
||||
if (!nutrients.has(fdcId)) nutrients.set(fdcId, {});
|
||||
const amount = parseFloat(row['amount'] || '0');
|
||||
if (!isNaN(amount)) {
|
||||
nutrients.get(fdcId)![fieldName] = amount;
|
||||
}
|
||||
}
|
||||
console.log(` Loaded nutrients for ${nutrients.size} foods`);
|
||||
|
||||
// Read portions
|
||||
const portionRows = await readCSV(resolve(datasetDir, 'food_portion.csv'));
|
||||
const portions = new Map<number, Portion[]>();
|
||||
|
||||
for (const row of portionRows) {
|
||||
const fdcId = parseInt(row['fdc_id']);
|
||||
if (!foods.has(fdcId)) continue;
|
||||
|
||||
const gramWeight = parseFloat(row['gram_weight'] || '0');
|
||||
if (!gramWeight || isNaN(gramWeight)) continue;
|
||||
|
||||
// Build description from amount + modifier/description
|
||||
const amount = parseFloat(row['amount'] || '1');
|
||||
const modifier = row['modifier'] || row['portion_description'] || '';
|
||||
const desc = modifier
|
||||
? (amount !== 1 ? `${amount} ${modifier}` : modifier)
|
||||
: `${amount} unit`;
|
||||
|
||||
if (!portions.has(fdcId)) portions.set(fdcId, []);
|
||||
portions.get(fdcId)!.push({ description: desc, grams: Math.round(gramWeight * 100) / 100 });
|
||||
}
|
||||
console.log(` Loaded portions for ${portions.size} foods`);
|
||||
|
||||
return { foods, nutrients, portions };
|
||||
}
|
||||
|
||||
function buildNutrientRecord(data: NutrientData | undefined): Record<string, number> {
|
||||
const allFields = Object.values(NUTRIENT_IDS);
|
||||
const result: Record<string, number> = {};
|
||||
for (const field of allFields) {
|
||||
result[field] = Math.round((data?.[field] || 0) * 100) / 100;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== USDA Nutrition Database Import ===\n');
|
||||
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
// Download and extract datasets
|
||||
const srDir = resolve(DATA_DIR, 'sr_legacy');
|
||||
const foundationDir = resolve(DATA_DIR, 'foundation');
|
||||
|
||||
await downloadAndExtract(USDA_URLS.srLegacy, srDir);
|
||||
await downloadAndExtract(USDA_URLS.foundation, foundationDir);
|
||||
|
||||
// Import both datasets
|
||||
const sr = await importDataset(srDir, 'SR Legacy');
|
||||
const foundation = await importDataset(foundationDir, 'Foundation Foods');
|
||||
|
||||
// Merge: Foundation Foods takes priority (more detailed), SR Legacy fills gaps
|
||||
const merged = new Map<string, {
|
||||
fdcId: number;
|
||||
name: string;
|
||||
category: string;
|
||||
per100g: Record<string, number>;
|
||||
portions: Portion[];
|
||||
}>();
|
||||
|
||||
// Add SR Legacy first
|
||||
for (const [fdcId, food] of sr.foods) {
|
||||
const nutrientData = buildNutrientRecord(sr.nutrients.get(fdcId));
|
||||
// Skip entries with no nutrient data at all
|
||||
if (!sr.nutrients.has(fdcId)) continue;
|
||||
|
||||
merged.set(food.description.toLowerCase(), {
|
||||
fdcId,
|
||||
name: food.description,
|
||||
category: food.category,
|
||||
per100g: nutrientData,
|
||||
portions: sr.portions.get(fdcId) || [],
|
||||
});
|
||||
}
|
||||
|
||||
// Override with Foundation Foods where available
|
||||
for (const [fdcId, food] of foundation.foods) {
|
||||
const nutrientData = buildNutrientRecord(foundation.nutrients.get(fdcId));
|
||||
if (!foundation.nutrients.has(fdcId)) continue;
|
||||
|
||||
merged.set(food.description.toLowerCase(), {
|
||||
fdcId,
|
||||
name: food.description,
|
||||
category: food.category,
|
||||
per100g: nutrientData,
|
||||
portions: foundation.portions.get(fdcId) || [],
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\nMerged total: ${merged.size} unique foods`);
|
||||
|
||||
// Sort by name for stable output
|
||||
const entries = [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Generate TypeScript output
|
||||
const tsContent = `// Auto-generated from USDA FoodData Central (SR Legacy + Foundation Foods)
|
||||
// Generated: ${new Date().toISOString().split('T')[0]}
|
||||
// Do not edit manually — regenerate with: pnpm exec vite-node scripts/import-usda-nutrition.ts
|
||||
|
||||
import type { NutritionPer100g } from '$types/types';
|
||||
|
||||
export type NutritionEntry = {
|
||||
fdcId: number;
|
||||
name: string;
|
||||
category: string;
|
||||
per100g: NutritionPer100g;
|
||||
portions: { description: string; grams: number }[];
|
||||
};
|
||||
|
||||
export const NUTRITION_DB: NutritionEntry[] = ${JSON.stringify(entries, null, '\t')};
|
||||
`;
|
||||
|
||||
writeFileSync(OUTPUT_PATH, tsContent, 'utf-8');
|
||||
console.log(`\nWritten ${entries.length} entries to ${OUTPUT_PATH}`);
|
||||
|
||||
// Print category breakdown
|
||||
const categoryCounts = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
categoryCounts.set(entry.category, (categoryCounts.get(entry.category) || 0) + 1);
|
||||
}
|
||||
console.log('\nCategory breakdown:');
|
||||
for (const [cat, count] of [...categoryCounts.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${cat}: ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Import failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
61
scripts/process-gemini-icons.sh
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Process raw Gemini-generated shopping icons:
|
||||
# 1. Crop out the bottom-right watermark (sparkle)
|
||||
# 2. Remove solid black background → transparent
|
||||
# 3. Trim whitespace/transparent padding
|
||||
#
|
||||
# Usage: ./scripts/process-gemini-icons.sh [file...]
|
||||
# No args: processes all unprocessed gemini_raw-*.png in static/shopping-icons/
|
||||
# With args: processes only the specified raw files
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ICON_DIR="static/shopping-icons"
|
||||
|
||||
# Collect files to process
|
||||
if [ $# -gt 0 ]; then
|
||||
files=("$@")
|
||||
else
|
||||
files=()
|
||||
for raw in "$ICON_DIR"/gemini_raw-*.png; do
|
||||
[ -f "$raw" ] || continue
|
||||
name=$(basename "$raw" | sed 's/gemini_raw-//')
|
||||
if [ ! -f "$ICON_DIR/$name" ]; then
|
||||
files+=("$raw")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No unprocessed icons found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Processing ${#files[@]} icon(s)..."
|
||||
|
||||
for raw in "${files[@]}"; do
|
||||
name=$(basename "$raw" | sed 's/gemini_raw-//')
|
||||
out="$ICON_DIR/$name"
|
||||
|
||||
echo " $name"
|
||||
|
||||
# Get image dimensions
|
||||
dims=$(identify -format '%wx%h' "$raw")
|
||||
w=${dims%x*}
|
||||
h=${dims#*x}
|
||||
|
||||
# 1. Cover watermark sparkle in bottom-right with black
|
||||
# 2. Remove all black → transparent
|
||||
# 3. Trim transparent padding
|
||||
wm_size=$(( w * 8 / 100 ))
|
||||
wm_x=$(( w - wm_size ))
|
||||
wm_y=$(( h - wm_size ))
|
||||
|
||||
magick "$raw" \
|
||||
-fill black -draw "rectangle ${wm_x},${wm_y} ${w},${h}" \
|
||||
-fuzz 25% -transparent black \
|
||||
-trim +repage \
|
||||
"$out"
|
||||
done
|
||||
|
||||
echo "Done."
|
||||
@@ -1,69 +0,0 @@
|
||||
# Formatter Replacement Progress
|
||||
|
||||
## Components Completed ✅
|
||||
1. DebtBreakdown.svelte - Replaced formatCurrency function
|
||||
2. EnhancedBalance.svelte - Replaced formatCurrency function (with Math.abs wrapper)
|
||||
|
||||
## Remaining Files to Update
|
||||
|
||||
### Components (3 files)
|
||||
- [ ] PaymentModal.svelte - Has formatCurrency function
|
||||
- [ ] SplitMethodSelector.svelte - Has inline .toFixed() calls
|
||||
- [ ] BarChart.svelte - Has inline .toFixed() calls
|
||||
- [ ] IngredientsPage.svelte - Has .toFixed() for recipe calculations
|
||||
|
||||
### Cospend Pages (7 files)
|
||||
- [ ] routes/cospend/+page.svelte - Has formatCurrency function
|
||||
- [ ] routes/cospend/payments/+page.svelte - Has formatCurrency function
|
||||
- [ ] routes/cospend/payments/view/[id]/+page.svelte - Has formatCurrency and .toFixed()
|
||||
- [ ] routes/cospend/payments/add/+page.svelte - Has .toFixed() and .toLocaleString()
|
||||
- [ ] routes/cospend/payments/edit/[id]/+page.svelte - Has multiple .toFixed() calls
|
||||
- [ ] routes/cospend/recurring/+page.svelte - Has formatCurrency function
|
||||
- [ ] routes/cospend/recurring/edit/[id]/+page.svelte - Has .toFixed() and .toLocaleString()
|
||||
- [ ] routes/cospend/settle/+page.svelte - Has formatCurrency function
|
||||
|
||||
## Replacement Strategy
|
||||
|
||||
### Pattern 1: Identical formatCurrency functions
|
||||
```typescript
|
||||
// OLD
|
||||
function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// NEW
|
||||
import { formatCurrency } from '$lib/utils/formatters';
|
||||
// Usage: formatCurrency(amount, 'CHF', 'de-CH')
|
||||
```
|
||||
|
||||
### Pattern 2: .toFixed() for currency display
|
||||
```typescript
|
||||
// OLD
|
||||
{payment.amount.toFixed(2)}
|
||||
|
||||
// NEW
|
||||
import { formatNumber } from '$lib/utils/formatters';
|
||||
{formatNumber(payment.amount, 2, 'de-CH')}
|
||||
```
|
||||
|
||||
### Pattern 3: .toLocaleString() for dates
|
||||
```typescript
|
||||
// OLD
|
||||
nextDate.toLocaleString('de-CH', { weekday: 'long', ... })
|
||||
|
||||
// NEW
|
||||
import { formatDateTime } from '$lib/utils/formatters';
|
||||
formatDateTime(nextDate, 'de-CH', { weekday: 'long', ... })
|
||||
```
|
||||
|
||||
### Pattern 4: Exchange rate display (4 decimals)
|
||||
```typescript
|
||||
// OLD
|
||||
{exchangeRate.toFixed(4)}
|
||||
|
||||
// NEW
|
||||
{formatNumber(exchangeRate, 4, 'de-CH')}
|
||||
```
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to replace inline formatCurrency functions with shared formatter utilities
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
files_to_update = [
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/+page.svelte",
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/payments/+page.svelte",
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/payments/view/[id]/+page.svelte",
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/recurring/+page.svelte",
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/settle/+page.svelte",
|
||||
]
|
||||
|
||||
def process_file(filepath):
|
||||
print(f"Processing: {filepath}")
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
original_content = content
|
||||
|
||||
# Check if already has the import
|
||||
has_formatter_import = 'from \'$lib/utils/formatters\'' in content or 'from "$lib/utils/formatters"' in content
|
||||
|
||||
# Find the <script> tag
|
||||
script_match = re.search(r'(<script[^>]*>)', content)
|
||||
if not script_match:
|
||||
print(f" ⚠️ No <script> tag found")
|
||||
return False
|
||||
|
||||
# Add import if not present
|
||||
if not has_formatter_import:
|
||||
script_tag = script_match.group(1)
|
||||
# Find where to insert (after <script> tag)
|
||||
script_end = script_match.end()
|
||||
|
||||
# Get existing imports to find the right place
|
||||
imports_section_match = re.search(r'<script[^>]*>(.*?)(?:\n\n|\n export|\n let)', content, re.DOTALL)
|
||||
if imports_section_match:
|
||||
imports_end = imports_section_match.end() - len(imports_section_match.group(0).split('\n')[-1])
|
||||
insert_pos = imports_end
|
||||
else:
|
||||
insert_pos = script_end
|
||||
|
||||
new_import = "\n import { formatCurrency } from '$lib/utils/formatters';"
|
||||
content = content[:insert_pos] + new_import + content[insert_pos:]
|
||||
print(f" ✓ Added import")
|
||||
|
||||
# Remove the formatCurrency function definition
|
||||
# Pattern for the function with different variations
|
||||
patterns = [
|
||||
r'\n function formatCurrency\(amount\) \{\n return new Intl\.NumberFormat\(\'de-CH\',\s*\{\n\s*style:\s*\'currency\',\n\s*currency:\s*\'CHF\'\n\s*\}\)\.format\(amount\);\n \}',
|
||||
r'\n function formatCurrency\(amount,\s*currency\s*=\s*\'CHF\'\) \{\n return new Intl\.NumberFormat\(\'de-CH\',\s*\{\n\s*style:\s*\'currency\',\n\s*currency:\s*currency\n\s*\}\)\.format\(amount\);\n \}',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, content):
|
||||
content = re.sub(pattern, '', content)
|
||||
print(f" ✓ Removed formatCurrency function")
|
||||
break
|
||||
|
||||
# Check if content changed
|
||||
if content != original_content:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f" ✅ Updated successfully")
|
||||
return True
|
||||
else:
|
||||
print(f" ⚠️ No changes needed")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Replacing formatCurrency functions with shared utilities")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = 0
|
||||
for filepath in files_to_update:
|
||||
if process_file(filepath):
|
||||
success_count += 1
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print(f"Summary: {success_count}/{len(files_to_update)} files updated")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,205 +1,156 @@
|
||||
import { dbConnect } from '../src/utils/db';
|
||||
import { Exercise } from '../src/models/Exercise';
|
||||
/**
|
||||
* Scrapes the full ExerciseDB v2 API (via RapidAPI) and saves raw data.
|
||||
*
|
||||
* Run with: RAPIDAPI_KEY=... pnpm exec vite-node scripts/scrape-exercises.ts
|
||||
*
|
||||
* Outputs: src/lib/data/exercisedb-raw.json
|
||||
*
|
||||
* Supports resuming — already-fetched exercises are read from the output file
|
||||
* and skipped. Saves to disk after every detail fetch.
|
||||
*/
|
||||
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// ExerciseDB API configuration
|
||||
const RAPIDAPI_KEY = process.env.RAPIDAPI_KEY || 'your-rapidapi-key-here';
|
||||
const RAPIDAPI_HOST = 'exercisedb.p.rapidapi.com';
|
||||
const BASE_URL = 'https://exercisedb.p.rapidapi.com';
|
||||
|
||||
interface ExerciseDBExercise {
|
||||
id: string;
|
||||
name: string;
|
||||
gifUrl: string;
|
||||
bodyPart: string;
|
||||
equipment: string;
|
||||
target: string;
|
||||
secondaryMuscles: string[];
|
||||
instructions: string[];
|
||||
const API_HOST = 'edb-with-videos-and-images-by-ascendapi.p.rapidapi.com';
|
||||
const API_KEY = process.env.RAPIDAPI_KEY;
|
||||
if (!API_KEY) {
|
||||
console.error('Set RAPIDAPI_KEY environment variable');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function fetchFromExerciseDB(endpoint: string): Promise<any> {
|
||||
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
'X-RapidAPI-Key': RAPIDAPI_KEY,
|
||||
'X-RapidAPI-Host': RAPIDAPI_HOST
|
||||
}
|
||||
});
|
||||
const BASE = `https://${API_HOST}/api/v1`;
|
||||
const HEADERS = {
|
||||
'x-rapidapi-host': API_HOST,
|
||||
'x-rapidapi-key': API_KEY,
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch from ExerciseDB: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const OUTPUT_PATH = resolve('src/lib/data/exercisedb-raw.json');
|
||||
const IDS_CACHE_PATH = resolve('src/lib/data/.exercisedb-ids.json');
|
||||
const DELAY_MS = 1500;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
return response.json();
|
||||
function sleep(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function scrapeAllExercises(): Promise<void> {
|
||||
console.log('🚀 Starting ExerciseDB scraping...');
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
console.log('✅ Connected to database');
|
||||
|
||||
// Fetch all exercises
|
||||
console.log('📡 Fetching exercises from ExerciseDB...');
|
||||
const exercises: ExerciseDBExercise[] = await fetchFromExerciseDB('/exercises?limit=2000');
|
||||
|
||||
console.log(`📊 Found ${exercises.length} exercises`);
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const exercise of exercises) {
|
||||
try {
|
||||
// Check if exercise already exists
|
||||
const existingExercise = await Exercise.findOne({ exerciseId: exercise.id });
|
||||
|
||||
if (existingExercise) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine difficulty based on equipment and complexity
|
||||
let difficulty: 'beginner' | 'intermediate' | 'advanced' = 'intermediate';
|
||||
|
||||
if (exercise.equipment === 'body weight') {
|
||||
difficulty = 'beginner';
|
||||
} else if (exercise.equipment.includes('barbell') || exercise.equipment.includes('olympic')) {
|
||||
difficulty = 'advanced';
|
||||
} else if (exercise.equipment.includes('dumbbell') || exercise.equipment.includes('cable')) {
|
||||
difficulty = 'intermediate';
|
||||
}
|
||||
|
||||
// Create new exercise
|
||||
const newExercise = new Exercise({
|
||||
exerciseId: exercise.id,
|
||||
name: exercise.name,
|
||||
gifUrl: exercise.gifUrl,
|
||||
bodyPart: exercise.bodyPart.toLowerCase(),
|
||||
equipment: exercise.equipment.toLowerCase(),
|
||||
target: exercise.target.toLowerCase(),
|
||||
secondaryMuscles: exercise.secondaryMuscles.map(m => m.toLowerCase()),
|
||||
instructions: exercise.instructions,
|
||||
difficulty,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
await newExercise.save();
|
||||
imported++;
|
||||
|
||||
if (imported % 100 === 0) {
|
||||
console.log(`⏳ Imported ${imported} exercises...`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error importing exercise ${exercise.name}:`, error);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Scraping completed!');
|
||||
console.log(`📈 Summary: ${imported} imported, ${skipped} skipped, ${errors} errors`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('💥 Scraping failed:', error);
|
||||
throw error;
|
||||
}
|
||||
async function apiFetch(path: string, attempt = 1): Promise<any> {
|
||||
const res = await fetch(`${BASE}${path}`, { headers: HEADERS });
|
||||
if (res.status === 429 && attempt <= MAX_RETRIES) {
|
||||
const wait = DELAY_MS * 2 ** attempt;
|
||||
console.warn(` rate limited on ${path}, retrying in ${wait}ms...`);
|
||||
await sleep(wait);
|
||||
return apiFetch(path, attempt + 1);
|
||||
}
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function updateExistingExercises(): Promise<void> {
|
||||
console.log('🔄 Updating existing exercises...');
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
|
||||
const exercises: ExerciseDBExercise[] = await fetchFromExerciseDB('/exercises?limit=2000');
|
||||
|
||||
let updated = 0;
|
||||
|
||||
for (const exercise of exercises) {
|
||||
try {
|
||||
const existingExercise = await Exercise.findOne({ exerciseId: exercise.id });
|
||||
|
||||
if (existingExercise) {
|
||||
// Update with new data from API
|
||||
existingExercise.name = exercise.name;
|
||||
existingExercise.gifUrl = exercise.gifUrl;
|
||||
existingExercise.bodyPart = exercise.bodyPart.toLowerCase();
|
||||
existingExercise.equipment = exercise.equipment.toLowerCase();
|
||||
existingExercise.target = exercise.target.toLowerCase();
|
||||
existingExercise.secondaryMuscles = exercise.secondaryMuscles.map(m => m.toLowerCase());
|
||||
existingExercise.instructions = exercise.instructions;
|
||||
|
||||
await existingExercise.save();
|
||||
updated++;
|
||||
|
||||
if (updated % 100 === 0) {
|
||||
console.log(`⏳ Updated ${updated} exercises...`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error updating exercise ${exercise.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Updated ${updated} exercises`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('💥 Update failed:', error);
|
||||
throw error;
|
||||
}
|
||||
function loadExisting(): { metadata: any; exercises: any[] } | null {
|
||||
if (!existsSync(OUTPUT_PATH)) return null;
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(OUTPUT_PATH, 'utf-8'));
|
||||
if (data.exercises?.length) {
|
||||
console.log(` found existing file with ${data.exercises.length} exercises`);
|
||||
return { metadata: data.metadata, exercises: data.exercises };
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getExerciseStats(): Promise<void> {
|
||||
try {
|
||||
await dbConnect();
|
||||
|
||||
const totalExercises = await Exercise.countDocuments();
|
||||
const activeExercises = await Exercise.countDocuments({ isActive: true });
|
||||
|
||||
const bodyParts = await Exercise.distinct('bodyPart');
|
||||
const equipment = await Exercise.distinct('equipment');
|
||||
const targets = await Exercise.distinct('target');
|
||||
|
||||
console.log('📊 Exercise Database Stats:');
|
||||
console.log(` Total exercises: ${totalExercises}`);
|
||||
console.log(` Active exercises: ${activeExercises}`);
|
||||
console.log(` Body parts: ${bodyParts.length} (${bodyParts.join(', ')})`);
|
||||
console.log(` Equipment types: ${equipment.length}`);
|
||||
console.log(` Target muscles: ${targets.length}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('💥 Stats failed:', error);
|
||||
}
|
||||
function saveToDisk(metadata: any, exercises: any[]) {
|
||||
const output = {
|
||||
scrapedAt: new Date().toISOString(),
|
||||
metadata,
|
||||
exercises,
|
||||
};
|
||||
writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2));
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
const command = process.argv[2];
|
||||
async function fetchAllIds(): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
switch (command) {
|
||||
case 'scrape':
|
||||
scrapeAllExercises()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
updateExistingExercises()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
break;
|
||||
|
||||
case 'stats':
|
||||
getExerciseStats()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Usage: tsx scripts/scrape-exercises.ts [command]');
|
||||
console.log('Commands:');
|
||||
console.log(' scrape - Import all exercises from ExerciseDB');
|
||||
console.log(' update - Update existing exercises with latest data');
|
||||
console.log(' stats - Show database statistics');
|
||||
process.exit(0);
|
||||
}
|
||||
while (true) {
|
||||
const params = new URLSearchParams({ limit: '100' });
|
||||
if (cursor) params.set('after', cursor);
|
||||
|
||||
const res = await apiFetch(`/exercises?${params}`);
|
||||
for (const ex of res.data) {
|
||||
ids.push(ex.exerciseId);
|
||||
}
|
||||
console.log(` fetched page, ${ids.length} IDs so far`);
|
||||
|
||||
if (!res.meta.hasNextPage) break;
|
||||
cursor = res.meta.nextCursor;
|
||||
await sleep(DELAY_MS);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function fetchMetadata() {
|
||||
const endpoints = ['/bodyparts', '/equipments', '/muscles', '/exercisetypes'] as const;
|
||||
const keys = ['bodyParts', 'equipments', 'muscles', 'exerciseTypes'] as const;
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
const res = await apiFetch(endpoints[i]);
|
||||
result[keys[i]] = res.data;
|
||||
await sleep(DELAY_MS);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== ExerciseDB v2 Scraper ===\n');
|
||||
|
||||
const existing = loadExisting();
|
||||
const fetchedIds = new Set(existing?.exercises.map((e: any) => e.exerciseId) ?? []);
|
||||
|
||||
console.log('Fetching metadata...');
|
||||
const metadata = existing?.metadata ?? await fetchMetadata();
|
||||
if (!existing?.metadata) {
|
||||
console.log(` ${metadata.bodyParts.length} body parts, ${metadata.equipments.length} equipments, ${metadata.muscles.length} muscles, ${metadata.exerciseTypes.length} exercise types\n`);
|
||||
} else {
|
||||
console.log(' using cached metadata\n');
|
||||
}
|
||||
|
||||
let ids: string[];
|
||||
if (existsSync(IDS_CACHE_PATH)) {
|
||||
ids = JSON.parse(readFileSync(IDS_CACHE_PATH, 'utf-8'));
|
||||
console.log(`Using cached exercise IDs (${ids.length})\n`);
|
||||
} else {
|
||||
console.log('Fetching exercise IDs...');
|
||||
ids = await fetchAllIds();
|
||||
writeFileSync(IDS_CACHE_PATH, JSON.stringify(ids));
|
||||
console.log(` ${ids.length} total exercises\n`);
|
||||
}
|
||||
|
||||
const remaining = ids.filter(id => !fetchedIds.has(id));
|
||||
if (remaining.length === 0) {
|
||||
console.log('All exercises already fetched!');
|
||||
return;
|
||||
}
|
||||
console.log(`Fetching ${remaining.length} remaining details (${fetchedIds.size} already cached)...`);
|
||||
|
||||
const exercises = [...(existing?.exercises ?? [])];
|
||||
|
||||
for (const id of remaining) {
|
||||
const detail = await apiFetch(`/exercises/${id}`);
|
||||
exercises.push(detail.data);
|
||||
saveToDisk(metadata, exercises);
|
||||
|
||||
if (exercises.length % 10 === 0 || exercises.length === ids.length) {
|
||||
console.log(` ${exercises.length}/${ids.length} details fetched`);
|
||||
}
|
||||
await sleep(DELAY_MS);
|
||||
}
|
||||
|
||||
console.log(`\nDone! ${exercises.length} exercises written to ${OUTPUT_PATH}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update all files importing from the legacy $lib/db/db to use $utils/db instead
|
||||
|
||||
files=(
|
||||
"/home/alex/.local/src/homepage/src/routes/mario-kart/[id]/+page.server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/mario-kart/+page.server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/fitness/sessions/[id]/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/fitness/sessions/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/fitness/templates/[id]/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/fitness/templates/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/fitness/exercises/[id]/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/fitness/exercises/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/fitness/exercises/filters/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/fitness/seed-example/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/groups/[groupId]/scores/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/groups/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/contestants/[contestantId]/dnf/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/contestants/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/bracket/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/bracket/matches/[matchId]/scores/+server.ts"
|
||||
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/+server.ts"
|
||||
)
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "Updating $file"
|
||||
sed -i "s/from '\$lib\/db\/db'/from '\$utils\/db'/g" "$file"
|
||||
else
|
||||
echo "File not found: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All files updated!"
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to update formatCurrency calls to include CHF and de-CH parameters
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
files_to_update = [
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/+page.svelte",
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/payments/+page.svelte",
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/payments/view/[id]/+page.svelte",
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/recurring/+page.svelte",
|
||||
"/home/alex/.local/src/homepage/src/routes/cospend/settle/+page.svelte",
|
||||
]
|
||||
|
||||
def process_file(filepath):
|
||||
print(f"Processing: {filepath}")
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
original_content = content
|
||||
changes = 0
|
||||
|
||||
# Pattern 1: formatCurrency(amount) -> formatCurrency(amount, 'CHF', 'de-CH')
|
||||
# But skip if already has parameters
|
||||
def replace_single_param(match):
|
||||
amount = match.group(1)
|
||||
# Check if amount already contains currency parameter (contains comma followed by quote)
|
||||
if ", '" in amount or ', "' in amount:
|
||||
return match.group(0) # Already has parameters, skip
|
||||
return f"formatCurrency({amount}, 'CHF', 'de-CH')"
|
||||
|
||||
content, count1 = re.subn(
|
||||
r'formatCurrency\(([^)]+)\)',
|
||||
replace_single_param,
|
||||
content
|
||||
)
|
||||
changes += count1
|
||||
|
||||
if changes > 0:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f" ✅ Updated {changes} formatCurrency calls")
|
||||
return True
|
||||
else:
|
||||
print(f" ⚠️ No changes needed")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Updating formatCurrency calls with CHF and de-CH params")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = 0
|
||||
for filepath in files_to_update:
|
||||
if process_file(filepath):
|
||||
success_count += 1
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print(f"Summary: {success_count}/{len(files_to_update)} files updated")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
src-tauri/Cargo.lock
generated
@@ -143,8 +143,8 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bocken-fitness"
|
||||
version = "0.1.0"
|
||||
name = "bocken"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "bocken-fitness"
|
||||
version = "0.1.0"
|
||||
name = "bocken"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "bocken_fitness_lib"
|
||||
name = "bocken_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
12
src-tauri/gen/android/.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
19
src-tauri/gen/android/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
key.properties
|
||||
|
||||
/.tauri
|
||||
/tauri.settings.gradle
|
||||
6
src-tauri/gen/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/src/main/**/generated
|
||||
/src/main/jniLibs/**/*.so
|
||||
/src/main/assets/tauri.conf.json
|
||||
/tauri.build.gradle.kts
|
||||
/proguard-tauri.pro
|
||||
/tauri.properties
|
||||
70
src-tauri/gen/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,70 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("rust")
|
||||
}
|
||||
|
||||
val tauriProperties = Properties().apply {
|
||||
val propFile = file("tauri.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
namespace = "org.bocken.app"
|
||||
defaultConfig {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
applicationId = "org.bocken.app"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
.toList().toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
21
src-tauri/gen/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
56
src-tauri/gen/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.bocken"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- AndroidTV support -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".LocationForegroundService"
|
||||
android:foregroundServiceType="location"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,182 @@
|
||||
package org.bocken.app
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.VibrationAttributes
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.webkit.JavascriptInterface
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
||||
class AndroidBridge(private val context: Context) {
|
||||
|
||||
@JavascriptInterface
|
||||
fun startLocationService(ttsConfigJson: String, startPaused: Boolean) {
|
||||
if (context is Activity) {
|
||||
// Request notification permission on Android 13+ (required for foreground service notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
context,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
1003
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Request background location on Android 10+ (required for screen-off GPS)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
context,
|
||||
arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
|
||||
1002
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(context, LocationForegroundService::class.java).apply {
|
||||
putExtra("ttsConfig", ttsConfigJson)
|
||||
putExtra("startPaused", startPaused)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** Overload: TTS config only (not paused) */
|
||||
@JavascriptInterface
|
||||
fun startLocationService(ttsConfigJson: String) {
|
||||
startLocationService(ttsConfigJson, false)
|
||||
}
|
||||
|
||||
/** Overload: no args (not paused, no TTS) */
|
||||
@JavascriptInterface
|
||||
fun startLocationService() {
|
||||
startLocationService("{}", false)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun stopLocationService() {
|
||||
val intent = Intent(context, LocationForegroundService::class.java)
|
||||
context.stopService(intent)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getPoints(): String {
|
||||
return LocationForegroundService.drainPoints()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun isTracking(): Boolean {
|
||||
return LocationForegroundService.tracking
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun pauseTracking() {
|
||||
LocationForegroundService.instance?.doPause()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun resumeTracking() {
|
||||
LocationForegroundService.instance?.doResume()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getIntervalState(): String {
|
||||
return LocationForegroundService.getIntervalState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-vibrate bypassing silent/DND by using USAGE_ACCESSIBILITY attributes.
|
||||
* Why: default web Vibration API uses USAGE_TOUCH which Android silences.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun forceVibrate(durationMs: Long, intensityPct: Int) {
|
||||
val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
}
|
||||
if (vibrator?.hasVibrator() != true) return
|
||||
|
||||
val amplitude = (intensityPct.coerceIn(1, 100) * 255 / 100).coerceAtLeast(1)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val effect = VibrationEffect.createOneShot(durationMs, amplitude)
|
||||
val attrs = VibrationAttributes.Builder()
|
||||
.setUsage(VibrationAttributes.USAGE_ACCESSIBILITY)
|
||||
.build()
|
||||
vibrator.vibrate(effect, attrs)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if at least one TTS engine is installed on the device. */
|
||||
@JavascriptInterface
|
||||
fun hasTtsEngine(): Boolean {
|
||||
val dummy = TextToSpeech(context, null)
|
||||
val hasEngine = dummy.engines.isNotEmpty()
|
||||
dummy.shutdown()
|
||||
return hasEngine
|
||||
}
|
||||
|
||||
/** Opens the Android TTS install intent (prompts user to install a TTS engine). */
|
||||
@JavascriptInterface
|
||||
fun installTtsEngine() {
|
||||
val intent = Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available TTS voices as a JSON array.
|
||||
* Each entry: { "id": "...", "name": "...", "language": "en-US" }
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun getAvailableTtsVoices(): String {
|
||||
val result = JSONArray()
|
||||
try {
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
var engine: TextToSpeech? = null
|
||||
engine = TextToSpeech(context) { status ->
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
engine?.voices?.forEach { voice ->
|
||||
val obj = JSONObject().apply {
|
||||
put("id", voice.name)
|
||||
put("name", voice.name)
|
||||
put("language", voice.locale.toLanguageTag())
|
||||
}
|
||||
result.put(obj)
|
||||
}
|
||||
}
|
||||
latch.countDown()
|
||||
}
|
||||
latch.await(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
engine.shutdown()
|
||||
} catch (_: Exception) {}
|
||||
return result.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
package org.bocken.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlin.math.*
|
||||
|
||||
private const val TAG = "BockenTTS"
|
||||
|
||||
class LocationForegroundService : Service(), TextToSpeech.OnInitListener, SensorEventListener {
|
||||
|
||||
private var locationManager: LocationManager? = null
|
||||
private var locationListener: LocationListener? = null
|
||||
private var notificationManager: NotificationManager? = null
|
||||
|
||||
// Step detector for cadence
|
||||
private var sensorManager: SensorManager? = null
|
||||
private var stepDetector: Sensor? = null
|
||||
private val stepTimestamps = ConcurrentLinkedQueue<Long>()
|
||||
private val CADENCE_WINDOW_MS = 15_000L // 15 second rolling window
|
||||
private var pendingIntent: PendingIntent? = null
|
||||
private var startTimeMs: Long = 0L
|
||||
private var pausedAccumulatedMs: Long = 0L // total time spent paused
|
||||
private var pausedSinceMs: Long = 0L // timestamp when last paused (0 = not paused)
|
||||
private var lastLat: Double = Double.NaN
|
||||
private var lastLng: Double = Double.NaN
|
||||
private var lastTimestamp: Long = 0L
|
||||
private var currentPaceMinKm: Double = 0.0
|
||||
|
||||
// TTS
|
||||
private var tts: TextToSpeech? = null
|
||||
private var ttsReady = false
|
||||
private var ttsConfig: TtsConfig? = null
|
||||
private var ttsTimeHandler: Handler? = null
|
||||
private var ttsTimeRunnable: Runnable? = null
|
||||
private var lastAnnouncementDistanceKm: Double = 0.0
|
||||
private var lastAnnouncementTimeMs: Long = 0L
|
||||
private var splitDistanceAtLastAnnouncement: Double = 0.0
|
||||
private var splitTimeAtLastAnnouncement: Long = 0L
|
||||
|
||||
// Interval tracking
|
||||
private var intervalSteps: List<IntervalStep> = emptyList()
|
||||
private var currentIntervalIdx: Int = 0
|
||||
private var intervalAccumulatedDistanceKm: Double = 0.0
|
||||
private var intervalStartTimeMs: Long = 0L
|
||||
private var intervalsComplete: Boolean = false
|
||||
|
||||
// Audio focus / ducking
|
||||
private var audioManager: AudioManager? = null
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var hasAudioFocus = false
|
||||
|
||||
data class IntervalStep(
|
||||
val label: String,
|
||||
val durationType: String, // "distance" or "time"
|
||||
val durationValue: Double // meters (distance) or seconds (time)
|
||||
)
|
||||
|
||||
data class TtsConfig(
|
||||
val enabled: Boolean = false,
|
||||
val triggerType: String = "distance", // "distance" or "time"
|
||||
val triggerValue: Double = 1.0, // km or minutes
|
||||
val metrics: List<String> = listOf("totalTime", "totalDistance", "avgPace"),
|
||||
val language: String = "en",
|
||||
val voiceId: String? = null,
|
||||
val ttsVolume: Float = 0.8f, // 0.0–1.0 relative TTS volume
|
||||
val audioDuck: Boolean = false, // duck other audio during TTS
|
||||
val intervals: List<IntervalStep> = emptyList()
|
||||
) {
|
||||
companion object {
|
||||
fun fromJson(json: String): TtsConfig {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val metricsArr = obj.optJSONArray("metrics")
|
||||
val metrics = if (metricsArr != null) {
|
||||
(0 until metricsArr.length()).map { metricsArr.getString(it) }
|
||||
} else {
|
||||
listOf("totalTime", "totalDistance", "avgPace")
|
||||
}
|
||||
val intervalsArr = obj.optJSONArray("intervals")
|
||||
val intervals = if (intervalsArr != null) {
|
||||
(0 until intervalsArr.length()).map { i ->
|
||||
val step = intervalsArr.getJSONObject(i)
|
||||
IntervalStep(
|
||||
label = step.optString("label", ""),
|
||||
durationType = step.optString("durationType", "time"),
|
||||
durationValue = step.optDouble("durationValue", 0.0)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
TtsConfig(
|
||||
enabled = obj.optBoolean("enabled", false),
|
||||
triggerType = obj.optString("triggerType", "distance"),
|
||||
triggerValue = obj.optDouble("triggerValue", 1.0),
|
||||
metrics = metrics,
|
||||
language = obj.optString("language", "en"),
|
||||
voiceId = obj.optString("voiceId", null),
|
||||
ttsVolume = obj.optDouble("ttsVolume", 0.8).toFloat().coerceIn(0f, 1f),
|
||||
audioDuck = obj.optBoolean("audioDuck", false),
|
||||
intervals = intervals
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
TtsConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "gps_tracking"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val MIN_TIME_MS = 3000L
|
||||
const val MIN_DISTANCE_M = 0f
|
||||
|
||||
private val pointBuffer = Collections.synchronizedList(mutableListOf<JSONObject>())
|
||||
var instance: LocationForegroundService? = null
|
||||
private set
|
||||
var tracking = false
|
||||
private set
|
||||
var paused = false
|
||||
private set
|
||||
var totalDistanceKm: Double = 0.0
|
||||
private set
|
||||
|
||||
fun getIntervalState(): String {
|
||||
val svc = instance ?: return "{}"
|
||||
if (svc.intervalSteps.isEmpty()) return "{}"
|
||||
val obj = JSONObject()
|
||||
obj.put("currentIndex", svc.currentIntervalIdx)
|
||||
obj.put("totalSteps", svc.intervalSteps.size)
|
||||
obj.put("complete", svc.intervalsComplete)
|
||||
if (!svc.intervalsComplete && svc.currentIntervalIdx < svc.intervalSteps.size) {
|
||||
val step = svc.intervalSteps[svc.currentIntervalIdx]
|
||||
obj.put("currentLabel", step.label)
|
||||
val progress = when (step.durationType) {
|
||||
"distance" -> {
|
||||
val target = step.durationValue / 1000.0
|
||||
if (target > 0) (svc.intervalAccumulatedDistanceKm / target).coerceIn(0.0, 1.0) else 0.0
|
||||
}
|
||||
"time" -> {
|
||||
val target = step.durationValue * 1000.0
|
||||
if (target > 0) ((System.currentTimeMillis() - svc.intervalStartTimeMs) / target).coerceIn(0.0, 1.0) else 0.0
|
||||
}
|
||||
else -> 0.0
|
||||
}
|
||||
obj.put("progress", progress)
|
||||
} else {
|
||||
obj.put("currentLabel", "")
|
||||
obj.put("progress", 1.0)
|
||||
}
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
fun drainPoints(): String {
|
||||
val drained: List<JSONObject>
|
||||
synchronized(pointBuffer) {
|
||||
drained = ArrayList(pointBuffer)
|
||||
pointBuffer.clear()
|
||||
}
|
||||
val arr = JSONArray()
|
||||
for (p in drained) arr.put(p)
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
private fun haversineKm(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double {
|
||||
val R = 6371.0
|
||||
val dLat = Math.toRadians(lat2 - lat1)
|
||||
val dLng = Math.toRadians(lng2 - lng1)
|
||||
val a = sin(dLat / 2).pow(2) +
|
||||
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLng / 2).pow(2)
|
||||
return 2 * R * asin(sqrt(a))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
// --- Step detector sensor callbacks ---
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
if (event?.sensor?.type == Sensor.TYPE_STEP_DETECTOR) {
|
||||
if (!paused) {
|
||||
stepTimestamps.add(System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/**
|
||||
* Compute cadence (steps per minute) from recent step detector events.
|
||||
* Returns null if no steps detected in the rolling window.
|
||||
*/
|
||||
private fun computeCadence(): Double? {
|
||||
val now = System.currentTimeMillis()
|
||||
val cutoff = now - CADENCE_WINDOW_MS
|
||||
// Prune old timestamps
|
||||
while (stepTimestamps.peek()?.let { it < cutoff } == true) {
|
||||
stepTimestamps.poll()
|
||||
}
|
||||
val count = stepTimestamps.size
|
||||
if (count < 2) return null
|
||||
val windowMs = now - (stepTimestamps.peek() ?: now)
|
||||
if (windowMs < 2000) return null // need at least 2s of data
|
||||
return count.toDouble() / (windowMs / 60000.0)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
notificationManager = getSystemService(NotificationManager::class.java)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val startPaused = intent?.getBooleanExtra("startPaused", false) ?: false
|
||||
startTimeMs = System.currentTimeMillis()
|
||||
pausedAccumulatedMs = 0L
|
||||
pausedSinceMs = if (startPaused) startTimeMs else 0L
|
||||
paused = startPaused
|
||||
totalDistanceKm = 0.0
|
||||
lastLat = Double.NaN
|
||||
lastLng = Double.NaN
|
||||
lastTimestamp = 0L
|
||||
currentPaceMinKm = 0.0
|
||||
|
||||
// Parse TTS config from intent
|
||||
val configJson = intent?.getStringExtra("ttsConfig") ?: "{}"
|
||||
Log.d(TAG, "TTS config JSON: $configJson")
|
||||
ttsConfig = TtsConfig.fromJson(configJson)
|
||||
Log.d(TAG, "TTS enabled=${ttsConfig?.enabled}, trigger=${ttsConfig?.triggerType}/${ttsConfig?.triggerValue}, metrics=${ttsConfig?.metrics}")
|
||||
|
||||
// Initialize interval tracking
|
||||
intervalSteps = ttsConfig?.intervals ?: emptyList()
|
||||
currentIntervalIdx = 0
|
||||
intervalAccumulatedDistanceKm = 0.0
|
||||
intervalStartTimeMs = startTimeMs
|
||||
intervalsComplete = false
|
||||
if (intervalSteps.isNotEmpty()) {
|
||||
Log.d(TAG, "Intervals configured: ${intervalSteps.size} steps")
|
||||
intervalSteps.forEachIndexed { i, step ->
|
||||
Log.d(TAG, " Step $i: ${step.label} ${step.durationValue} ${step.durationType}")
|
||||
}
|
||||
}
|
||||
|
||||
val notifIntent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, notifIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = if (startPaused) {
|
||||
buildNotification("Waiting to start...", "", "")
|
||||
} else {
|
||||
buildNotification("0:00", "0.00 km", "")
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
startLocationUpdates()
|
||||
startStepDetector()
|
||||
tracking = true
|
||||
instance = this
|
||||
|
||||
// Initialize TTS *after* startForeground — using applicationContext for reliable engine binding
|
||||
if (ttsConfig?.enabled == true) {
|
||||
Log.d(TAG, "Initializing TTS engine (post-startForeground)...")
|
||||
lastAnnouncementDistanceKm = 0.0
|
||||
lastAnnouncementTimeMs = startTimeMs
|
||||
splitDistanceAtLastAnnouncement = 0.0
|
||||
splitTimeAtLastAnnouncement = startTimeMs
|
||||
|
||||
val dummyTts = TextToSpeech(applicationContext, null)
|
||||
val engines = dummyTts.engines
|
||||
Log.d(TAG, "Available TTS engines: ${engines.map { "${it.label} (${it.name})" }}")
|
||||
dummyTts.shutdown()
|
||||
|
||||
if (engines.isNotEmpty()) {
|
||||
val engineName = engines[0].name
|
||||
Log.d(TAG, "Trying TTS with explicit engine: $engineName")
|
||||
tts = TextToSpeech(applicationContext, this, engineName)
|
||||
} else {
|
||||
Log.e(TAG, "No TTS engines found on device!")
|
||||
tts = TextToSpeech(applicationContext, this)
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
// --- TTS ---
|
||||
|
||||
/** Called when TTS is ready — either immediately (pre-warmed) or from onInit (cold start). */
|
||||
private fun onTtsReady() {
|
||||
val config = ttsConfig ?: return
|
||||
Log.d(TAG, "TTS ready! triggerType=${config.triggerType}, triggerValue=${config.triggerValue}")
|
||||
|
||||
// Set specific voice if requested
|
||||
if (!config.voiceId.isNullOrEmpty()) {
|
||||
tts?.voices?.find { it.name == config.voiceId }?.let { voice ->
|
||||
tts?.voice = voice
|
||||
}
|
||||
}
|
||||
|
||||
// Announce workout started
|
||||
speakWithConfig("Workout started", "workout_started")
|
||||
|
||||
// Announce first interval step if intervals are configured (queue after "Workout started")
|
||||
if (intervalSteps.isNotEmpty() && !intervalsComplete) {
|
||||
val first = intervalSteps[0]
|
||||
val durationText = if (first.durationType == "distance") {
|
||||
"${first.durationValue.toInt()} meters"
|
||||
} else {
|
||||
val secs = first.durationValue.toInt()
|
||||
if (secs >= 60) {
|
||||
val m = secs / 60
|
||||
val s = secs % 60
|
||||
if (s > 0) "$m minutes $s seconds" else "$m minutes"
|
||||
} else {
|
||||
"$secs seconds"
|
||||
}
|
||||
}
|
||||
speakWithConfig("${first.label}. $durationText", "interval_announcement", flush = false)
|
||||
}
|
||||
|
||||
// Set up time-based trigger if configured
|
||||
if (config.triggerType == "time") {
|
||||
startTimeTrigger(config.triggerValue)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInit(status: Int) {
|
||||
Log.d(TAG, "TTS onInit status=$status (SUCCESS=${TextToSpeech.SUCCESS})")
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
val config = ttsConfig ?: return
|
||||
val locale = Locale.forLanguageTag(config.language)
|
||||
val langResult = tts?.setLanguage(locale)
|
||||
Log.d(TAG, "TTS setLanguage($locale) result=$langResult")
|
||||
ttsReady = true
|
||||
onTtsReady()
|
||||
} else {
|
||||
Log.e(TAG, "TTS init FAILED with status=$status")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
val config = ttsConfig ?: return
|
||||
if (!config.audioDuck) return
|
||||
if (hasAudioFocus) return
|
||||
|
||||
audioManager = audioManager ?: getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val focusReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setOnAudioFocusChangeListener { }
|
||||
.build()
|
||||
audioFocusRequest = focusReq
|
||||
val result = audioManager?.requestAudioFocus(focusReq)
|
||||
hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
Log.d(TAG, "Audio focus request (duck): granted=$hasAudioFocus")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = audioManager?.requestAudioFocus(
|
||||
{ },
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
|
||||
)
|
||||
hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonAudioFocus() {
|
||||
if (!hasAudioFocus) return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager?.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager?.abandonAudioFocus { }
|
||||
}
|
||||
hasAudioFocus = false
|
||||
}
|
||||
|
||||
/** Speak text with configured volume; requests/abandons audio focus for ducking. */
|
||||
private fun speakWithConfig(text: String, utteranceId: String, flush: Boolean = true) {
|
||||
if (!ttsReady) return
|
||||
val config = ttsConfig ?: return
|
||||
val queueMode = if (flush) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
|
||||
|
||||
requestAudioFocus()
|
||||
|
||||
val params = Bundle().apply {
|
||||
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, config.ttsVolume)
|
||||
}
|
||||
|
||||
// Set up listener to abandon audio focus after utterance completes
|
||||
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(id: String?) {}
|
||||
override fun onDone(id: String?) { abandonAudioFocus() }
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(id: String?) { abandonAudioFocus() }
|
||||
})
|
||||
|
||||
val result = tts?.speak(text, queueMode, params, utteranceId)
|
||||
Log.d(TAG, "speakWithConfig($utteranceId) result=$result vol=${config.ttsVolume} duck=${config.audioDuck}")
|
||||
}
|
||||
|
||||
private fun startTimeTrigger(intervalMinutes: Double) {
|
||||
val intervalMs = (intervalMinutes * 60 * 1000).toLong()
|
||||
Log.d(TAG, "Starting time trigger: every ${intervalMs}ms (${intervalMinutes} min)")
|
||||
ttsTimeHandler = Handler(Looper.getMainLooper())
|
||||
ttsTimeRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
Log.d(TAG, "Time trigger fired!")
|
||||
announceMetrics()
|
||||
ttsTimeHandler?.postDelayed(this, intervalMs)
|
||||
}
|
||||
}
|
||||
ttsTimeHandler?.postDelayed(ttsTimeRunnable!!, intervalMs)
|
||||
}
|
||||
|
||||
// --- Pause / Resume ---
|
||||
|
||||
fun doPause() {
|
||||
if (paused) return
|
||||
paused = true
|
||||
pausedSinceMs = System.currentTimeMillis()
|
||||
Log.d(TAG, "Tracking paused")
|
||||
|
||||
// Pause TTS time trigger
|
||||
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
|
||||
|
||||
// Update notification to show paused state
|
||||
val notification = buildNotification(formatElapsed(), "%.2f km".format(totalDistanceKm), "PAUSED")
|
||||
notificationManager?.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun doResume() {
|
||||
if (!paused) return
|
||||
// Accumulate paused duration
|
||||
pausedAccumulatedMs += System.currentTimeMillis() - pausedSinceMs
|
||||
pausedSinceMs = 0L
|
||||
paused = false
|
||||
Log.d(TAG, "Tracking resumed (total paused: ${pausedAccumulatedMs / 1000}s)")
|
||||
|
||||
// Reset last position so we don't accumulate drift during pause
|
||||
lastLat = Double.NaN
|
||||
lastLng = Double.NaN
|
||||
lastTimestamp = 0L
|
||||
|
||||
// Resume TTS time trigger
|
||||
val config = ttsConfig
|
||||
if (ttsReady && config != null && config.triggerType == "time") {
|
||||
val intervalMs = (config.triggerValue * 60 * 1000).toLong()
|
||||
ttsTimeRunnable?.let { ttsTimeHandler?.postDelayed(it, intervalMs) }
|
||||
}
|
||||
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
private fun checkDistanceTrigger() {
|
||||
val config = ttsConfig ?: return
|
||||
if (!ttsReady || config.triggerType != "distance") return
|
||||
|
||||
val sinceLast = totalDistanceKm - lastAnnouncementDistanceKm
|
||||
if (sinceLast >= config.triggerValue) {
|
||||
announceMetrics()
|
||||
lastAnnouncementDistanceKm = totalDistanceKm
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkIntervalProgress(segmentKm: Double) {
|
||||
if (intervalsComplete || intervalSteps.isEmpty()) return
|
||||
if (currentIntervalIdx >= intervalSteps.size) return
|
||||
|
||||
val step = intervalSteps[currentIntervalIdx]
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val complete = when (step.durationType) {
|
||||
"distance" -> {
|
||||
intervalAccumulatedDistanceKm += segmentKm
|
||||
intervalAccumulatedDistanceKm >= step.durationValue / 1000.0
|
||||
}
|
||||
"time" -> {
|
||||
(now - intervalStartTimeMs) >= step.durationValue * 1000
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (complete) {
|
||||
currentIntervalIdx++
|
||||
intervalAccumulatedDistanceKm = 0.0
|
||||
intervalStartTimeMs = now
|
||||
|
||||
if (currentIntervalIdx >= intervalSteps.size) {
|
||||
intervalsComplete = true
|
||||
Log.d(TAG, "All intervals complete!")
|
||||
announceIntervalTransition("Intervals complete")
|
||||
} else {
|
||||
val next = intervalSteps[currentIntervalIdx]
|
||||
val durationText = if (next.durationType == "distance") {
|
||||
"${next.durationValue.toInt()} meters"
|
||||
} else {
|
||||
val secs = next.durationValue.toInt()
|
||||
if (secs >= 60) {
|
||||
val m = secs / 60
|
||||
val s = secs % 60
|
||||
if (s > 0) "$m minutes $s seconds" else "$m minutes"
|
||||
} else {
|
||||
"$secs seconds"
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Interval transition: step ${currentIntervalIdx}/${intervalSteps.size} — ${next.label} $durationText")
|
||||
announceIntervalTransition("${next.label}. $durationText")
|
||||
}
|
||||
updateNotification()
|
||||
}
|
||||
}
|
||||
|
||||
private fun announceIntervalTransition(text: String) {
|
||||
if (!ttsReady) return
|
||||
Log.d(TAG, "Interval announcement: $text")
|
||||
speakWithConfig(text, "interval_announcement")
|
||||
}
|
||||
|
||||
private fun announceMetrics() {
|
||||
if (!ttsReady) return
|
||||
val config = ttsConfig ?: return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val activeSecs = activeElapsedSecs()
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
for (metric in config.metrics) {
|
||||
when (metric) {
|
||||
"totalTime" -> {
|
||||
val h = activeSecs / 3600
|
||||
val m = (activeSecs % 3600) / 60
|
||||
val s = activeSecs % 60
|
||||
val timeStr = if (h > 0) {
|
||||
"$h hours $m minutes"
|
||||
} else {
|
||||
"$m minutes $s seconds"
|
||||
}
|
||||
parts.add("Time: $timeStr")
|
||||
}
|
||||
"totalDistance" -> {
|
||||
val distStr = "%.2f".format(totalDistanceKm)
|
||||
parts.add("Distance: $distStr kilometers")
|
||||
}
|
||||
"avgPace" -> {
|
||||
val elapsedMin = activeSecs / 60.0
|
||||
if (totalDistanceKm > 0.01) {
|
||||
val avgPace = elapsedMin / totalDistanceKm
|
||||
val mins = avgPace.toInt()
|
||||
val secs = ((avgPace - mins) * 60).toInt()
|
||||
parts.add("Average pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
}
|
||||
"splitPace" -> {
|
||||
val splitDist = totalDistanceKm - splitDistanceAtLastAnnouncement
|
||||
val splitTimeMin = (now - splitTimeAtLastAnnouncement) / 60000.0
|
||||
if (splitDist > 0.01) {
|
||||
val splitPace = splitTimeMin / splitDist
|
||||
val mins = splitPace.toInt()
|
||||
val secs = ((splitPace - mins) * 60).toInt()
|
||||
parts.add("Split pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
}
|
||||
"currentPace" -> {
|
||||
if (currentPaceMinKm > 0 && currentPaceMinKm <= 60) {
|
||||
val mins = currentPaceMinKm.toInt()
|
||||
val secs = ((currentPaceMinKm - mins) * 60).toInt()
|
||||
parts.add("Current pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update split tracking
|
||||
splitDistanceAtLastAnnouncement = totalDistanceKm
|
||||
splitTimeAtLastAnnouncement = now
|
||||
lastAnnouncementTimeMs = now
|
||||
|
||||
if (parts.isNotEmpty()) {
|
||||
val text = parts.joinToString(". ")
|
||||
Log.d(TAG, "Announcing: $text")
|
||||
speakWithConfig(text, "workout_announcement")
|
||||
} else {
|
||||
Log.d(TAG, "announceMetrics: no parts to announce")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification / Location (unchanged) ---
|
||||
|
||||
private fun formatPace(paceMinKm: Double): String {
|
||||
if (paceMinKm <= 0 || paceMinKm > 60) return ""
|
||||
val mins = paceMinKm.toInt()
|
||||
val secs = ((paceMinKm - mins) * 60).toInt()
|
||||
return "%d:%02d /km".format(mins, secs)
|
||||
}
|
||||
|
||||
private fun buildNotification(elapsed: String, distance: String, pace: String): Notification {
|
||||
val parts = mutableListOf(elapsed, distance)
|
||||
if (pace.isNotEmpty()) parts.add(pace)
|
||||
val text = parts.joinToString(" · ")
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Bocken — Tracking GPS for active Workout")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this)
|
||||
.setContentTitle("Bocken — Tracking GPS for active Workout")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns active (non-paused) elapsed time in seconds. */
|
||||
private fun activeElapsedSecs(): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
val totalPaused = pausedAccumulatedMs + if (pausedSinceMs > 0) (now - pausedSinceMs) else 0L
|
||||
return (now - startTimeMs - totalPaused) / 1000
|
||||
}
|
||||
|
||||
private fun formatElapsed(): String {
|
||||
val secs = activeElapsedSecs()
|
||||
val h = secs / 3600
|
||||
val m = (secs % 3600) / 60
|
||||
val s = secs % 60
|
||||
return if (h > 0) {
|
||||
"%d:%02d:%02d".format(h, m, s)
|
||||
} else {
|
||||
"%d:%02d".format(m, s)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
val paceStr = if (intervalSteps.isNotEmpty() && !intervalsComplete && currentIntervalIdx < intervalSteps.size) {
|
||||
val step = intervalSteps[currentIntervalIdx]
|
||||
"${step.label} (${currentIntervalIdx + 1}/${intervalSteps.size})"
|
||||
} else if (intervalsComplete) {
|
||||
"Intervals done"
|
||||
} else {
|
||||
formatPace(currentPaceMinKm)
|
||||
}
|
||||
val notification = buildNotification(
|
||||
formatElapsed(),
|
||||
"%.2f km".format(totalDistanceKm),
|
||||
paceStr
|
||||
)
|
||||
notificationManager?.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun startStepDetector() {
|
||||
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
stepDetector = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)
|
||||
if (stepDetector != null) {
|
||||
sensorManager?.registerListener(this, stepDetector, SensorManager.SENSOR_DELAY_FASTEST)
|
||||
Log.d(TAG, "Step detector sensor registered")
|
||||
} else {
|
||||
Log.d(TAG, "Step detector sensor not available on this device")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
private fun startLocationUpdates() {
|
||||
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
locationListener = LocationListener { location ->
|
||||
val lat = location.latitude
|
||||
val lng = location.longitude
|
||||
val now = location.time
|
||||
|
||||
// Always buffer GPS points (for track drawing) even when paused
|
||||
val cadence = computeCadence()
|
||||
val point = JSONObject().apply {
|
||||
put("lat", lat)
|
||||
put("lng", lng)
|
||||
if (location.hasAltitude()) put("altitude", location.altitude)
|
||||
if (location.hasSpeed()) put("speed", location.speed.toDouble())
|
||||
if (cadence != null) put("cadence", cadence)
|
||||
put("timestamp", location.time)
|
||||
}
|
||||
pointBuffer.add(point)
|
||||
|
||||
// Skip distance/pace accumulation and TTS triggers when paused
|
||||
if (paused) return@LocationListener
|
||||
|
||||
// Accumulate distance and compute pace
|
||||
if (!lastLat.isNaN()) {
|
||||
val segmentKm = haversineKm(lastLat, lastLng, lat, lng)
|
||||
totalDistanceKm += segmentKm
|
||||
if (segmentKm > 0.001 && lastTimestamp > 0) {
|
||||
val dtMin = (now - lastTimestamp) / 60000.0
|
||||
currentPaceMinKm = dtMin / segmentKm
|
||||
}
|
||||
// Check interval progress with this segment's distance
|
||||
checkIntervalProgress(segmentKm)
|
||||
} else {
|
||||
// First point — check time-based intervals even with no distance
|
||||
checkIntervalProgress(0.0)
|
||||
}
|
||||
lastLat = lat
|
||||
lastLng = lng
|
||||
lastTimestamp = now
|
||||
|
||||
updateNotification()
|
||||
|
||||
// Check distance-based TTS trigger
|
||||
checkDistanceTrigger()
|
||||
}
|
||||
|
||||
locationManager?.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
MIN_TIME_MS,
|
||||
MIN_DISTANCE_M,
|
||||
locationListener!!
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the finish summary text from current stats.
|
||||
* Must be called while service state is still valid (before clearing fields).
|
||||
*/
|
||||
private fun buildFinishSummaryText(): String? {
|
||||
val config = ttsConfig ?: return null
|
||||
if (!config.enabled) return null
|
||||
|
||||
val activeSecs = activeElapsedSecs()
|
||||
val h = activeSecs / 3600
|
||||
val m = (activeSecs % 3600) / 60
|
||||
val s = activeSecs % 60
|
||||
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add("Workout finished")
|
||||
|
||||
val timeStr = if (h > 0) "$h hours $m minutes" else "$m minutes $s seconds"
|
||||
parts.add("Total time: $timeStr")
|
||||
|
||||
if (totalDistanceKm > 0.01) {
|
||||
parts.add("Distance: ${"%.2f".format(totalDistanceKm)} kilometers")
|
||||
}
|
||||
|
||||
if (totalDistanceKm > 0.01) {
|
||||
val avgPace = (activeSecs / 60.0) / totalDistanceKm
|
||||
val mins = avgPace.toInt()
|
||||
val secs = ((avgPace - mins) * 60).toInt()
|
||||
parts.add("Average pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
|
||||
return parts.joinToString(". ")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// Snapshot summary text while stats are still valid
|
||||
val summaryText = buildFinishSummaryText()
|
||||
val config = ttsConfig
|
||||
|
||||
// Stop time-based TTS triggers
|
||||
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
|
||||
ttsTimeHandler = null
|
||||
ttsTimeRunnable = null
|
||||
|
||||
// Hand off the existing TTS instance for the finish summary.
|
||||
// We do NOT call tts?.stop() or tts?.shutdown() here — the utterance
|
||||
// listener will clean up after the summary finishes speaking.
|
||||
val finishTts = tts
|
||||
tts = null
|
||||
ttsReady = false
|
||||
|
||||
tracking = false
|
||||
paused = false
|
||||
instance = null
|
||||
locationListener?.let { locationManager?.removeUpdates(it) }
|
||||
locationListener = null
|
||||
locationManager = null
|
||||
sensorManager?.unregisterListener(this)
|
||||
sensorManager = null
|
||||
stepDetector = null
|
||||
stepTimestamps.clear()
|
||||
abandonAudioFocus()
|
||||
|
||||
// Speak finish summary using the handed-off TTS instance (already initialized)
|
||||
if (summaryText != null && finishTts != null && config != null) {
|
||||
Log.d(TAG, "Finish summary: $summaryText")
|
||||
|
||||
// Audio focus for ducking
|
||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
var focusReq: AudioFocusRequest? = null
|
||||
if (config.audioDuck && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
focusReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setOnAudioFocusChangeListener { }
|
||||
.build()
|
||||
am.requestAudioFocus(focusReq)
|
||||
}
|
||||
|
||||
finishTts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(id: String?) {}
|
||||
override fun onDone(id: String?) { cleanup() }
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(id: String?) { cleanup() }
|
||||
|
||||
private fun cleanup() {
|
||||
if (focusReq != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
am.abandonAudioFocusRequest(focusReq)
|
||||
}
|
||||
finishTts.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
val params = Bundle().apply {
|
||||
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, config.ttsVolume)
|
||||
}
|
||||
finishTts.speak(summaryText, TextToSpeech.QUEUE_FLUSH, params, "workout_finished")
|
||||
} else {
|
||||
finishTts?.shutdown()
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"GPS Tracking",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Shows while GPS is recording your workout"
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.bocken.app
|
||||
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
webView.addJavascriptInterface(AndroidBridge(this), "AndroidBridge")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hello World!"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,6 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.bocken" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
10
src-tauri/gen/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Bocken</string>
|
||||
<string name="main_activity_title">Bocken</string>
|
||||
</resources>
|
||||
6
src-tauri/gen/android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.bocken" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
22
src-tauri/gen/android/build.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean").configure {
|
||||
delete("build")
|
||||
}
|
||||
|
||||
23
src-tauri/gen/android/buildSrc/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("pluginsForCoolKids") {
|
||||
id = "rust"
|
||||
implementationClass = "RustPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(gradleApi())
|
||||
implementation("com.android.tools.build:gradle:8.11.0")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import java.io.File
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.logging.LogLevel
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
|
||||
open class BuildTask : DefaultTask() {
|
||||
@Input
|
||||
var rootDirRel: String? = null
|
||||
@Input
|
||||
var target: String? = null
|
||||
@Input
|
||||
var release: Boolean? = null
|
||||
|
||||
@TaskAction
|
||||
fun assemble() {
|
||||
val executable = """pnpm""";
|
||||
try {
|
||||
runTauriCli(executable)
|
||||
} catch (e: Exception) {
|
||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||
// Try different Windows-specific extensions
|
||||
val fallbacks = listOf(
|
||||
"$executable.exe",
|
||||
"$executable.cmd",
|
||||
"$executable.bat",
|
||||
)
|
||||
|
||||
var lastException: Exception = e
|
||||
for (fallback in fallbacks) {
|
||||
try {
|
||||
runTauriCli(fallback)
|
||||
return
|
||||
} catch (fallbackException: Exception) {
|
||||
lastException = fallbackException
|
||||
}
|
||||
}
|
||||
throw lastException
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun runTauriCli(executable: String) {
|
||||
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||
val target = target ?: throw GradleException("target cannot be null")
|
||||
val release = release ?: throw GradleException("release cannot be null")
|
||||
val args = listOf("tauri", "android", "android-studio-script");
|
||||
|
||||
project.exec {
|
||||
workingDir(File(project.projectDir, rootDirRel))
|
||||
executable(executable)
|
||||
args(args)
|
||||
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||
args("-vv")
|
||||
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||
args("-v")
|
||||
}
|
||||
if (release) {
|
||||
args("--release")
|
||||
}
|
||||
args(listOf("--target", target))
|
||||
}.assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.gradle.kotlin.dsl.get
|
||||
|
||||
const val TASK_GROUP = "rust"
|
||||
|
||||
open class Config {
|
||||
lateinit var rootDirRel: String
|
||||
}
|
||||
|
||||
open class RustPlugin : Plugin<Project> {
|
||||
private lateinit var config: Config
|
||||
|
||||
override fun apply(project: Project) = with(project) {
|
||||
config = extensions.create("rust", Config::class.java)
|
||||
|
||||
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
||||
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
||||
|
||||
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
||||
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
||||
|
||||
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
||||
|
||||
extensions.configure<ApplicationExtension> {
|
||||
@Suppress("UnstableApiUsage")
|
||||
flavorDimensions.add("abi")
|
||||
productFlavors {
|
||||
create("universal") {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters += abiList
|
||||
}
|
||||
}
|
||||
defaultArchList.forEachIndexed { index, arch ->
|
||||
create(arch) {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters.add(defaultAbiList[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
for (profile in listOf("debug", "release")) {
|
||||
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
||||
val buildTask = tasks.maybeCreate(
|
||||
"rustBuildUniversal$profileCapitalized",
|
||||
DefaultTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for all targets"
|
||||
}
|
||||
|
||||
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
||||
|
||||
for (targetPair in targetsList.withIndex()) {
|
||||
val targetName = targetPair.value
|
||||
val targetArch = archList[targetPair.index]
|
||||
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
||||
val targetBuildTask = project.tasks.maybeCreate(
|
||||
"rustBuild$targetArchCapitalized$profileCapitalized",
|
||||
BuildTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for $targetArch"
|
||||
rootDirRel = config.rootDirRel
|
||||
target = targetName
|
||||
release = profile == "release"
|
||||
}
|
||||
|
||||
buildTask.dependsOn(targetBuildTask)
|
||||
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
||||
targetBuildTask
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src-tauri/gen/android/gradle.properties
Normal file
@@ -0,0 +1,24 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app"s APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=false
|
||||
BIN
src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
#Tue May 10 19:22:52 CST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
185
src-tauri/gen/android/gradlew
vendored
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
89
src-tauri/gen/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
3
src-tauri/gen/android/settings.gradle
Normal file
@@ -0,0 +1,3 @@
|
||||
include ':app'
|
||||
|
||||
apply from: 'tauri.settings.gradle'
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
2316
src-tauri/gen/schemas/android-schema.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"bocken-remote":{"identifier":"bocken-remote","description":"","remote":{"urls":["https://bocken.org/*","http://192.168.1.4:5173/*"]},"local":true,"windows":["main"],"permissions":["geolocation:allow-check-permissions","geolocation:allow-request-permissions","geolocation:allow-get-current-position","geolocation:allow-watch-position","geolocation:allow-clear-watch"]}}
|
||||
2316
src-tauri/gen/schemas/mobile-schema.json
Normal file
@@ -1,5 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
bocken_fitness_lib::run();
|
||||
bocken_lib::run();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"productName": "Bocken Fitness",
|
||||
"identifier": "org.bocken.fitness",
|
||||
"version": "0.1.0",
|
||||
"productName": "Bocken",
|
||||
"identifier": "org.bocken.app",
|
||||
"version": "0.5.0",
|
||||
"build": {
|
||||
"devUrl": "http://192.168.1.4:5173",
|
||||
"frontendDist": "https://bocken.org"
|
||||
@@ -10,8 +10,8 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Bocken Fitness",
|
||||
"url": "/fitness",
|
||||
"title": "Bocken",
|
||||
"url": "/",
|
||||
"fullscreen": false,
|
||||
"useHttpsScheme": true
|
||||
}
|
||||
|
||||
91
src/app.css
@@ -149,6 +149,15 @@
|
||||
--text-xl: 1.5rem;
|
||||
--text-2xl: 2rem;
|
||||
--text-3xl: 3rem;
|
||||
|
||||
/* Shopping icon filter — white PNGs need invert in light mode */
|
||||
--shopping-icon-filter: invert(1);
|
||||
|
||||
/* LinksGrid icon fills — colorful in light */
|
||||
--grid-fill-base: var(--nord10);
|
||||
--grid-fill-pop-a: var(--nord11);
|
||||
--grid-fill-pop-b: var(--nord12);
|
||||
--grid-fill-pop-c: var(--nord14);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -212,6 +221,14 @@
|
||||
--color-link: var(--nord8);
|
||||
--color-link-visited: #c89fb6;
|
||||
--color-link-hover: var(--nord7);
|
||||
|
||||
--shopping-icon-filter: none;
|
||||
|
||||
/* LinksGrid icon fills — cool blues/whites in dark */
|
||||
--grid-fill-base: var(--nord8);
|
||||
--grid-fill-pop-a: var(--nord9);
|
||||
--grid-fill-pop-b: var(--nord7);
|
||||
--grid-fill-pop-c: var(--nord4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +278,13 @@
|
||||
--color-link: var(--nord8);
|
||||
--color-link-visited: #c89fb6;
|
||||
--color-link-hover: var(--nord7);
|
||||
|
||||
--shopping-icon-filter: none;
|
||||
|
||||
--grid-fill-base: var(--nord8);
|
||||
--grid-fill-pop-a: var(--nord9);
|
||||
--grid-fill-pop-b: var(--nord7);
|
||||
--grid-fill-pop-c: var(--nord4);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -272,6 +296,16 @@
|
||||
font-family: Helvetica, Arial, "Noto Sans", sans-serif;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -280,6 +314,26 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Status bar drop shadow for edge-to-edge Android/Tauri.
|
||||
Covers the status-bar area; the bottom third fades out
|
||||
to create a soft shadow at the boundary. */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(env(safe-area-inset-top, 0px) * 1.2);
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(0, 0, 0, 0.4) 50%,
|
||||
rgba(0, 0, 0, 0.32) 62%,
|
||||
rgba(0, 0, 0, 0.2) 75%,
|
||||
rgba(0, 0, 0, 0.1) 87%,
|
||||
transparent);
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LINK STYLES
|
||||
============================================ */
|
||||
@@ -428,3 +482,40 @@ a:focus-visible {
|
||||
gap: 1.8em;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Scrollbar
|
||||
*/
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-font-primary);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-font-primary);
|
||||
}
|
||||
/*Firefox*/
|
||||
* {
|
||||
scrollbar-width: thin; /* auto | thin | none */
|
||||
scrollbar-color: rgba(0, 0,0,0.3) transparent;
|
||||
}
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]){
|
||||
scrollbar-color: rgba(255, 255,255,0.3) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,18 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
|
||||
}
|
||||
|
||||
// Protect cospend routes and API endpoints
|
||||
if (event.url.pathname.startsWith('/cospend') || event.url.pathname.startsWith('/api/cospend')) {
|
||||
if (event.url.pathname.startsWith('/cospend') || event.url.pathname.startsWith('/expenses') || event.url.pathname.startsWith('/api/cospend')) {
|
||||
if (!session) {
|
||||
// Allow share-token access to shopping list routes
|
||||
const isShoppingRoute = event.url.pathname.startsWith('/cospend/list') || event.url.pathname.startsWith('/expenses/list') || event.url.pathname.startsWith('/api/cospend/list');
|
||||
const shareToken = event.url.searchParams.get('token');
|
||||
if (isShoppingRoute && shareToken) {
|
||||
const { validateShareToken } = await import('$lib/server/shoppingAuth');
|
||||
if (await validateShareToken(shareToken)) {
|
||||
return resolve(event);
|
||||
}
|
||||
}
|
||||
|
||||
// For API routes, return 401 instead of redirecting
|
||||
if (event.url.pathname.startsWith('/api/cospend')) {
|
||||
error(401, {
|
||||
@@ -56,6 +66,24 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Protect tasks routes and API endpoints
|
||||
if (event.url.pathname.startsWith('/tasks') || event.url.pathname.startsWith('/api/tasks')) {
|
||||
if (!session) {
|
||||
if (event.url.pathname.startsWith('/api/tasks')) {
|
||||
error(401, {
|
||||
message: 'Anmeldung erforderlich.'
|
||||
});
|
||||
}
|
||||
const callbackUrl = encodeURIComponent(event.url.pathname + event.url.search);
|
||||
redirect(303, `/login?callbackUrl=${callbackUrl}`);
|
||||
}
|
||||
else if (!session.user?.groups?.includes('task_users')) {
|
||||
error(403, {
|
||||
message: 'Zugriff verweigert. Du hast keine Berechtigung für diesen Bereich. Falls du glaubst, dass dies ein Fehler ist, wende dich bitte an Alexander.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Protect fitness routes and API endpoints
|
||||
if (event.url.pathname.startsWith('/fitness') || event.url.pathname.startsWith('/api/fitness')) {
|
||||
if (!session) {
|
||||
|
||||
13
src/lib/assets/muscle-back.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
12
src/lib/assets/muscle-front.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
108
src/lib/components/ConfirmDialog.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import { getConfirmDialog } from '$lib/js/confirmDialog.svelte';
|
||||
|
||||
const dialog = getConfirmDialog();
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!dialog.open) return;
|
||||
if (e.key === 'Escape') dialog.respond(false);
|
||||
if (e.key === 'Enter') dialog.respond(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if dialog.open}
|
||||
<div class="confirm-backdrop" onclick={() => dialog.respond(false)} role="presentation">
|
||||
<div class="confirm-dialog" onclick={(e) => e.stopPropagation()} role="alertdialog" aria-modal="true">
|
||||
{#if dialog.title}
|
||||
<h3 class="confirm-title">{dialog.title}</h3>
|
||||
{/if}
|
||||
<p class="confirm-message">{dialog.message}</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn cancel" onclick={() => dialog.respond(false)}>
|
||||
{dialog.cancelText}
|
||||
</button>
|
||||
<button
|
||||
class="confirm-btn confirm"
|
||||
class:destructive={dialog.destructive}
|
||||
onclick={() => dialog.respond(true)}
|
||||
>
|
||||
{dialog.confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.confirm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fade-in 150ms ease-out;
|
||||
}
|
||||
.confirm-dialog {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
max-width: 360px;
|
||||
width: calc(100vw - 2rem);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
animation: scale-in 150ms ease-out;
|
||||
}
|
||||
.confirm-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.confirm-message {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.confirm-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.confirm-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.confirm-btn.cancel {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.confirm-btn.confirm {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
.confirm-btn.confirm.destructive {
|
||||
background: var(--nord11);
|
||||
color: white;
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,69 +0,0 @@
|
||||
<script lang="ts">
|
||||
let { onclick } = $props<{ onclick?: () => void }>();
|
||||
</script>
|
||||
|
||||
<button class="counter-button" {onclick} aria-label="Nächstes Ave Maria">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4V2.21c0-.45-.54-.67-.85-.35l-2.8 2.79c-.2.2-.2.51 0 .71l2.79 2.79c.32.31.86.09.86-.36V6c3.31 0 6 2.69 6 6 0 .79-.15 1.56-.44 2.25-.15.36-.04.77.23 1.04.51.51 1.37.33 1.64-.34.37-.91.57-1.91.57-2.95 0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-.79.15-1.56.44-2.25.15-.36.04-.77-.23-1.04-.51-.51-1.37-.33-1.64.34C4.2 9.96 4 10.96 4 12c0 4.42 3.58 8 8 8v1.79c0 .45.54.67.85.35l2.79-2.79c.2-.2.2-.51 0-.71l-2.79-2.79c-.31-.31-.85-.09-.85.36V18z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.counter-button {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nord1);
|
||||
border: 2px solid var(--nord9);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme="dark"])) .counter-button {
|
||||
background: var(--nord5);
|
||||
border-color: var(--nord10);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .counter-button {
|
||||
background: var(--nord5);
|
||||
border-color: var(--nord10);
|
||||
}
|
||||
|
||||
.counter-button:hover {
|
||||
background: var(--nord2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme="dark"])) .counter-button:hover {
|
||||
background: var(--nord4);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .counter-button:hover {
|
||||
background: var(--nord4);
|
||||
}
|
||||
|
||||
.counter-button svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
fill: var(--nord9);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme="dark"])) .counter-button svg {
|
||||
fill: var(--nord10);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .counter-button svg {
|
||||
fill: var(--nord10);
|
||||
}
|
||||
|
||||
.counter-button:hover svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
354
src/lib/components/DatePicker.svelte
Normal file
@@ -0,0 +1,354 @@
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight, Calendar } from '@lucide/svelte';
|
||||
|
||||
let { value = $bindable(''), lang = 'en', min = '', max = '' } = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let pickerRef = $state(null);
|
||||
|
||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||
|
||||
const weekdays = $derived(lang === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN);
|
||||
const months = $derived(lang === 'de' ? MONTHS_DE : MONTHS_EN);
|
||||
|
||||
// The month being viewed in the calendar (independent of selected value)
|
||||
let viewYear = $state(0);
|
||||
let viewMonth = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (value) {
|
||||
const d = new Date(value + 'T12:00:00');
|
||||
viewYear = d.getFullYear();
|
||||
viewMonth = d.getMonth();
|
||||
} else {
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
}
|
||||
});
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const displayDate = $derived.by(() => {
|
||||
if (!value) return lang === 'en' ? 'Select date' : 'Datum wählen';
|
||||
if (value === todayStr) return lang === 'en' ? 'Today' : 'Heute';
|
||||
const d = new Date(value + 'T12:00:00');
|
||||
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
});
|
||||
|
||||
function isDisabled(dateStr) {
|
||||
if (min && dateStr < min) return true;
|
||||
if (max && dateStr > max) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function navigateDate(delta) {
|
||||
const d = new Date((value || todayStr) + 'T12:00:00');
|
||||
d.setDate(d.getDate() + delta);
|
||||
const next = d.toISOString().slice(0, 10);
|
||||
if (!isDisabled(next)) value = next;
|
||||
}
|
||||
|
||||
function navMonth(delta) {
|
||||
viewMonth += delta;
|
||||
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
||||
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
||||
}
|
||||
|
||||
function selectDay(dateStr) {
|
||||
value = dateStr;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
value = todayStr;
|
||||
open = false;
|
||||
}
|
||||
|
||||
const calendarDays = $derived.by(() => {
|
||||
const first = new Date(viewYear, viewMonth, 1);
|
||||
// Monday=0 based offset
|
||||
let startDay = first.getDay() - 1;
|
||||
if (startDay < 0) startDay = 6;
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
|
||||
|
||||
/** @type {{ date: string, day: number, currentMonth: boolean, isToday: boolean, isSelected: boolean }[]} */
|
||||
const days = [];
|
||||
|
||||
// Previous month trailing days
|
||||
for (let i = startDay - 1; i >= 0; i--) {
|
||||
const d = daysInPrevMonth - i;
|
||||
const m = viewMonth === 0 ? 11 : viewMonth - 1;
|
||||
const y = viewMonth === 0 ? viewYear - 1 : viewYear;
|
||||
const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
days.push({ date: dateStr, day: d, currentMonth: false, isToday: dateStr === todayStr, isSelected: dateStr === value, disabled: isDisabled(dateStr) });
|
||||
}
|
||||
|
||||
// Current month
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
days.push({ date: dateStr, day: d, currentMonth: true, isToday: dateStr === todayStr, isSelected: dateStr === value, disabled: isDisabled(dateStr) });
|
||||
}
|
||||
|
||||
// Next month leading days (fill to complete rows of 7)
|
||||
const remaining = 7 - (days.length % 7);
|
||||
if (remaining < 7) {
|
||||
for (let d = 1; d <= remaining; d++) {
|
||||
const m = viewMonth === 11 ? 0 : viewMonth + 1;
|
||||
const y = viewMonth === 11 ? viewYear + 1 : viewYear;
|
||||
const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
days.push({ date: dateStr, day: d, currentMonth: false, isToday: dateStr === todayStr, isSelected: dateStr === value, disabled: isDisabled(dateStr) });
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
function handleClickOutside(e) {
|
||||
if (pickerRef && !pickerRef.contains(e.target)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('pointerdown', handleClickOutside);
|
||||
return () => document.removeEventListener('pointerdown', handleClickOutside);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="datepicker" bind:this={pickerRef}>
|
||||
<div class="dp-pill">
|
||||
<button type="button" class="dp-arrow" onclick={() => navigateDate(-1)} aria-label="Previous day">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" class="dp-display" onclick={() => open = !open}>
|
||||
<Calendar size={14} />
|
||||
{displayDate}
|
||||
</button>
|
||||
<button type="button" class="dp-arrow" onclick={() => navigateDate(1)} aria-label="Next day">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="dp-dropdown">
|
||||
<div class="dp-header">
|
||||
<button type="button" class="dp-nav" onclick={() => navMonth(-1)} aria-label="Previous month">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span class="dp-month-label">{months[viewMonth]} {viewYear}</span>
|
||||
<button type="button" class="dp-nav" onclick={() => navMonth(1)} aria-label="Next month">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dp-weekdays">
|
||||
{#each weekdays as wd (wd)}
|
||||
<span class="dp-wd">{wd}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="dp-grid">
|
||||
{#each calendarDays as day (day.date)}
|
||||
<button
|
||||
type="button"
|
||||
class="dp-day"
|
||||
class:other-month={!day.currentMonth}
|
||||
class:today={day.isToday}
|
||||
class:selected={day.isSelected}
|
||||
class:disabled={day.disabled}
|
||||
disabled={day.disabled}
|
||||
onclick={() => selectDay(day.date)}
|
||||
>
|
||||
{day.day}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if value !== todayStr}
|
||||
<button type="button" class="dp-today-btn" onclick={goToday}>
|
||||
{lang === 'en' ? 'Today' : 'Heute'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.datepicker {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Pill row */
|
||||
.dp-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dp-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
.dp-arrow:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.dp-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
.dp-display:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
/* Dropdown calendar */
|
||||
.dp-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 0.6rem;
|
||||
z-index: 200;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.dp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.dp-month-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.dp-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
.dp-nav:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.dp-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.dp-wd {
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.dp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.dp-day {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
.dp-day:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.dp-day.other-month {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.dp-day.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.dp-day.today {
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1.5px var(--color-primary);
|
||||
}
|
||||
.dp-day.selected {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.dp-day.selected:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.dp-today-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
.dp-today-btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { recipeId, isFavorite = $bindable(false), isLoggedIn = false } = $props<{ recipeId: string, isFavorite?: boolean, isLoggedIn?: boolean }>();
|
||||
|
||||
const recipeLang = $derived($page.url.pathname.split('/')[1] || 'rezepte');
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
async function toggleFavorite(event: Event) {
|
||||
@@ -17,7 +20,7 @@
|
||||
|
||||
try {
|
||||
const method = isFavorite ? 'DELETE' : 'POST';
|
||||
const response = await fetch('/api/rezepte/favorites', {
|
||||
const response = await fetch(`/api/${recipeLang}/favorites`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -13,35 +13,17 @@
|
||||
|
||||
<style>
|
||||
.form-section {
|
||||
background: var(--nord6);
|
||||
background: var(--color-surface);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--nord4);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.form-section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--nord0);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .form-section {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
|
||||
:global(:root:not([data-theme="light"])) .form-section h2 {
|
||||
color: var(--font-default-dark);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .form-section {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .form-section h2 {
|
||||
color: var(--font-default-dark);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,6 @@ nav {
|
||||
border-radius: 100px;
|
||||
background: var(--nav-bg, rgba(46, 52, 64, 0.82));
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--nav-border, rgba(255,255,255,0.08));
|
||||
box-shadow: 0 4px 24px var(--nav-shadow, rgba(0,0,0,0.25));
|
||||
view-transition-name: site-header;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/js/cospendI18n';
|
||||
|
||||
let {
|
||||
imagePreview = $bindable(''),
|
||||
imageFile = $bindable(null),
|
||||
uploading = $bindable(false),
|
||||
currentImage = $bindable(null),
|
||||
title = 'Receipt Image',
|
||||
title = undefined as string | undefined,
|
||||
lang = 'de' as 'en' | 'de',
|
||||
onerror,
|
||||
onimageSelected,
|
||||
onimageRemoved,
|
||||
@@ -15,23 +18,26 @@
|
||||
uploading?: boolean,
|
||||
currentImage?: string | null,
|
||||
title?: string,
|
||||
lang?: 'en' | 'de',
|
||||
onerror?: (message: string) => void,
|
||||
onimageSelected?: (file: File) => void,
|
||||
onimageRemoved?: () => void,
|
||||
oncurrentImageRemoved?: () => void
|
||||
}>();
|
||||
|
||||
const displayTitle = $derived(title ?? t('receipt_image', lang));
|
||||
|
||||
function handleImageChange(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
onerror?.('File size must be less than 5MB');
|
||||
onerror?.(t('file_too_large', lang));
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
onerror?.('Please select a valid image file (JPEG, PNG, WebP)');
|
||||
onerror?.(t('invalid_image', lang));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,24 +66,24 @@
|
||||
</script>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>{title}</h2>
|
||||
|
||||
<h2>{displayTitle}</h2>
|
||||
|
||||
{#if currentImage}
|
||||
<div class="current-image">
|
||||
<img src={currentImage} alt="Receipt" class="receipt-preview" />
|
||||
<img src={currentImage} alt={t('receipt', lang)} class="receipt-preview" />
|
||||
<div class="image-actions">
|
||||
<button type="button" class="btn-remove" onclick={removeCurrentImage}>
|
||||
Remove Image
|
||||
{t('remove_image', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if imagePreview}
|
||||
<div class="image-preview">
|
||||
<img src={imagePreview} alt="Receipt preview" />
|
||||
<img src={imagePreview} alt={t('receipt', lang)} />
|
||||
<button type="button" class="remove-image" onclick={removeImage}>
|
||||
Remove Image
|
||||
{t('remove_image', lang)}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -89,136 +95,77 @@
|
||||
<line x1="16" y1="5" x2="22" y2="5"/>
|
||||
<line x1="19" y1="2" x2="19" y2="8"/>
|
||||
</svg>
|
||||
<p>{currentImage ? 'Replace Image' : 'Upload Receipt Image'}</p>
|
||||
<p>{currentImage ? t('replace_image', lang) : t('upload_receipt', lang)}</p>
|
||||
<small>JPEG, PNG, WebP (max 5MB)</small>
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="image"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||
<input
|
||||
type="file"
|
||||
id="image"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||
onchange={handleImageChange}
|
||||
disabled={uploading}
|
||||
hidden
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if uploading}
|
||||
<div class="upload-status">Uploading image...</div>
|
||||
<div class="upload-status">{t('uploading_image', lang)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-section {
|
||||
background: var(--nord6);
|
||||
background: var(--color-surface);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--nord4);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.form-section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--nord0);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .form-section {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
|
||||
:global(:root:not([data-theme="light"])) .form-section h2 {
|
||||
color: var(--font-default-dark);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .form-section {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .form-section h2 {
|
||||
color: var(--font-default-dark);
|
||||
}
|
||||
|
||||
.image-upload {
|
||||
border: 2px dashed var(--nord4);
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--nord5);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.image-upload:hover {
|
||||
border-color: var(--blue);
|
||||
background-color: var(--nord4);
|
||||
background-color: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .image-upload {
|
||||
background-color: var(--nord2);
|
||||
border-color: var(--nord3);
|
||||
}
|
||||
|
||||
:global(:root:not([data-theme="light"])) .image-upload:hover {
|
||||
background-color: var(--nord3);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .image-upload {
|
||||
background-color: var(--nord2);
|
||||
border-color: var(--nord3);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .image-upload:hover {
|
||||
background-color: var(--nord3);
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-content svg {
|
||||
color: var(--nord3);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.upload-content p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 500;
|
||||
color: var(--nord0);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.upload-content small {
|
||||
color: var(--nord3);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .upload-content svg {
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
:global(:root:not([data-theme="light"])) .upload-content p {
|
||||
color: var(--font-default-dark);
|
||||
}
|
||||
|
||||
:global(:root:not([data-theme="light"])) .upload-content small {
|
||||
color: var(--nord4);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .upload-content svg {
|
||||
color: var(--nord4);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .upload-content p {
|
||||
color: var(--font-default-dark);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .upload-content small {
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -255,22 +202,13 @@
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--nord4);
|
||||
border: 1px solid var(--color-border);
|
||||
margin-bottom: 0.75rem;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .receipt-preview {
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .receipt-preview {
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -282,4 +220,4 @@
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
import { recipeTranslationStore } from '$lib/stores/recipeTranslation';
|
||||
import { languageStore } from '$lib/stores/language';
|
||||
import { convertFitnessPath } from '$lib/js/fitnessI18n';
|
||||
import { convertCospendPath } from '$lib/js/cospendI18n';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { lang = undefined }: { lang?: 'de' | 'en' } = $props();
|
||||
let { lang = undefined }: { lang?: 'de' | 'en' | 'la' } = $props();
|
||||
|
||||
// Use prop for display if provided (SSR-safe), otherwise fall back to store
|
||||
const displayLang = $derived(lang ?? $languageStore);
|
||||
@@ -17,10 +18,17 @@
|
||||
|
||||
// Faith subroute mappings
|
||||
const faithSubroutes: Record<string, Record<string, string>> = {
|
||||
en: { gebete: 'prayers', rosenkranz: 'rosary' },
|
||||
de: { prayers: 'gebete', rosary: 'rosenkranz' }
|
||||
en: { gebete: 'prayers', rosenkranz: 'rosary', rosarium: 'rosary', orationes: 'prayers' },
|
||||
de: { prayers: 'gebete', rosary: 'rosenkranz', rosarium: 'rosenkranz', orationes: 'gebete' },
|
||||
la: { prayers: 'orationes', gebete: 'orationes', rosary: 'rosarium', rosenkranz: 'rosarium' }
|
||||
};
|
||||
|
||||
// Whether the current page is a faith route (show LA option)
|
||||
const faithPath = $derived(currentPath || $page.url.pathname);
|
||||
const isFaithRoute = $derived(
|
||||
faithPath.startsWith('/glaube') || faithPath.startsWith('/faith') || faithPath.startsWith('/fides')
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
// Update current language and path when page changes (reactive to browser navigation)
|
||||
const path = $page.url.pathname;
|
||||
@@ -30,8 +38,14 @@
|
||||
languageStore.set('en');
|
||||
} else if (path.startsWith('/rezepte') || path.startsWith('/glaube')) {
|
||||
languageStore.set('de');
|
||||
} else if (path.startsWith('/fides')) {
|
||||
// Latin route — no language switching needed
|
||||
} else if (path.startsWith('/fitness')) {
|
||||
// Language is determined by sub-route slugs; don't override store
|
||||
} else if (path.startsWith('/cospend')) {
|
||||
languageStore.set('de');
|
||||
} else if (path.startsWith('/expenses')) {
|
||||
languageStore.set('en');
|
||||
} else {
|
||||
// On other pages, read from localStorage
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
@@ -45,11 +59,11 @@
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function convertFaithPath(path: string, targetLang: 'de' | 'en'): string {
|
||||
const faithMatch = path.match(/^\/(glaube|faith)(\/(.+))?$/);
|
||||
function convertFaithPath(path: string, targetLang: 'de' | 'en' | 'la'): string {
|
||||
const faithMatch = path.match(/^\/(glaube|faith|fides)(\/(.+))?$/);
|
||||
if (!faithMatch) return path;
|
||||
|
||||
const targetBase = targetLang === 'en' ? 'faith' : 'glaube';
|
||||
const targetBase = targetLang === 'la' ? 'fides' : targetLang === 'en' ? 'faith' : 'glaube';
|
||||
const rest = faithMatch[3]; // e.g., "gebete", "rosenkranz/sub", "angelus"
|
||||
|
||||
if (!rest) {
|
||||
@@ -63,17 +77,21 @@
|
||||
}
|
||||
|
||||
// Compute target paths for each language (used as href for no-JS)
|
||||
function computeTargetPath(targetLang: 'de' | 'en'): string {
|
||||
function computeTargetPath(targetLang: 'de' | 'en' | 'la'): string {
|
||||
const path = currentPath || $page.url.pathname;
|
||||
|
||||
if (path.startsWith('/glaube') || path.startsWith('/faith')) {
|
||||
if (path.startsWith('/glaube') || path.startsWith('/faith') || path.startsWith('/fides')) {
|
||||
return convertFaithPath(path, targetLang);
|
||||
}
|
||||
|
||||
if (path.startsWith('/fitness')) {
|
||||
if (path.startsWith('/fitness') && targetLang !== 'la') {
|
||||
return convertFitnessPath(path, targetLang);
|
||||
}
|
||||
|
||||
if ((path.startsWith('/cospend') || path.startsWith('/expenses')) && targetLang !== 'la') {
|
||||
return convertCospendPath(path, targetLang);
|
||||
}
|
||||
|
||||
// Use translated recipe slugs from page data when available (works during SSR)
|
||||
const pageData = $page.data;
|
||||
if (targetLang === 'en' && path.startsWith('/rezepte')) {
|
||||
@@ -94,15 +112,18 @@
|
||||
|
||||
const dePath = $derived(computeTargetPath('de'));
|
||||
const enPath = $derived(computeTargetPath('en'));
|
||||
const laPath = $derived(computeTargetPath('la'));
|
||||
|
||||
async function switchLanguage(lang: 'de' | 'en') {
|
||||
async function switchLanguage(lang: 'de' | 'en' | 'la') {
|
||||
isOpen = false;
|
||||
|
||||
// Update the shared language store immediately
|
||||
languageStore.set(lang);
|
||||
// Update the shared language store immediately (la not tracked in store)
|
||||
if (lang !== 'la') {
|
||||
languageStore.set(lang);
|
||||
}
|
||||
|
||||
// Store preference
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
if (typeof localStorage !== 'undefined' && lang !== 'la') {
|
||||
localStorage.setItem('preferredLanguage', lang);
|
||||
}
|
||||
|
||||
@@ -112,19 +133,27 @@
|
||||
// For pages that handle their own translations inline (not recipe/faith routes),
|
||||
// dispatch event and stay on the page
|
||||
if (!path.startsWith('/rezepte') && !path.startsWith('/recipes')
|
||||
&& !path.startsWith('/glaube') && !path.startsWith('/faith')
|
||||
&& !path.startsWith('/fitness')) {
|
||||
&& !path.startsWith('/glaube') && !path.startsWith('/faith') && !path.startsWith('/fides')
|
||||
&& !path.startsWith('/fitness')
|
||||
&& !path.startsWith('/cospend') && !path.startsWith('/expenses')) {
|
||||
window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle faith pages
|
||||
if (path.startsWith('/glaube') || path.startsWith('/faith')) {
|
||||
if (path.startsWith('/glaube') || path.startsWith('/faith') || path.startsWith('/fides')) {
|
||||
const newPath = convertFaithPath(path, lang);
|
||||
await goto(newPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle cospend/expenses pages
|
||||
if ((path.startsWith('/cospend') || path.startsWith('/expenses')) && lang !== 'la') {
|
||||
const newPath = convertCospendPath(path, lang);
|
||||
await goto(newPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle fitness pages
|
||||
if (path.startsWith('/fitness')) {
|
||||
const newPath = convertFitnessPath(path, lang);
|
||||
@@ -313,6 +342,15 @@
|
||||
>
|
||||
EN
|
||||
</a>
|
||||
{#if isFaithRoute}
|
||||
<a
|
||||
href={laPath}
|
||||
class:active={displayLang === 'la'}
|
||||
onclick={(e) => { e.preventDefault(); switchLanguage('la'); }}
|
||||
>
|
||||
LA
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
children
|
||||
} = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let isVisible = $state(eager); // If eager=true, render immediately
|
||||
/** @type {HTMLDivElement | null} */
|
||||
let containerRef = $state(null);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
...restProps
|
||||
} = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let shouldLoad = $state(eager);
|
||||
/** @type {HTMLImageElement | null} */
|
||||
let imgElement = $state(null);
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
<style>
|
||||
|
||||
.links_grid {
|
||||
/* Light mode card palette */
|
||||
--card-bg-a: var(--nord6);
|
||||
--card-bg-b: var(--nord5);
|
||||
--card-bg-c: var(--nord6);
|
||||
--card-bg-d: var(--nord5);
|
||||
--card-fill-a: var(--nord11);
|
||||
--card-fill-b: var(--nord10);
|
||||
--card-fill-c: var(--nord0);
|
||||
--card-fill-d: var(--nord0);
|
||||
--card-text: var(--nord0);
|
||||
--card-shadow: rgba(0,0,0,0.04);
|
||||
--card-shadow-hover: rgba(0,0,0,0.1);
|
||||
--card-lock: var(--nord3);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(250px, calc(50% - 1rem)), 1fr));
|
||||
gap: 2rem;
|
||||
@@ -23,70 +9,19 @@
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.links_grid {
|
||||
--card-bg-a: #1a1a1a;
|
||||
--card-bg-b: #1a1a1a;
|
||||
--card-bg-c: var(--nord1);
|
||||
--card-bg-d: #000;
|
||||
--card-fill-a: var(--nord11);
|
||||
--card-fill-b: var(--nord9);
|
||||
--card-fill-c: var(--nord8);
|
||||
--card-fill-d: var(--nord7);
|
||||
--card-text: white;
|
||||
--card-shadow: rgba(0,0,0,0.08);
|
||||
--card-shadow-hover: rgba(0,0,0,0.15);
|
||||
--card-lock: var(--nord3);
|
||||
}
|
||||
/* Base fill for all icons */
|
||||
:global(.links_grid a svg:not(.lock-icon)) {
|
||||
fill: var(--grid-fill-base);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .links_grid {
|
||||
--card-bg-a: #1a1a1a;
|
||||
--card-bg-b: #1a1a1a;
|
||||
--card-bg-c: var(--nord1);
|
||||
--card-bg-d: #000;
|
||||
--card-fill-a: var(--nord11);
|
||||
--card-fill-b: var(--nord9);
|
||||
--card-fill-c: var(--nord8);
|
||||
--card-fill-d: var(--nord7);
|
||||
--card-text: white;
|
||||
--card-shadow: rgba(0,0,0,0.08);
|
||||
--card-shadow-hover: rgba(0,0,0,0.15);
|
||||
--card-lock: var(--nord3);
|
||||
/* Mottled pops — prime-offset selectors for irregular feel */
|
||||
:global(.links_grid a:nth-child(3n+1) svg:not(.lock-icon)) {
|
||||
fill: var(--grid-fill-pop-a);
|
||||
}
|
||||
:global(:root[data-theme="light"]) .links_grid {
|
||||
--card-bg-a: var(--nord6);
|
||||
--card-bg-b: var(--nord5);
|
||||
--card-bg-c: var(--nord6);
|
||||
--card-bg-d: var(--nord5);
|
||||
--card-fill-a: var(--nord11);
|
||||
--card-fill-b: var(--nord10);
|
||||
--card-fill-c: var(--nord0);
|
||||
--card-fill-d: var(--nord0);
|
||||
--card-text: var(--nord0);
|
||||
--card-shadow: rgba(0,0,0,0.04);
|
||||
--card-shadow-hover: rgba(0,0,0,0.1);
|
||||
--card-lock: var(--nord3);
|
||||
:global(.links_grid a:nth-child(5n+2) svg:not(.lock-icon)) {
|
||||
fill: var(--grid-fill-pop-b);
|
||||
}
|
||||
|
||||
:global(.links_grid a:nth-child(4n)),
|
||||
:global(.links_grid a:nth-child(4n) svg:not(.lock-icon)){
|
||||
background-color: var(--card-bg-a);
|
||||
fill: var(--card-fill-a);
|
||||
}
|
||||
:global(.links_grid a:nth-child(4n+1)),
|
||||
:global(.links_grid a:nth-child(4n+1) svg:not(.lock-icon)){
|
||||
background-color: var(--card-bg-b);
|
||||
fill: var(--card-fill-b);
|
||||
}
|
||||
:global(.links_grid a:nth-child(4n+2)),
|
||||
:global(.links_grid a:nth-child(4n+2) svg:not(.lock-icon)){
|
||||
background-color: var(--card-bg-c);
|
||||
fill: var(--card-fill-c);
|
||||
}
|
||||
:global(.links_grid a:nth-child(4n+3)),
|
||||
:global(.links_grid a:nth-child(4n+3) svg:not(.lock-icon)){
|
||||
background-color: var(--card-bg-d);
|
||||
fill: var(--card-fill-d);
|
||||
:global(.links_grid a:nth-child(5n+4) svg:not(.lock-icon)) {
|
||||
fill: var(--grid-fill-pop-c);
|
||||
}
|
||||
|
||||
:global(.links_grid a){
|
||||
@@ -95,15 +30,18 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: unset;
|
||||
color: var(--card-text);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
transition: var(--transition-normal);
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
box-shadow: 0 0.1em 0.5em 0 var(--card-shadow);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
:global(.links_grid a:hover){
|
||||
box-shadow: 0 0.2em 1em 0 var(--card-shadow-hover);
|
||||
box-shadow: var(--shadow-hover);
|
||||
scale: 1.02;
|
||||
}
|
||||
:global(.links_grid a :is(svg, img)){
|
||||
@@ -111,7 +49,7 @@
|
||||
}
|
||||
:global(.links_grid h3){
|
||||
font-size: 1.5rem;
|
||||
color: var(--card-text);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
:global(.links_grid a .lock-icon){
|
||||
position: absolute;
|
||||
@@ -119,7 +57,7 @@
|
||||
right: 0.5rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
fill: var(--card-lock);
|
||||
fill: var(--color-text-secondary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -164,6 +102,7 @@
|
||||
right: 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class=links_grid>
|
||||
|
||||
51
src/lib/components/SaveFab.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script>
|
||||
import Check from '$lib/assets/icons/Check.svelte';
|
||||
|
||||
let { disabled = false, onclick, label = 'Save', type = 'submit' } = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
class="fab-save"
|
||||
{onclick}
|
||||
{disabled}
|
||||
aria-label={label}
|
||||
>
|
||||
<Check fill="white" width="2rem" height="2rem" />
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.fab-save {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 2rem;
|
||||
margin: 2rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
background-color: var(--red);
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.fab-save:hover, .fab-save:focus {
|
||||
background-color: var(--nord11);
|
||||
}
|
||||
|
||||
.fab-save:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.fab-save {
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
import { Sun, Moon, SunMoon } from 'lucide-svelte';
|
||||
import { Sun, Moon, SunMoon } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
77
src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script>
|
||||
import { X } from '@lucide/svelte';
|
||||
import { getToasts } from '$lib/js/toast.svelte';
|
||||
|
||||
const toasts = getToasts();
|
||||
</script>
|
||||
|
||||
{#if toasts.items.length > 0}
|
||||
<div class="toast-container">
|
||||
{#each toasts.items as t (t.id)}
|
||||
<div class="toast toast-{t.type}" role="alert">
|
||||
<span class="toast-msg">{t.message}</span>
|
||||
<button class="toast-close" onclick={() => toasts.remove(t.id)} aria-label="Dismiss">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
pointer-events: auto;
|
||||
animation: slide-up 0.2s ease-out;
|
||||
}
|
||||
.toast-error {
|
||||
background: var(--nord11);
|
||||
color: var(--nord6, #eceff4);
|
||||
}
|
||||
.toast-success {
|
||||
background: var(--nord14);
|
||||
color: var(--nord0, #2e3440);
|
||||
}
|
||||
.toast-info {
|
||||
background: var(--nord10);
|
||||
color: var(--nord6, #eceff4);
|
||||
}
|
||||
.toast-msg {
|
||||
flex: 1;
|
||||
}
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
}
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(0.5rem); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { checked = $bindable(false), label = "", accentColor = "var(--color-primary)", href = undefined as string | undefined } = $props<{ checked?: boolean, label?: string, accentColor?: string, href?: string }>();
|
||||
let { checked = $bindable(false), label = "", accentColor = "var(--color-primary)", href = undefined as string | undefined, onchange = undefined as (() => void) | undefined } = $props<{ checked?: boolean, label?: string, accentColor?: string, href?: string, onchange?: () => void }>();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -96,7 +96,7 @@
|
||||
</a>
|
||||
{:else}
|
||||
<label>
|
||||
<input type="checkbox" bind:checked />
|
||||
<input type="checkbox" bind:checked onchange={onchange} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import { paymentCategoryName } from '$lib/js/cospendI18n';
|
||||
|
||||
/**
|
||||
* @type {{
|
||||
@@ -10,7 +11,7 @@
|
||||
* onFilterChange?: ((categories: string[] | null) => void) | null
|
||||
* }}
|
||||
*/
|
||||
let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null } = $props();
|
||||
let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null, lang = /** @type {'en' | 'de'} */ ('de') } = $props();
|
||||
|
||||
/** @type {HTMLCanvasElement | undefined} */
|
||||
let canvas = $state(undefined);
|
||||
@@ -20,6 +21,13 @@
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables);
|
||||
|
||||
function isDark() {
|
||||
const theme = document.documentElement.getAttribute('data-theme');
|
||||
if (theme === 'dark') return true;
|
||||
if (theme === 'light') return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
// Nord theme colors for categories
|
||||
const nordColors = [
|
||||
'#5E81AC', // Nord Blue
|
||||
@@ -66,7 +74,7 @@
|
||||
} else {
|
||||
const visible = c.data.datasets
|
||||
.filter((/** @type {any} */ _, /** @type {number} */ idx) => !c.getDatasetMeta(idx).hidden)
|
||||
.map((/** @type {any} */ ds) => /** @type {string} */ (ds.label ?? '').toLowerCase());
|
||||
.map((/** @type {any} */ ds) => /** @type {string} */ (ds._categoryKey ?? ds.label ?? '').toLowerCase());
|
||||
onFilterChange(visible);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +90,12 @@
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dark = isDark();
|
||||
const textColor = dark ? '#D8DEE9' : '#2E3440';
|
||||
const tooltipBg = dark ? '#2E3440' : '#ECEFF4';
|
||||
const tooltipText = dark ? '#ECEFF4' : '#2E3440';
|
||||
const tooltipBody = dark ? '#D8DEE9' : '#3B4252';
|
||||
|
||||
// Convert $state proxy to plain arrays to avoid Chart.js property descriptor issues
|
||||
const plainLabels = [...(data.labels || [])];
|
||||
const plainDatasets = (data.datasets || []).map((/** @type {{ label: string, data: number[] }} */ ds) => ({
|
||||
@@ -91,11 +105,12 @@
|
||||
|
||||
// Process datasets with colors and capitalize labels
|
||||
const processedDatasets = plainDatasets.map((/** @type {{ label: string, data: number[] }} */ dataset, /** @type {number} */ index) => ({
|
||||
label: dataset.label.charAt(0).toUpperCase() + dataset.label.slice(1),
|
||||
label: paymentCategoryName(dataset.label, lang),
|
||||
data: dataset.data,
|
||||
backgroundColor: getCategoryColor(dataset.label, index),
|
||||
borderColor: getCategoryColor(dataset.label, index),
|
||||
borderWidth: 1
|
||||
borderWidth: 1,
|
||||
_categoryKey: dataset.label
|
||||
}));
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
@@ -123,7 +138,7 @@
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
color: textColor,
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 14,
|
||||
@@ -157,7 +172,7 @@
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
color: '#ffffff',
|
||||
color: textColor,
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 14,
|
||||
@@ -194,7 +209,7 @@
|
||||
title: {
|
||||
display: !!title,
|
||||
text: title,
|
||||
color: '#ffffff',
|
||||
color: textColor,
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 18,
|
||||
@@ -203,9 +218,9 @@
|
||||
padding: 20
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#2e3440',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
backgroundColor: tooltipBg,
|
||||
titleColor: tooltipText,
|
||||
bodyColor: tooltipBody,
|
||||
borderWidth: 0,
|
||||
cornerRadius: 12,
|
||||
padding: 12,
|
||||
@@ -275,7 +290,7 @@
|
||||
|
||||
ctx.save();
|
||||
ctx.font = 'bold 14px Inter, system-ui, sans-serif';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillStyle = isDark() ? '#D8DEE9' : '#2E3440';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
|
||||
@@ -321,6 +336,16 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Recreate chart when lang changes
|
||||
let prevLang = lang;
|
||||
$effect(() => {
|
||||
const currentLang = lang;
|
||||
if (currentLang !== prevLang) {
|
||||
prevLang = currentLang;
|
||||
untrack(() => { if (canvas) createChart(); });
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
createChart();
|
||||
// Enable animations for subsequent updates (legend toggles, etc.)
|
||||
@@ -367,24 +392,12 @@
|
||||
|
||||
<style>
|
||||
.chart-container {
|
||||
background: var(--nord6);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--nord4);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .chart-container {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .chart-container {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.chart-container {
|
||||
padding: 0.75rem;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
import { formatCurrency } from '$lib/utils/formatters';
|
||||
import { detectCospendLang, locale, t } from '$lib/js/cospendI18n';
|
||||
|
||||
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||
const loc = $derived(locale(lang));
|
||||
|
||||
/**
|
||||
* @typedef {{ username: string, netAmount: number, transactions: Array<any> }} DebtEntry
|
||||
@@ -61,19 +66,19 @@
|
||||
|
||||
{#if !shouldHide}
|
||||
<div class="debt-breakdown">
|
||||
<h2>Debt Overview</h2>
|
||||
<h2>{t('debt_overview', lang)}</h2>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading debt breakdown...</div>
|
||||
<div class="loading">{t('loading_debt_breakdown', lang)}</div>
|
||||
{:else if error}
|
||||
<div class="error">Error: {error}</div>
|
||||
<div class="error">{t('error_prefix', lang)}: {error}</div>
|
||||
{:else}
|
||||
<div class="debt-sections">
|
||||
{#if debtData.whoOwesMe.length > 0}
|
||||
<div class="debt-section owed-to-me">
|
||||
<h3>Who owes you</h3>
|
||||
<h3>{t('who_owes_you', lang)}</h3>
|
||||
<div class="total-amount positive">
|
||||
Total: {formatCurrency(debtData.totalOwedToMe, 'CHF', 'de-CH')}
|
||||
{t('total', lang)}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)}
|
||||
</div>
|
||||
|
||||
<div class="debt-list">
|
||||
@@ -83,11 +88,11 @@
|
||||
<ProfilePicture username={debt.username} size={40} />
|
||||
<div class="user-details">
|
||||
<span class="username">{debt.username}</span>
|
||||
<span class="amount positive">{formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
||||
<span class="amount positive">{formatCurrency(debt.netAmount, 'CHF', loc)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-count">
|
||||
{debt.transactions.length} transaction{debt.transactions.length !== 1 ? 's' : ''}
|
||||
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -97,9 +102,9 @@
|
||||
|
||||
{#if debtData.whoIOwe.length > 0}
|
||||
<div class="debt-section owe-to-others">
|
||||
<h3>You owe</h3>
|
||||
<h3>{t('you_owe_section', lang)}</h3>
|
||||
<div class="total-amount negative">
|
||||
Total: {formatCurrency(debtData.totalIOwe, 'CHF', 'de-CH')}
|
||||
{t('total', lang)}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)}
|
||||
</div>
|
||||
|
||||
<div class="debt-list">
|
||||
@@ -109,11 +114,11 @@
|
||||
<ProfilePicture username={debt.username} size={40} />
|
||||
<div class="user-details">
|
||||
<span class="username">{debt.username}</span>
|
||||
<span class="amount negative">{formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
||||
<span class="amount negative">{formatCurrency(debt.netAmount, 'CHF', loc)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-count">
|
||||
{debt.transactions.length} transaction{debt.transactions.length !== 1 ? 's' : ''}
|
||||
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
|
||||
import { detectCospendLang, locale, t } from '$lib/js/cospendI18n';
|
||||
|
||||
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||
const loc = $derived(locale(lang));
|
||||
|
||||
let { initialBalance = null, initialDebtData = null } = $props<{ initialBalance?: any, initialDebtData?: any }>();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let balance = $state(initialBalance || {
|
||||
netBalance: 0,
|
||||
recentSplits: []
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
let debtData = $state(initialDebtData || {
|
||||
whoOwesMe: [],
|
||||
whoIOwe: [],
|
||||
totalOwedToMe: 0,
|
||||
totalIOwe: 0
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
let loading = $state(!initialBalance || !initialDebtData);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
@@ -95,7 +103,7 @@
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return formatCurrencyUtil(Math.abs(amount), 'CHF', 'de-CH');
|
||||
return formatCurrencyUtil(Math.abs(amount), 'CHF', loc);
|
||||
}
|
||||
|
||||
// Export refresh method for parent components to call
|
||||
@@ -114,29 +122,29 @@
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-content">
|
||||
<h3>Your Balance</h3>
|
||||
<div class="loading">Loading...</div>
|
||||
<h3>{t('your_balance', lang)}</h3>
|
||||
<div class="loading">{t('loading', lang)}</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<h3>Your Balance</h3>
|
||||
<div class="error">Error: {error}</div>
|
||||
<h3>{t('your_balance', lang)}</h3>
|
||||
<div class="error">{t('error_prefix', lang)}: {error}</div>
|
||||
{:else if shouldShowIntegratedView}
|
||||
<!-- Enhanced view with single user debt -->
|
||||
<h3>Your Balance</h3>
|
||||
<h3>{t('your_balance', lang)}</h3>
|
||||
<div class="enhanced-balance">
|
||||
<div class="main-amount">
|
||||
{#if balance.netBalance < 0}
|
||||
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
|
||||
<small>You are owed</small>
|
||||
<small>{t('you_are_owed', lang)}</small>
|
||||
{:else if balance.netBalance > 0}
|
||||
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
|
||||
<small>You owe</small>
|
||||
<small>{t('you_owe_balance', lang)}</small>
|
||||
{:else}
|
||||
<span class="even">CHF 0.00</span>
|
||||
<small>You're all even</small>
|
||||
<small>{t('all_even', lang)}</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="debt-details">
|
||||
<div class="debt-user">
|
||||
{#if singleDebtUser && singleDebtUser.user}
|
||||
@@ -146,9 +154,9 @@
|
||||
<span class="username">{singleDebtUser.user.username}</span>
|
||||
<span class="debt-description">
|
||||
{#if singleDebtUser.type === 'owesMe'}
|
||||
owes you {formatCurrency(singleDebtUser.amount)}
|
||||
{t('owes_you_balance', lang)} {formatCurrency(singleDebtUser.amount)}
|
||||
{:else}
|
||||
you owe {formatCurrency(singleDebtUser.amount)}
|
||||
{t('you_owe_user', lang)} {formatCurrency(singleDebtUser.amount)}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -158,24 +166,24 @@
|
||||
</div>
|
||||
<div class="transaction-count">
|
||||
{#if singleDebtUser && singleDebtUser.user && singleDebtUser.user.transactions}
|
||||
{singleDebtUser.user.transactions.length} transaction{singleDebtUser.user.transactions.length !== 1 ? 's' : ''}
|
||||
{singleDebtUser.user.transactions.length} {singleDebtUser.user.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Standard balance view -->
|
||||
<h3>Your Balance</h3>
|
||||
<h3>{t('your_balance', lang)}</h3>
|
||||
<div class="amount">
|
||||
{#if balance.netBalance < 0}
|
||||
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
|
||||
<small>You are owed</small>
|
||||
<small>{t('you_are_owed', lang)}</small>
|
||||
{:else if balance.netBalance > 0}
|
||||
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
|
||||
<small>You owe</small>
|
||||
<small>{t('you_owe_balance', lang)}</small>
|
||||
{:else}
|
||||
<span class="even">CHF 0.00</span>
|
||||
<small>You're all even</small>
|
||||
<small>{t('all_even', lang)}</small>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||