184 Commits

Author SHA1 Message Date
f2f40dcd2d feat(faith): render 1962 Mass propers with scripture refs and Bible fallback
All checks were successful
CI / update (push) Successful in 4m32s
Show propers text for each 1962 celebration with scripture reference pills
grouping each block. When a translated proper is missing, fall back to the
local-language Bible (Douay-Rheims for en, Allioli for de), showing a note
above the translated column. Handles multi-segment refs (e.g. "Ps 118:85;
118:46") with inherited book/chapter, and shifts Vulgate→Hebrew psalm
numbering for Allioli.

Also restructures date navigation as folder-based optional params
(/yyyy/mm/dd) with the rite forced as a required path segment so day/month
navigation stays within the active rite.
2026-04-14 22:44:03 +02:00
e7772e7cc9 feat(faith): show accuracy disclaimer on 1962 calendar and bump romcal
Some checks failed
CI / update (push) Failing after 27s
Add a small banner on the 1962 rite view noting that day-to-day data is
still being verified and that local proper calendars (diocese, order,
national feasts) are not yet layered on top of the general Roman
calendar. Bump the romcal dep to AlexBocken/romcal1962#e4731a8 for the
Holy Week / Easter Octave name fixes and the Pentecost-season color fix
(ordinary-time Sundays are now Green, not White/Red).
2026-04-13 18:56:43 +02:00
e6e50c7fe2 feat(stickers): expand blobcat catalog with Tirifto set and drop mismatched art
Replace four tastytea-style SVGs (blanket, hammer, teapot, heart_tastytea)
whose 800px viewBox clashed with the 33.866666 viewBox of the rest, and add
the full Tirifto gutkato_ set. Catalog grows from 54 to 113 positive stickers
(new foods/drinks, rose and cat colors, professions, cute expressions, signs).
2026-04-13 18:56:25 +02:00
092ec26cd4 feat(faith): add calendar tile to faith landing and soften rite wording
All checks were successful
CI / update (push) Successful in 3m35s
Adds a calendar tile to the /faith (glaube/fides) LinksGrid linking to the
liturgical calendar. Homepage description and WIP body reference the "older
rite" / "alten Ritus" rather than "Tridentine" to avoid loaded connotations;
subtitle labels keep the neutral Ordinary/Extraordinary Form terminology.
2026-04-13 11:39:11 +02:00
3ab339042b feat(faith): add liturgical calendar with 1969/1962 rite toggle
All checks were successful
CI / update (push) Successful in 3m42s
Adds `/faith/calendar`, `/glaube/kalender`, `/fides/calendarium` route backed by
romcal v3 + @romcal/calendar.general-roman with native EN/DE/LA locales. Month
grid, today hero, and day detail panel use liturgical colors from the rubric.

Header gets a segmented 1969/1962 pill toggle; selecting 1962 shows a WIP
placeholder (Tridentine calendar data not yet wired up).
2026-04-13 10:55:04 +02:00
1d798c94bf fix(rosary): trigger haptic on pointerup so iOS Safari vibrates
WebKit's hidden <input type="checkbox" switch> haptic trick is gated on
tap-completion, not tap-start. Firing on pointerdown ran the programmatic
click() before iOS committed to "this is a tap" so the switch-toggle
haptic was suppressed. pointerup lands inside iOS's tap-completion
window — still earlier than click (no movement filter, no 300ms wait).
Android native bridge path is unaffected.
2026-04-13 10:54:07 +02:00
61681dd556 feat(nutrition): reactive quick-log recents and favorites
All checks were successful
CI / update (push) Successful in 3m43s
Derive recents from current-day entries merged with historical
server data so logging a food updates the quick-log bar instantly.
FoodSearch now emits onfavoritechange so toggling a heart keeps
the quick-log favorites tab in sync without reloading.
2026-04-13 09:52:31 +02:00
84b45a6d2a feat(fitness): replace ExercisePicker dropdowns with icon pills
Align muscle group and equipment filters in the exercise picker
with the rest of the fitness UI — horizontal pill scrollers, multi-
select, with Lucide icons for each equipment type.
2026-04-13 09:52:25 +02:00
6c160bc6cf feat(nutrition): change meal of logged food via edit pills or drag-and-drop
Edit mode now shows a pill row (breakfast/lunch/dinner/snack with their
icons) beneath the grams input so the entry can be moved to another meal.
Food cards are also draggable; dropping onto a different meal section
optimistically moves the entry (rolls back on API error). Desktop/pointer
only — touch devices use the pill row.
2026-04-13 09:34:47 +02:00
63a10df7c5 feat(fitness): support repeat groups in interval training templates
All checks were successful
CI / update (push) Successful in 4m8s
Allow users to nest steps inside a repeat group (e.g. "5×: 30s sprint,
60s recovery") when building GPS interval templates. Groups are tagged
{ type: 'group', repeat, steps } alongside flat { type: 'step' } entries
and capped at one nesting level. Entries are expanded into a flat list
before handing off to the native Android TTS/interval service, so the
runtime state machine is unchanged.
2026-04-13 09:17:55 +02:00
6e48cfd27c feat(fitness): classify exercises by type and redesign filter UI
Distinguish stretches, strength, cardio, and plyometric exercises
with a curated `exerciseType` field that overrides mislabels in
ExerciseDB for the hand-curated stretch set. Surface the type as
pills on the detail page and as a filter toggle on the list page.

Replace the muscle-group and equipment dropdowns with horizontally
scrolling pill rows; equipment pills carry Lucide icons, and the
type toggle shows a flexed-bicep / person / layers glyph. Selected
muscle groups hoist to the front of the scroller.
2026-04-13 08:59:12 +02:00
5416110e81 feat(recipes): redesign cake-form and baking info with collapsible card pattern
All checks were successful
CI / update (push) Successful in 3m38s
Viewer: cake-form adjust now collapses into a summary trigger with live
factor badge; shape picker replaced with icon-only tiles that flex to
fill the row; numeric inputs gain inline cm suffix; restore-default link
appears when user deviates from the default. Editor: default-Backform
config mirrors the same card + tile pattern (adds "none" tile), plus
inline cm suffixes. Baking info row in instruction editor becomes a
click-to-reveal card with summary chips, mode presets, and editable
fields behind the chevron.
2026-04-12 21:46:51 +02:00
49665c94db feat: align recipe edit page with viewer design
All checks were successful
CI / update (push) Successful in 3m49s
Replace CardAdd with EditTitleImgParallax so /rezepte/edit/[name]
mirrors the hero-parallax layout of /rezepte/[name]. Add Lucide icons
and a width-constrained info-card grid to CreateStepList's additional
info section. Refactor the English translation view to use semantic
theme vars, side-by-side German/English field comparison, and a card
aesthetic matching the rest of the site.

Fix portions binding bug where the shared portions store was being
written by both language ingredient lists. CreateIngredientList now
accepts a bindable portions prop; the English list uses it with
useStore=false to stay isolated from the German value.
2026-04-12 21:22:53 +02:00
be7880304c feat: add tactile haptic feedback to rosary prayer cards
All checks were successful
CI / update (push) Successful in 3m56s
Every prayer card now vibrates on tap — non-decade cards advance to the
next section, decade cards increment the Ave Maria counter with auto-scroll
at 10. Two profiles (bead vs card) give distinct tactile feel; the 10th
bead fires the heavier card haptic to mark decade completion.

Native Android path via AndroidBridge.forceVibrate uses VibrationAttributes
USAGE_ACCESSIBILITY so vibration bypasses silent / Do-Not-Disturb inside
the Tauri app. Browser falls back to the web-haptics npm package. Haptic
fires on pointerdown with touch-action: manipulation for near-zero tap
latency; state change stays on click so scroll gestures don't advance.

- Remove CounterButton (whole card is now the tap target)
- Replace emoji with Lucide BookOpen icon, restyle citation as an
  understated inline typographic link (no background chip)
- Drop decade min-height leftover from the pre-auto-advance layout

Bumps site to 1.27.0 and Tauri app to 0.5.0 (new Android capability).
2026-04-12 20:15:40 +02:00
8023a907de fix: adjust LinksGrid nth-child offsets for earlier, more frequent color pops
Shift pop-b and pop-c selectors so accent colors appear sooner in the
grid and the light/white pop-c repeats more frequently (every 5th
instead of every 7th item).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:34:23 +02:00
35a98f6a0a feat: add chat.bocken.org link to homepage links grid
All checks were successful
CI / update (push) Successful in 3m39s
2026-04-11 19:39:32 +02:00
b4da24b572 feat: add hold timer for timed exercises with full sync support
All checks were successful
CI / update (push) Successful in 3m32s
- Play/Stop button replaces checkmark for duration-only exercises
- Green countdown bar with auto-completion and rest timer chaining
- Display duration in seconds (SEC) instead of minutes for holds
- ActiveWorkout model now preserves distance/duration fields on sync
- Hold timer state syncs across devices via SSE
- Workout summary shows per-set hold times for duration exercises
- Template diff compares and displays duration changes correctly
2026-04-11 17:40:58 +02:00
a5daae3fc9 fix: add 15 stretching exercises to exercise database
Exercises used by the Day 6 stretching template were only in
exercisedb-map.ts but missing from exercises.ts, causing the
template detail to show raw IDs instead of proper names.
2026-04-11 16:14:34 +02:00
3b11cb9878 feat: record cadence from step detector during GPS workouts
All checks were successful
CI / update (push) Successful in 3m43s
Use Android TYPE_STEP_DETECTOR sensor in LocationForegroundService to
count steps in a 15s rolling window. Cadence (spm) is computed at each
GPS point and stored alongside lat/lng/altitude/speed. Session detail
page shows cadence chart when data is available.

No additional permissions required — step detector is not a restricted
sensor. Gracefully skipped on devices without the sensor.
2026-04-11 15:28:27 +02:00
b209f6e936 feat: add elevation chart and gain/loss stats to GPS workout detail 2026-04-11 15:17:34 +02:00
9527c253ed feat: add template library for browsing and adding defaults
All checks were successful
CI / update (push) Successful in 3m55s
Replace auto-seed with a browsable template library. Users can
selectively add built-in templates to their collection via a
BookOpen icon or the empty-state prompt. Each library template
tracks its origin via libraryId to prevent duplicates.

- Extract default templates to shared $lib/data/defaultTemplates.ts
- Add GET/POST /api/fitness/templates/library endpoint
- Add library modal with add/added state per template
- Keep seed endpoint as fallback (imports from shared data)
2026-04-11 15:02:58 +02:00
8591e5cff7 feat: add Day 6 stretching template to seed defaults
Full-body stretching session (~30 min) covering all major muscle
groups with 15 bodyweight exercises: neck, shoulders, chest, back,
spine, hips, hamstrings, quads, glutes, calves, and arms.
Each exercise has 2 sets of 60-90s holds with 15s rest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 13:17:05 +02:00
0b86f72d92 feat: project exercise kcal from next scheduled template
All checks were successful
CI / update (push) Successful in 4m2s
When no workout is logged for the day, look up the next template
in the schedule rotation and show the kcal from its most recent
session as a projection. Tappable toggle includes/excludes it
from the calorie goal, ring, and macro bars for meal planning.
2026-04-11 13:11:32 +02:00
e0b932127d fix: increase shopping icon sizes for better visibility
All checks were successful
CI / update (push) Successful in 3m51s
List icons: 44→56px desktop, 36→48px mobile.
Icon picker modal: 42→56px grid cells.
2026-04-11 10:19:17 +02:00
8cb3d3c4eb fix: sync runtime shopping catalog with source catalog
shoppingCatalog.json was missing all 22 new entries (11 icons
with aliases) added to catalog.json, so new icons like stroh80
were never matched at runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:14:32 +02:00
3b42d6b01d fix: correct ~100 misassigned shopping icon categories
All checks were successful
CI / update (push) Successful in 3m46s
The embedding model assigned many items to wrong categories
(e.g. rum→Milchprodukte, zahnbürsten→Fleisch, pflaumen→Hygiene).
Manually reviewed and corrected all 419 entries.
2026-04-11 09:39:31 +02:00
ae33591529 feat: add 11 new shopping icons and processing script
All checks were successful
CI / update (push) Successful in 3m54s
Add processed icons for glasnudeln, grünkohl, kokosnuss, lychee,
mangold, pak choi, pastinaken, reisnudeln, rettich, stroh 80, and
topinambur. Add ImageMagick script to remove Gemini watermark and
black background from raw icons. Update catalog and re-embed.
2026-04-11 09:33:09 +02:00
77badc6a36 fix: make macro progress bar labels concise with goal context
All checks were successful
CI / update (push) Successful in 3m58s
Show remaining as "10/150g left" and over as "10g over 150g".
Remove redundant per-day goal from label. Shorten "remaining" to "left".
2026-04-11 08:44:26 +02:00
46d7833e75 fix: make custom meal logging reactive by updating entries locally
logCustomMeal and inlineLogCustomMeal relied on goto() to re-run the
page load function, but SvelteKit skips it when the URL doesn't change.
Now they update the entries array directly like the other log functions.
2026-04-11 08:43:50 +02:00
1265647963 fix: use Atwater calories for consistent macro/calorie tracking
All checks were successful
CI / update (push) Successful in 3m41s
Calorie ring and macro progress bars now both use Atwater-derived
calories (P×4 + F×9 + C×4) instead of DB calories, so hitting all
three macro goals guarantees hitting the calorie goal exactly.

Also: show full daily TDEE (not time-based), show BMR/NEAT multiplier
breakdown in info tooltip, display macro goal grams on progress bars,
fix TDEE tooltip z-index on desktop.
2026-04-10 21:22:24 +02:00
ee4eda7a32 feat: restyle recipe info cards with grid layout and lucide icons
All checks were successful
CI / update (push) Successful in 3m42s
Align recipe metadata cards with site-wide design language using
surface colors, borders, and rounded corners. Add distinct lucide
icons per card type (Timer, Wheat, Croissant, Flame, CookingPot,
UtensilsCrossed) and switch from flex-wrap to CSS grid for uniform
card widths.
2026-04-10 20:55:48 +02:00
72a77b9dc3 feat: path-based month URLs for workout history with month navigation
All checks were successful
CI / update (push) Successful in 3m37s
Add ?month=YYYY-MM filter to sessions API. Migrate history page to
/fitness/history/[[month]] optional param route. Default view shows last
2 months; specific month view via /fitness/history/2026-04. Replace
load-more button with prev/next month anchor navigation.
2026-04-10 08:57:13 +02:00
b7444e8bc7 feat: use path-based date URLs for nutrition page
All checks were successful
CI / update (push) Successful in 3m25s
Migrate /fitness/nutrition?date=YYYY-MM-DD to /fitness/nutrition/YYYY-MM-DD
using SvelteKit optional param [[date=fitnessDate]]. Replace date nav
buttons with anchor tags for native browser navigation. Today resolves to
the clean /fitness/nutrition path without a date segment.
2026-04-10 08:47:48 +02:00
637a918dd8 feat: replace all native date inputs with custom DatePicker component
All checks were successful
CI / update (push) Successful in 3m36s
Add theme-aware DatePicker with pill display, calendar dropdown, prev/next
day arrows, bilingual month/weekday names, and min/max support. Replace all
15 native <input type="date"> elements across fitness, tasks, and cospend.
2026-04-10 08:36:33 +02:00
82a27c3f51 feat: inline measurement form on /fitness/measure, remove /add route
All checks were successful
CI / update (push) Successful in 3m49s
Move weight hero card, body fat and body parts accordions directly
onto the main measure page. SaveFab only appears when a field has
been changed. After saving, form resets and history updates in place.
Fix response unwrapping (created.measurement) that caused Invalid Date.
2026-04-10 08:22:23 +02:00
b7c66b6f07 feat: redesign measurement add page with weight-focused layout
Replace flat form with hero weight card (±0.1 stepper, last-weight
placeholder, clear button) and collapsible accordion sections for
body fat and body part measurements. Body parts grouped by region.
2026-04-10 08:12:18 +02:00
0ad72ddf24 fix: compute macro targets dynamically from protein goal and body weight
All checks were successful
CI / update (push) Successful in 4m3s
Fat/carb percentages were stored as absolute values that didn't account
for protein g/kg varying with body weight. Now protein calories are
computed first, and remaining calories are split between fat and carbs
by their stored ratio — guaranteeing all macros sum to the calorie goal.

Exercise burned calories also flow into fat/carb targets via a new
effectiveCalorieGoal derived. Goal editor ring preview and labels
updated to show computed actual percentages.
2026-04-10 08:01:08 +02:00
89b3202dc5 fix: include exercise calories in diet adherence calculation
All checks were successful
CI / update (push) Successful in 3m55s
Adherence was comparing intake against the flat calorie goal, ignoring
burned workout calories. Now the per-day target is goal + exercise kcal.
Also expanded workout query from 7 to 30 days to cover the full
adherence window.
2026-04-10 07:53:19 +02:00
0ae16ddd6d fix: correct week count in period tracker relative dates
All checks were successful
CI / update (push) Successful in 3m44s
Math.floor(days/7) was off by one — e.g. 12 days away showed "in 1 week"
instead of "in 2 weeks". Using Math.ceil matches colloquial usage.
2026-04-09 23:51:07 +02:00
fd137bc519 fix: show round-off suggestions on future days, not just today
Allow meal prep planning by relaxing the isToday check to isTodayOrFuture
in both SSR and client-side rendering of the round-off card.
2026-04-09 23:51:00 +02:00
1472451ac4 feat: SSR shopping list by fetching initial data server-side
All checks were successful
CI / update (push) Successful in 3m33s
Server load now fetches the shopping list from the DB and passes it as
initialList. The sync layer seeds state immediately in the script block
(not onMount) so SSR renders the full list. SSE connects client-side
in onMount for real-time updates.
2026-04-09 23:20:12 +02:00
2dfed11fd6 feat: add 7 new shopping icons and deploy hook
All checks were successful
CI / update (push) Successful in 3m36s
Add Gemini-generated icons: aperol, bulgur, emmentaler, magerquark,
quinoa, raeucherlachs, tortilla_wraps. Update catalog with 11 new
entries (including aliases), regenerate embeddings and categories.
Add post-commit hook to rsync shopping icons to bocken.org.
2026-04-09 23:09:09 +02:00
68349fbf46 fix: align MysterySelector buttons with site-wide design language
All checks were successful
CI / update (push) Successful in 3m50s
Replace hardcoded Nord values and manual dark/light overrides with
semantic CSS variables (--color-surface, --color-primary, --shadow-sm,
--radius-card, etc.). Use scale:1.02 hover pattern and remove all
@media(prefers-color-scheme) and :global(:root[data-theme]) blocks.
2026-04-09 22:08:14 +02:00
b5cbc3f74b feat: redesign LinksGrid with semantic theming, rounded corners, and new layout
Some checks failed
CI / update (push) Has been cancelled
- Use semantic CSS variables (--color-surface, --shadow-sm, etc.) instead
  of hardcoded Nord values and manual dark mode overrides
- Add border-radius and overflow:hidden for rounded card corners
- Move icon fill variables (--grid-fill-*) into app.css theme system:
  colorful (red/orange/green) in light, cool blues in dark
- Mottled fill distribution via prime-offset nth-child selectors
- Reorder homepage links: Recipes, Shopping, Fitness, Faith, Tasks first
- Add Nutrition direct link with heart-pulse icon
- Document site-wide design language in CLAUDE.md
2026-04-09 22:05:09 +02:00
62515b95f0 fix: extend recipe hero images into status bar safe area
All checks were successful
CI / update (push) Successful in 3m30s
Account for env(safe-area-inset-top) in the hero negative margin-top
so images fill the status bar area on edge-to-edge Android instead of
leaving empty space.
2026-04-09 21:33:53 +02:00
830a1f98a9 fix: use gradient overlay instead of box-shadow for status bar shadow
The box-shadow rendered outside the pseudo-element, placing the shadow
below the status bar boundary. Switch to a multi-stop linear gradient
inside the element so the shadow fades smoothly through the status bar
area (+20% overshoot for softer transition).
2026-04-09 21:31:26 +02:00
396174fd34 fix: compute round-off card visibility server-side to prevent flicker
All checks were successful
CI / update (push) Successful in 3m37s
Server pre-computes initialShowRoundOff from food log totals and goals.
SSR uses this value; client $effect sets hasHydrated=true after mount,
switching to the reactive $derived for live updates.
2026-04-09 21:13:08 +02:00
6e2fdce7f3 feat: include custom meals in round-off combinatorial food pool
Custom meals are now resolved to per100g nutrition data and added
to the base food pool, allowing them to appear in 1-3 food combo
suggestions alongside pantry items and favorites.
2026-04-09 21:06:31 +02:00
6c44758e55 feat: replace P/F/C text labels with Lucide macro icons across fitness routes
All checks were successful
CI / update (push) Successful in 3m53s
Use Beef (protein), Droplet (fat), Wheat (carbs) icons consistently in
ring graphs, macro bars, food cards, detail rows, ingredient lists, and
stats page. Add labelIcon snippet prop to RingGraph/StatsRingGraph. Show
macro split legend always (was hidden on mobile) and group it with the
title in horizontal layout.
2026-04-09 21:00:46 +02:00
a1b80862f5 feat: add "round off this day" nutrition suggestions
Suggest optimal 1-3 food combinations to fill remaining macro budget using
weighted least-squares solver over a curated pantry (~55 foods) plus user
favorites/recents. Recipes scored individually (no combining). Features:

- Combinatorial solver (singles, pairs, triples) with macro-weighted scoring
- MealTypePicker component (extracted from quick-log, shared)
- Hero card with fit%, macro delta icons (Beef/Droplet/Wheat), ingredient
  cards, animated +/X toggle for logging
- Responsive layout: sidebar on mobile, center column on desktop
- MongoDB cache with ±5% tolerance, SSR on cache hit, TTL auto-expiry
- Cache invalidation on food-log/favorites/custom-meals CRUD
- Recipe per100g backfill admin endpoint
2026-04-09 20:47:32 +02:00
385e21b109 feat: add food detail pages for OFF and custom meal sources
All checks were successful
CI / update (push) Successful in 3m40s
Extend the nutrition detail page to support OpenFoodFacts items (looked up
by barcode) and custom meals (with ingredient breakdown). All food diary
cards and search results now link to detail pages regardless of source.
2026-04-09 18:17:43 +02:00
72b49baeab feat: add muscle visualization to exercise detail page
All checks were successful
CI / update (push) Successful in 3m42s
New MuscleMap component highlights primary muscles at full opacity and
secondary muscles at 40% using the existing body SVG diagrams. Only
renders front/back views that have active muscles.

Responsive layout: centered inline on mobile, sticky sidebar on desktop.
2026-04-09 00:27:05 +02:00
04284a7238 feat: add concise bilingual overviews for all 254 exercises
All checks were successful
CI / update (push) Successful in 3m34s
- Fix ExerciseDB data quality (remove empty calf raise, fix Wall Sit,
  correct muscles, typos)
- Rewrite verbose AI-generated English overviews to concise one-sentence
  descriptions
- Add German translations for all 199 EDB exercises (name, instructions,
  overview)
- Add English and German overviews for 55 static-only exercises
- Display overview above instructions on exercise detail page
2026-04-09 00:21:10 +02:00
133d121f84 feat: add recipe food detail route with hero image, favorites, and logged nutrition
All checks were successful
CI / update (push) Successful in 3m37s
Recipes logged in the food diary now link to a dedicated detail page at
/fitness/nutrition/food/recipe/{id} showing full nutritional breakdown,
hero image, and a link back to the recipe page. When opened from the
diary, the logged per100g snapshot is used; otherwise current recipe
nutrition is computed. Recipe favorites are now supported across the
favorite-ingredients API, nutrition lookup, and search endpoints.
2026-04-08 23:08:52 +02:00
9d8d1ec41f refactor: extract RingGraph, StatsRingGraph, MacroBreakdown components
All checks were successful
CI / update (push) Successful in 3m28s
Consolidate 6 duplicated instances of the SVG arc ring pattern into a
composable component hierarchy: RingGraph (base ring), StatsRingGraph
(ring with target markers), and MacroBreakdown (3 rings + kcal + detail
table). Removes ~400 lines of duplicated SVG/CSS from FoodSearch,
nutrition page, meals page, stats page, food detail page, and recipe
NutritionSummary.
2026-04-08 22:21:24 +02:00
dff8bccae1 fix: use --color-text-on-primary for all primary button text
All checks were successful
CI / update (push) Successful in 3m38s
Replace hardcoded white/#fff text on --color-primary buttons with
var(--color-text-on-primary) for proper theme contrast across:
- nutrition meals page (create btn + modal buttons)
- history detail page (save btn + start workout btn)
- recipe page (active filter chips)
- nutrition quick-log confirm button
2026-04-08 21:44:36 +02:00
48e277cf19 feat: custom meal detail screen, favorites tab, enhanced food search detail
All checks were successful
CI / update (push) Successful in 3m36s
- Replace inline amount row with full detail screen for custom meals
  showing calorie headline, macro rings, macro breakdown, and ingredients
- Add Favorites tab (with filter) between Search and Custom Meals tabs
- Search tab no longer prepends unmatched favorites
- Enhance FoodSearch selected view with macro rings and nutrient breakdown
- Add filter input to custom meals tab
- Document --color-primary/--color-text-on-primary in CLAUDE.md
2026-04-08 21:38:49 +02:00
b287affeb2 feat: add Today quick-navigate button to nutrition and period tracker
All checks were successful
CI / update (push) Successful in 3m36s
Shows a Today button when not viewing current date/month. Nutrition
page button appears right-aligned in the date nav. Period tracker
button appears top-right of the calendar header with centered
month title and chevrons.
2026-04-08 20:46:58 +02:00
aaeb0d1083 fix: SSR improvements for nutrition desktop layout
All checks were successful
CI / update (push) Successful in 3m38s
Move favorites and recent foods fetching to server load (parallel with
existing queries). Replace client-side $effect DOM manipulation for
fitness-content max-width with reactive CSS variable in layout via
route detection. Removes client-side fetch-on-mount pattern.
2026-04-08 20:42:46 +02:00
38860df660 feat: add favorite toggle to food detail page
Heart button in food header to add/remove favorites via the
existing favorite-ingredients API. Checks status on load, toggles
optimistically with error handling.
2026-04-08 20:38:18 +02:00
62b5c4c240 fix: remove tap highlight on interactive mobile elements
All checks were successful
CI / update (push) Successful in 3m46s
Add -webkit-tap-highlight-color: transparent to muscle heatmap,
water cups, exercise filter pills, and flip stats legend target
triangle 180 degrees.
2026-04-08 20:34:10 +02:00
54baf9eddb feat: quick-log sidebar with favorites and recent foods
Desktop sidebar (1600px+) for one-click food logging with inline amount
input. Favorites and recent items (last 3 days) shown with meal type
auto-selected by time of day. New /api/nutrition/lookup endpoint for
exact source+id food data retrieval. Parent container width override
via JS class toggle for reliable SvelteKit client-side navigation.
2026-04-08 20:30:13 +02:00
54b3bc6309 feat: desktop micronutrient card below water tracker
Micros use a snippet to render in two locations: inline inside the
daily-summary card on mobile (toggle accordion, unchanged), and as a
standalone always-visible card below the water tracker on desktop.
Also bumps desktop max-width to 1400px.
2026-04-08 20:13:08 +02:00
9150d1b8da feat: two-column desktop layout for nutrition page
All checks were successful
CI / update (push) Successful in 3m26s
At 1024px+, nutrition page switches from single-column (600px max) to a
two-column grid: sticky sidebar (summary, goals, water) + scrollable
meals column. Mobile layout unchanged via display:contents fallback.
2026-04-08 17:22:16 +02:00
6e161dc677 fix: remove redundant back links from nutrition sub-pages
All checks were successful
CI / update (push) Successful in 3m39s
Header navigation and browser history already provide this
functionality — the inline back links were unnecessary clutter.
2026-04-08 16:58:37 +02:00
340b4f6023 fix: calorie balance uses per-day TDEE from SMA trend weight + workout kcal
Balance is now intake minus estimated expenditure rather than intake
minus calorie goal. TDEE computed per day using that day's SMA trend
weight (Mifflin-St Jeor BMR × NEAT multiplier) plus tracked workout
calories, so a -500 kcal cut shows ~-500 on the balance card.
2026-04-08 16:58:15 +02:00
111fa91427 feat: replace browser confirm() with reusable ConfirmDialog component
Promise-based modal dialog with backdrop, keyboard support, and animations,
replacing all 18 native confirm() call sites across fitness, cospend, recipes,
and tasks pages.
2026-04-08 16:47:22 +02:00
a54c11145d fix: macro split sidebar breakpoint to 750px, polish legend icons
All checks were successful
CI / update (push) Successful in 3m40s
Lower desktop breakpoint from 1024px to 750px so the macro column
appears on more screens. Legend now uses horseshoe arc for actual
and rounded triangle for target.
2026-04-08 16:34:24 +02:00
6ad394d3a5 fix: invert shopping list icons to black in light mode
White PNG icons were invisible on light backgrounds. Added semantic
--shopping-icon-filter variable (invert(1) in light, none in dark)
applied to card and picker icons.
2026-04-08 16:30:06 +02:00
74d5562fed feat: desktop layout — macro split sidebar next to muscle heatmap
On desktop (≥1024px), protein/balance/adherence cards sit in a row above
the muscle heatmap, with the macro split card as a vertical sidebar on the
right spanning the full height. Includes ring/triangle legend for
actual vs target. Mobile layout unchanged.
2026-04-08 16:26:02 +02:00
61d80fe0bc feat: add info popover tooltips to calorie balance and adherence cards
Clicking the (i) icon on the calorie balance or adherence card now shows
a floating popover explaining how each metric is calculated, with EN/DE
translations.
2026-04-08 16:14:58 +02:00
41a9a0828c feat: auto-track liquids from custom meal ingredients in hydration tracker
When logging a custom meal, liquid ingredients (BLS drinks, water, beverages)
are detected and their volume stored as `liquidMl` on the food log entry.
The liquid tracker cups and list now include these meal-sourced liquids.
2026-04-08 16:06:12 +02:00
a5de45f56a feat: add EN/DE internationalization to cospend section
All checks were successful
CI / update (push) Successful in 3m30s
Move cospend routes to parameterized [cospendRoot=cospendRoot] supporting
both /cospend (DE) and /expenses (EN). Add cospendI18n.ts with 100+
translation keys covering all pages, components, categories, frequency
descriptions, and error messages. Translate BarChart legend, ImageUpload,
UsersList, SplitMethodSelector, DebtBreakdown, EnhancedBalance, and
PaymentModal. Update LanguageSelector and hooks.server.ts for /expenses.
2026-04-08 15:46:01 +02:00
eadc391b1a feat: add Swiss German aliases for shopping list categorization
All checks were successful
CI / update (push) Successful in 3m35s
Add Rahm, Rüebli, Poulet, Cervelat, Crevetten, Weggli, Bürli, Zopf,
Glacé, Konfitüre, Nüsslisalat, Federkohl, Peperoni, and other Swiss
German terms to category items and alias map so they categorize correctly
instead of falling back to embedding similarity (e.g. Rahm→Schwamm).
2026-04-08 14:15:24 +02:00
20368131c5 fix: eliminate all 167 svelte-check warnings
All checks were successful
CI / update (push) Successful in 3m20s
Refactor page components to use $derived + invalidateAll() where data
is read-only or re-fetched after mutations. Suppress state_referenced_locally
for intentional patterns (form state, optimistic updates, pagination).
Fix a11y issues with role="presentation", add standard line-clamp properties,
remove unused CSS selectors and empty rulesets.
2026-04-08 14:06:15 +02:00
6a41d5fd3e fix: exclude today from nutrition stats to avoid incomplete data
All checks were successful
CI / update (push) Successful in 3m27s
2026-04-08 13:18:22 +02:00
3ce60c21de feat: inline custom meals, calorie ring overflow animation, theme fixes
Some checks failed
CI / update (push) Has been cancelled
Add custom meals tab to inline food add section with search/meals toggle.
Animate calorie ring overflow (red) after primary fill completes, with
separate glow elements so red overflow glows red independently. Apply same
delayed overflow animation to macro progress bars. Replace hardcoded nord8
with --color-primary throughout nutrition page (today badge, ring, tabs,
buttons). Add custom clear button to FoodSearch, hide number input spinners
globally.
2026-04-08 13:15:48 +02:00
0aa5b9c1c2 feat: add nutrition statistics to fitness stats page
All checks were successful
CI / update (push) Successful in 3m37s
Add protein g/kg (7-day avg using trend weight), calorie balance
(surplus/deficit vs goal), diet adherence (since first tracked day),
and macro split rings with target markers to the stats dashboard.
2026-04-08 12:39:03 +02:00
294d9e6c8d fix: use --color-text-on-primary for profile save button text 2026-04-08 11:38:01 +02:00
bfb582379a fix: close profile editor on save, reactively update period tracker
All checks were successful
CI / update (push) Successful in 3m49s
Profile editor closes on successful save. Period tracker visibility
uses a reactive savedSex state variable that updates on save, so
changing sex to female/male immediately shows/hides the tracker
without requiring a page refresh.
2026-04-08 11:32:50 +02:00
af5e67a385 fix: period tracker day count off by one due to timezone mismatch
ongoingDay compared today (local time with hours) against startDate
parsed as UTC midnight, causing Math.floor to undershoot by 1 day.
Use todayMidnight and parseLocal to normalize both to local midnight.
2026-04-08 11:28:02 +02:00
a854f5141a fix: barcode scanner WASM initialization with eager loading and error handling
All checks were successful
CI / update (push) Successful in 3m22s
Use fireImmediately: true to load WASM eagerly during init instead of
lazily on first detect() call, catching load errors immediately. Bail
out after 5 consecutive detection errors instead of looping forever.
Remove verbose debug messages, keeping only error output.
2026-04-08 11:11:56 +02:00
470d000125 fix: correct BLS 4.0 category mapping for food search results
All checks were successful
CI / update (push) Successful in 3m27s
The CATEGORY_MAP was based on BLS 3.x letter codes which were completely
reshuffled in version 4.0. This caused wrong categories like Schwarztee
showing "Wurstwaren" instead of "Getränke". Remapped all 20 letter codes
to match actual BLS 4.0 Hauptlebensmittelgruppen and regenerated blsDb.
2026-04-08 10:40:26 +02:00
3bd80e60e1 feat: add inline portion size editing and edit/delete action buttons on food cards
All checks were successful
CI / update (push) Successful in 3m40s
Pencil button toggles inline gram editor; second tap saves via PUT API.
Both edit and delete buttons appear on hover (bottom-right on desktop).
Removed separate checkmark save button in favor of toggling the pencil.
2026-04-08 10:33:29 +02:00
a74bd15a57 feat: show macro/calorie overflow with red reverse-fill indicators
When intake exceeds goals, macro bars show a red segment growing from
the right edge backwards and the calorie ring draws a red arc backwards
from the 100% mark, clearly visualizing the overrun amount.
2026-04-08 10:28:37 +02:00
cb35a5c3dc feat: add liquid tracking card to nutrition page with water cups and beverage detection
Track water intake via interactive SVG cups (fill/drain animations) using
BLS Trinkwasser entries for mineral tracking. Detect beverages from food log
(BLS N-codes + name patterns) and include in liquid totals. Configurable
daily goal stored in localStorage. Cups show beverage fills (amber) as
non-removable and water fills (blue) as adjustable.
2026-04-08 10:24:12 +02:00
a82430371d feat: store-based category sorting presets for shopping list
All checks were successful
CI / update (push) Successful in 3m48s
Add toggleable store presets (Coop Max-Bill Platz, Migros Seebach) that
reorder categories to match the physical store layout. Selection persisted
in localStorage.
2026-04-08 09:18:21 +02:00
565b35154f feat: group icons by category in edit modal, reorder categories, mobile padding
All checks were successful
CI / update (push) Successful in 3m43s
2026-04-08 09:13:54 +02:00
ca5a2f67c5 fix: reorder shopping list categories 2026-04-08 09:13:46 +02:00
ddb3f9e5cd feat: shareable shopping list links with token-based guest access
- Generate temporary share links (default 24h) that allow unauthenticated
  users to view and edit the shopping list
- Share token management modal: create, copy, delete, and adjust TTL
- Token auth bypasses hooks middleware for /cospend/list routes only
- Guest users see only the Liste nav item, other cospend tabs are hidden
- All list API endpoints accept ?token= query param as alternative auth
- MongoDB TTL index auto-expires tokens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:04:58 +02:00
52d278bcd8 feat: stronger checked-off effect, long-press edit modal, SyncIndicator icon
All checks were successful
CI / update (push) Successful in 3m47s
- Diagonal strikethrough line + lower opacity on checked cards
- Long press opens edit modal to manually assign category and icon (saved to DB)
- Replace floating status toasts with inline SyncIndicator (Cloud/CloudOff/RefreshCw)
- Move category count badge next to title instead of right-aligned
2026-04-08 08:21:42 +02:00
4fe828e228 fix: move shopping catalog.json to src/lib/data to fix import resolution
All checks were successful
CI / update (push) Successful in 3m23s
2026-04-08 05:47:23 +02:00
fc31c208ef feat: add colored category icons, quantity badges, and remove collapsing in shopping list
All checks were successful
CI / update (push) Successful in 54s
Add Lucide icons and Nord colors per category, parse quantities from item names
(e.g. "10L Milch" → badge "10L" + name "Milch"), and remove category collapse toggling.
2026-04-08 00:12:38 +02:00
738875e89f feat: add real-time collaborative shopping list at /cospend/list
All checks were successful
CI / update (push) Successful in 1m18s
Real-time shopping list with SSE sync between multiple clients, automatic
item categorization using embedding-based classification + Bring icon
matching, and card-based UI with category grouping.

- SSE broadcast for live sync (add/check/remove items across tabs)
- Hybrid categorizer: direct catalog lookup → category-scoped embedding
  search → per-category default icons, with DB caching
- 388 Bring catalog icons matched via multilingual-e5-base embeddings
- 170+ English→German icon aliases for reliable cross-language matching
- Move cospend dashboard to /cospend/dash, /cospend redirects to list
- Shopping icon on homepage links to /cospend/list
2026-04-07 23:50:54 +02:00
d9f2a27700 fix: restore backdrop-filter blur in production builds
All checks were successful
CI / update (push) Successful in 3m36s
Lightning CSS was deduplicating manually written backdrop-filter +
-webkit-backdrop-filter to just the webkit version, breaking blur on
Firefox. Remove manual webkit prefixes and let Lightning CSS auto-prefix
via browser targets in vite.config.ts.
2026-04-07 21:46:05 +02:00
48beb50466 feat: tap-to-preview stickers in gallery with glow effect
All checks were successful
CI / update (push) Successful in 3m33s
Add full rarity glow to gallery stickers matching the reward popup style.
Tapping an owned sticker opens a large preview card. Allow calendar
stickers to overdraw their cell on hover.
2026-04-07 20:38:06 +02:00
e43bc9b067 feat: add seasonal badge to Regina Caeli link during Eastertide
All checks were successful
CI / update (push) Successful in 3m29s
Shows an "In season" / "Zur Zeit" / "Tempore" pill on the Regina
Caeli card in the faith links grid when it replaces the Angelus.
2026-04-07 20:25:18 +02:00
b65a30591c feat: show exercise weights in template preview and quick start
All checks were successful
CI / update (push) Successful in 3m26s
Template detail modal now shows the initial weight per exercise.
Quick start hero shows the first exercise name and weight to help
with rack preparation before starting.
2026-04-07 20:19:32 +02:00
753180acf1 fix: period end date set to yesterday and show fertile range during ongoing period
All checks were successful
CI / update (push) Successful in 3m42s
Clicking "Period Ended" now records yesterday as the end date, since
you only know the period ended the day after. Also added the missing
fertile date range to the ongoing-period status view.
2026-04-07 20:14:21 +02:00
2dce83de55 security: enforce auth on all API write endpoints, remove mario-kart
Some checks failed
CI / update (push) Has been cancelled
- Remove all mario-kart routes and model (zero auth, unused)
- Add requireGroup() helper to auth middleware
- Recipe write APIs (add/edit/delete/img/*): require rezepte_users group
- Translate endpoint: require rezepte_users (was fully unauthenticated)
- Nutrition overwrites: require auth (was no-op)
- Nutrition generate-all: require rezepte_users (was no-op)
- Alt-text/color endpoints: require rezepte_users group
- Image delete/mv: add path traversal protection
- Period shared endpoint: normalize username for consistent lookup
2026-04-07 20:10:49 +02:00
8e4ba896e1 fix: remove keyed each on recipe tags to handle duplicate tag names
All checks were successful
CI / update (push) Successful in 3m44s
Duplicate tags (e.g. "Butter" at two indexes) caused a Svelte
each_key_duplicate error that broke rendering on /recipes.
Also use past tense for angelus streak button to match rosary.
2026-04-07 20:03:17 +02:00
a432e6ebd5 fix: use dynamic recipeLang in API calls instead of hardcoded /api/rezepte
All checks were successful
CI / update (push) Successful in 4m3s
Client-side navigation to /recipes hung because getUserFavorites and
other endpoints were hardcoded to /api/rezepte, causing fetch mismatches
during SvelteKit's client-side routing.
2026-04-07 19:50:35 +02:00
d21162c10c fix: move clear filter button from muscle diagram to filter pills area
All checks were successful
CI / update (push) Successful in 3m30s
2026-04-06 21:30:24 +02:00
dd526ead0f feat: play double-beep sound when rest timer completes
All checks were successful
CI / update (push) Successful in 3m35s
2026-04-06 21:23:46 +02:00
8364a4fb23 fix: extend Regina Caeli period through Saturday after Pentecost
All checks were successful
CI / update (push) Successful in 3m32s
The Regina Caeli replaces the Angelus from Easter Sunday through the
Saturday after Pentecost (Easter + 55 days), not just until Ascension.
2026-04-06 21:04:32 +02:00
593f211252 feat: ExerciseDB integration with muscle heatmap, SVG body filter, and enriched exercises
All checks were successful
CI / update (push) Successful in 3m31s
Integrate ExerciseDB v2 data layer (muscleMap.ts, exercisedb.ts) to enrich
the 77 static exercises with detailed muscle targeting, similar exercises,
and expand the catalog to 254 exercises. Add interactive SVG muscle body
diagrams for both the stats page heatmap and exercise list filtering, with
split front/back views flanking the exercise list on desktop. Replace body
part dropdown with unified muscle group multi-select with pill tags.
2026-04-06 20:57:49 +02:00
0874283146 fitness: add ExerciseDB v2 scrape data, media, and ID mapping
Scrape scripts for ExerciseDB v2 API (scrape-exercises.ts,
download-exercise-media.ts), raw data for 200 exercises with
images/videos, and a 1:1 mapping from ExerciseDB IDs to internal
kebab-case slugs (exercisedb-map.ts). 23 exercises matched to
existing internal IDs, 177 new slugs generated.
2026-04-06 15:46:29 +02:00
d0e123018a feat: redesign nutrition goal editor with wizard flow, macro ring, and TDEE comparison
All checks were successful
CI / update (push) Successful in 3m22s
Replaces flat goal editor with a 3-step wizard (preset → calories → macros),
adds preset cards with diet descriptions, live macro donut ring preview,
overlaid TDEE vs target comparison bar, TDEE missing-data warning with
explanation, and surfaces latest weight used for TDEE calculation.
2026-04-06 15:38:19 +02:00
3e8340fde1 feat: add period tracker with calendar, predictions, fertility tracking, and sharing
All checks were successful
CI / update (push) Successful in 3m18s
Full period tracking system for the fitness measure page:
- Period logging with start/end dates, edit/delete support
- EMA-based cycle and period length predictions (α=0.3, 12 future cycles)
- Calendar view with connected range strips, overflow days, today marker
- Fertility window, peak fertility, ovulation, and luteal phase visualization
- Period sharing between users with profile picture avatars
- Cycle/period stats with 95% CI below calendar
- Redesigned profile card as inline header metadata with Venus/Mars icons
- Collapsible weight and period history sections
- Full DE/EN i18n support
2026-04-06 15:12:03 +02:00
d10946774d feat: add recipe and OpenFoodFacts search to nutrition food search
Recipes from /rezepte now appear in the food search on /fitness/nutrition,
with per-100g nutrition computed server-side from ingredient mappings.
Recipe results are boosted above BLS/USDA/OFF in search ranking.

OpenFoodFacts products are now searchable by name/brand via MongoDB
text index, alongside the existing barcode lookup.

Recipe and OFF queries run in parallel with in-memory BLS/USDA scans.
2026-04-06 15:09:56 +02:00
201847400e perf: parallelize DB queries across routes, clean up fitness UI
Parallelize sequential DB queries in 11 API routes and page loaders
using Promise.all — measurements/latest, stats/overview, goal streak,
exercises, sessions, task stats, monthly expenses, icon page, offline-db.

Move calorie tracking out of /fitness/measure (now under /fitness/nutrition
only). Remove fade-in entrance animations from nutrition page.

Progressive streak computation: scan 3 months first, widen only if needed.

Bump versions to 1.1.1 / 0.2.1.
2026-04-06 13:12:29 +02:00
09cd410eaa perf: optimize DB connections, queries, and indexes
All checks were successful
CI / update (push) Successful in 3m28s
Fix dev-mode reconnect storm by persisting mongoose connection state on
globalThis instead of a module-level flag that resets on Vite HMR.

Eliminate redundant in_season DB query on /rezepte — derive seasonal
subset from all_brief client-side. Parallelize all page load fetches.

Replace N+1 settlement queries in balance route with single batch $in
query. Parallelize balance sum and recent splits aggregations.

Trim unused dateModified/dateCreated from recipe brief projections.

Add indexes: Payment(date, createdAt), PaymentSplit(username),
Recipe(short_name), Recipe(season).
2026-04-06 12:42:54 +02:00
b2e271c3ea feat: major dependency upgrades, remove Redis, fix mongoose 9 types
All checks were successful
CI / update (push) Successful in 4m10s
Dependencies upgraded:
- svelte 5.38→5.55, @sveltejs/kit 2.37→2.56, adapter-node 5.3→5.5
- mongoose 8→9, sharp 0.33→0.34, typescript 5→6
- lucide-svelte → @lucide/svelte 1.7 (Svelte 5 native package)
- vite 7→8 with rolldown (build time 33s→14s)
- Removed terser (esbuild/oxc default minifier is 20-100x faster)

Infrastructure:
- Removed Redis/ioredis cache layer — MongoDB handles caching natively
- Deleted src/lib/server/cache.ts and all cache.get/set/invalidate usage
- Removed redis-cli from deploy workflow, Redis env vars from .env.example

Mongoose 9 migration:
- Replaced deprecated `new: true` with `returnDocument: 'after'` (16 files)
- Fixed strict query filter types for ObjectId/paymentId fields
- Fixed season param type (string→number) in recipe API
- Removed unused @ts-expect-error in WorkoutSession model
2026-04-06 12:21:26 +02:00
fbf888ab31 fix: strengthen status bar drop shadow and clarify positioning
All checks were successful
CI / update (push) Successful in 4m57s
Increase shadow spread (4px offset, 10px blur, 0.4 opacity) for a
more visible boundary between Android status bar and page content.
2026-04-06 00:39:14 +02:00
c5710ff72d fix: timezone-safe streak continuity with 48h elapsed-time window
Some checks failed
CI / update (push) Has been cancelled
Use local dates instead of UTC for day boundaries, and store an epoch
timestamp alongside the date string. Streak alive check uses real
elapsed time (<48h) which covers dateline crossings. Old data without
timestamps falls back to date-string comparison so existing streaks
are preserved.
2026-04-06 00:36:10 +02:00
082202b71c docs: add versioning guidelines to CLAUDE.md
All checks were successful
CI / update (push) Successful in 4m46s
2026-04-06 00:21:16 +02:00
c86d6f487a feat: styled offline page with app install hint, bump versions
Replace bare offline fallback with styled page matching the app's
design (glass nav, dark/light mode, wifi-off icon, retry button).
Add hint to install Android APK or PWA for offline use.

Site: 1.0.0 → 1.1.0
Android/Tauri: 0.1.0 → 0.2.0
2026-04-06 00:21:03 +02:00
0d2c8f8190 fix: add status bar shadow and safe-area offset for Android
All checks were successful
CI / update (push) Successful in 4m54s
Add drop shadow under the safe-area-inset-top zone to visually
separate Android status icons from page content. Adjust StickyImage
sticky positioning and max-height to account for safe-area-inset.
2026-04-06 00:10:08 +02:00
c2510855c5 fix: resolve Svelte build warnings
- Add keyboard handler to fab-modal and dialog overlays (a11y)
- Remove unused .btn-cancel CSS selector
- Wrap meal name input inside its label, use span for ingredients heading
- Change image-wrap-desktop from div to figure for valid figcaption
2026-04-06 00:06:59 +02:00
43c8b3da2e fix: catechesis SVG fill color and enlarge DE badge
Remove fill="currentColor" from book SVG path so it inherits the
LinksGrid's nth-child fill colors. Increase DE badge size and offset.
2026-04-06 00:03:36 +02:00
c5d54acd0d feat: add Latin language notice and German link on catechesis pages
Show language-appropriate notice for non-German users with an
underlined link to the German version of the same page.
2026-04-06 00:01:57 +02:00
8f31cf94a8 merge: integrate catechesis branch into master
Resolve merge conflicts keeping master's Latin/Eastertide support
while adding catechesis nav item, book SVG, DE badge, and disclaimers.
2026-04-05 23:54:54 +02:00
7539d17d0a feat: improve catechesis page with expanded content, TOC, and i18n
- Add missing PDF content: Dt 4:12f, Mt 19:17, Sir 1:26 quotes in Ursprung/Warum sections
- Add äussere Seite section (Röm 12:1, 1 Kor 6:18-20, KKK 2702) and Gemeinschaftsgebet (Mt 18:20)
- Add pars potentialis concept to inner side section
- Add sticky section TOC nav for wide screens (1200px+)
- Align commandment highlight colors with tablet categories (God=orange, neighbor=blue)
- Use straight left borders instead of rounded on commandments
- Add German-only notice for English users on all catechesis pages
- Add disclaimer attributing errors to site author, not P. Ramm/FSSP
- Replace Inkscape katechese SVG with cleaner book icon on faith landing page
- Fix 10 commandments tablet SVG to show 5+5 lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 23:49:43 +02:00
6548ff5016 feat: add Latin route support, Angelus/Regina Caeli streak counter, and Eastertide liturgical adjustments
All checks were successful
CI / update (push) Successful in 4m58s
- Add /fides route with Latin-only mode for all faith pages (rosary, prayers, individual prayers)
- Add LA option to language selector for faith routes
- Add Angelus/Regina Caeli streak counter with 3x daily tracking (morning/noon/evening bitmask)
- Store streak data in localStorage (offline) and MongoDB (logged-in sync)
- Show Annunciation/Coronation paintings via StickyImage with artist captions
- Switch Angelus↔Regina Caeli in header and landing page based on Eastertide
- Fix Eastertide to end at Ascension (+39 days) instead of Pentecost
- Fix Lent Holy Saturday off-by-one with toMidnight() normalization
- Fix non-reactive typedLang in faith layout
- Fix header nav highlighting: exclude angelus/regina-caeli from prayers active state
2026-04-05 22:53:27 +02:00
c316cb533c fix: smoother barcode scanner with validation and confirmation
All checks were successful
CI / update (push) Successful in 4m50s
- Use createImageBitmap for off-thread frame capture so video stays smooth
- Require 2 consecutive identical reads before accepting a barcode
- Validate EAN/UPC check digit and reject codes with invalid length
- Only accept 8, 12, or 13 digit codes (EAN-8, UPC-A, EAN-13)
2026-04-05 21:13:23 +02:00
c7b652bba4 fix: prefer native BarcodeDetector, fall back to WASM ponyfill
All checks were successful
CI / update (push) Successful in 4m37s
Native BarcodeDetector works in Chrome/Android WebView over HTTPS.
Only load the ZXing WASM ponyfill when native API is unavailable or
doesn't support the needed formats.
2026-04-05 12:35:44 +02:00
3b0b1d08e4 fix: self-host ZXing WASM in static/ instead of ?url import
All checks were successful
CI / update (push) Successful in 4m32s
The zxing-wasm ?url import fails in Rollup production builds. Copy the
WASM binary to static/fitness/ and reference it via absolute path in
prepareZXingModule locateFile.
2026-04-05 12:28:12 +02:00
3daa5b65c5 fix: barcode scanner WASM loading and Android camera permission
All checks were successful
CI / update (push) Successful in 1m5s
- Exclude barcode-detector from Vite optimizeDeps to prevent WASM mangling
- Self-host ZXing WASM via Vite ?url import with prepareZXingModule
- Use barcode-detector/ponyfill instead of deprecated /pure export
- Separate barcode-detector/zxing-wasm into own chunk
- Add CAMERA permission to Android manifest for Tauri app
2026-04-05 12:23:18 +02:00
b7397898e3 feat: add barcode scanner with OpenFoodFacts integration
All checks were successful
CI / update (push) Successful in 5m26s
- Camera-based barcode scanning in FoodSearch using barcode-detector (ZXing WASM)
- Import script to load OFF MongoDB dump into lean openfoodfacts collection
  with kJ→kcal fallback and dedup handling
- Barcode lookup API with live OFF API fallback that caches results locally,
  progressively enhancing the local database
- Add 'off' source to food log, custom meal, and favorite ingredient models
- OpenFoodFact mongoose model for the openfoodfacts collection
2026-04-05 11:57:28 +02:00
c4420b73d2 feat: add nutrition/food logging to fitness section
All checks were successful
CI / update (push) Successful in 4m47s
Daily food log with calorie and macro tracking against configurable diet
goals (presets: WHO balanced, cut, bulk, keto, etc.). Includes USDA/BLS
food search with portion-based units, favorite ingredients, custom
reusable meals, per-food micronutrient detail pages, and recipe-to-log
integration via AddToFoodLogButton. Extends FitnessGoal with nutrition
targets and adds birth year to user profile for BMR calculation.
2026-04-04 14:34:47 +02:00
4a0cddf4b7 nutrition: detect recipe refs in ingredients, show in edit UI with multiplier
All checks were successful
CI / update (push) Successful in 5m35s
Skip embedding matching for anchor-tag ingredients that reference other
recipes. Instead, mark them with recipeRef/recipeRefMultiplier fields so
their nutrition is resolved via resolveReferencedNutrition with a
user-configurable fraction. The edit UI shows these as teal REF badges
with an editable "Anteil" input.
2026-04-04 09:43:49 +02:00
97969f8151 nutrition: extract shared ref resolution, fix HTML in ingredient names
All checks were successful
CI / update (push) Successful in 4m45s
- Move parseAnchorRecipeRef and resolveReferencedNutrition from the
  items endpoint into nutritionMatcher.ts for reuse
- JSON-LD endpoint now includes nutrition from referenced recipes
  (base recipe refs and anchor-tag ingredient refs)
- Strip HTML tags in normalizeIngredientName/De before matching to
  prevent regex crash on ingredients containing anchor tags
- Escape regex special chars in substringMatchScore word-boundary check
2026-04-03 11:15:55 +02:00
9b2325a0cb nutrition: include NutritionInformation in recipe JSON-LD
Compute macro/micro totals from stored nutrition mappings and emit a
schema.org NutritionInformation block in the JSON-LD output. Values are
per-serving when portions are defined, otherwise recipe totals.
2026-04-03 11:15:49 +02:00
d462a6ae1b fix: nutrition coverage double-counting excluded ingredients
Excluded (manually disregarded) ingredients were incrementing the total
count twice — once in the loop body and again in the exclusion check —
deflating the displayed coverage percentage.
2026-04-03 09:00:27 +02:00
88f3909634 chore: Svelte 5 syntax updates, a11y fixes, and dead CSS removal
All checks were successful
CI / update (push) Successful in 4m29s
Replace deprecated svelte:component with direct component invocation,
use span instead of label for non-input controls with role="group",
remove unused imports and dead CSS rules.
2026-04-03 08:44:36 +02:00
8a14230d00 nutrition: use SvelteKit read() for embedding files instead of fs
Some checks failed
CI / update (push) Has been cancelled
Replace fragile CWD-based readFileSync path resolution with SvelteKit's
read() + Vite ?url asset imports. This lets the build system manage the
embedding files as hashed immutable assets, fixing ENOENT errors in
production where the working directory didn't match expectations.
2026-04-03 08:43:12 +02:00
f386032716 fitness: use server-computed PRs on workout summary screen
All checks were successful
CI / update (push) Successful in 3m36s
The summary screen was comparing against only the last session
(limit=1), showing false PRs when you beat last time but not your
all-time best. Now uses the server-computed PRs and kcal from the
save response, which compare against the best from 50 sessions.
2026-04-03 08:31:45 +02:00
eda8502568 fitness: compute kcal server-side and store in session document
All checks were successful
CI / update (push) Successful in 3m43s
Previously kcal was computed on-the-fly in 3 places with inconsistent
inputs (hardcoded 80kg, missing GPS data, no demographics). Now a
shared computeSessionKcal() helper runs server-side using the best
available method (GPS + real demographics) and stores the result in
a new kcalEstimate field on WorkoutSession.

Kcal is recomputed on save, recalculate, GPX upload, and GPX delete.
The stats overview uses stored values with a legacy fallback for
sessions saved before this change.
2026-04-03 08:24:45 +02:00
cee20f6bb3 fitness: improve weight SMA with lookback and partial-window scaling
All checks were successful
CI / update (push) Successful in 3m54s
Fetch up to 6 extra measurements beyond the display limit so the SMA
window is fully populated from the first displayed point. For users
with fewer total measurements, use a reduced window with Bessel's
correction and sqrt(w/k) sigma scaling to reflect increased uncertainty.
2026-04-02 22:24:28 +02:00
eda87a8231 chore: remove dead migration and one-off scripts 2026-04-02 21:11:36 +02:00
433477e2c6 nutrition: fix embedding file paths for production and copy to dist
All checks were successful
CI / update (push) Successful in 4m42s
resolve() uses CWD which in production (adapter-node) is dist/, not the
project root. Detect the correct data directory at startup and add a
postbuild step to copy the embedding JSON files into dist/data/.
2026-04-02 21:08:54 +02:00
61336823b3 nutrition: pre-download HuggingFace models at build time
All checks were successful
CI / update (push) Successful in 4m26s
The deployment server couldn't fetch transformer models at runtime due to
restricted network access and permission errors writing to node_modules.
Add a prebuild script to download models during build and document
TRANSFORMERS_CACHE env var for configuring a shared writable cache path.
2026-04-02 20:47:10 +02:00
7935ac6b75 theming: migrate cospend to semantic CSS variables, extract SaveFab, refactor measure page
All checks were successful
CI / update (push) Successful in 4m21s
Replace hardcoded Nord colors with semantic CSS variables across all cospend
pages and shared components (FormSection, ImageUpload, SplitMethodSelector,
UsersList, PaymentModal, BarChart). Remove all dark mode override blocks.
Make BarChart font colors theme-reactive via isDark() + MutationObserver.

Extract reusable SaveFab component and use it on recipe edit and all cospend
edit/add pages. Remove Cancel buttons and back links in favor of browser
navigation. Replace raw checkboxes with Toggle component.

Move fitness measurement add/edit forms to separate routes with SaveFab.
Collapse profile section (sex/height) by default on the measure page.

Document theming rules in CLAUDE.md for future reference.
2026-04-02 20:38:33 +02:00
4f77f29a27 Merge branch 'recipes-calories'
All checks were successful
CI / update (push) Successful in 4m37s
recipes: nutrition calculator with BLS/USDA matching, manual overwrites, and skip

Dual-source nutrition system using BLS (German, primary) and USDA (English, fallback)
with ML embedding matching (multilingual-e5-small / all-MiniLM-L6-v2), hybrid
substring-first search, and position-aware scoring heuristics.

Includes per-recipe and global manual ingredient overwrites, ingredient skip/exclude,
referenced recipe nutrition (base refs + anchor tags), section-name dedup,
amino acid tracking, and reactive client-side calculator with NutritionSummary component.

recipes: overhaul nutrition editor UI and defer saves to form submission

- Nutrition mappings and global overwrites are now local-only until
  the recipe is saved, preventing premature DB writes on generate/edit
- Generate endpoint supports ?preview=true for non-persisting previews
- Show existing nutrition data immediately instead of requiring generate
- Replace raw checkboxes with Toggle component for global overwrites,
  initialized from existing NutritionOverwrite records
- Fix search dropdown readability (solid backgrounds, proper theming)
- Use fuzzy search (fzf-style) for manual nutrition ingredient lookup
- Swap ingredient display: German primary, English in brackets
- Allow editing g/u on manually mapped ingredients
- Make translation optional: separate save (FAB) and translate buttons
- "Vollständig neu übersetzen" now triggers actual full retranslation
- Show existing translation inline instead of behind a button
- Replace nord0 dark backgrounds with semantic theme variables
2026-04-02 19:47:12 +02:00
705a10bb3a recipes: overhaul nutrition editor UI and defer saves to form submission
- Nutrition mappings and global overwrites are now local-only until
  the recipe is saved, preventing premature DB writes on generate/edit
- Generate endpoint supports ?preview=true for non-persisting previews
- Show existing nutrition data immediately instead of requiring generate
- Replace raw checkboxes with Toggle component for global overwrites,
  initialized from existing NutritionOverwrite records
- Fix search dropdown readability (solid backgrounds, proper theming)
- Use fuzzy search (fzf-style) for manual nutrition ingredient lookup
- Swap ingredient display: German primary, English in brackets
- Allow editing g/u on manually mapped ingredients
- Make translation optional: separate save (FAB) and translate buttons
- "Vollständig neu übersetzen" now triggers actual full retranslation
- Show existing translation inline instead of behind a button
- Replace nord0 dark backgrounds with semantic theme variables
2026-04-02 19:46:03 +02:00
866d2e9fff tasks: use Toggle component for recurring task switch
All checks were successful
CI / update (push) Successful in 5m12s
2026-04-02 17:29:35 +02:00
381012db98 tasks: add refresh mode toggle (completion date vs planned date)
Recurring tasks can now calculate next due date from either the
completion time (default) or the planned due date, catching up
if overdue.
2026-04-02 17:29:08 +02:00
7ebebe5d11 tasks: complete tasks on behalf of another user via long-press
All checks were successful
CI / update (push) Successful in 2m22s
Long-press the check button to open a popover with user selection.
Normal click still completes for yourself.
2026-04-02 08:13:50 +02:00
4f17ad56fa tasks: add individual completion deletion API and UI
All checks were successful
CI / update (push) Successful in 2m23s
2026-04-02 07:47:58 +02:00
a588b8ee84 tasks: add clear completion history button on rewards page
Some checks failed
CI / update (push) Has been cancelled
2026-04-02 07:46:11 +02:00
21a1f9b976 tasks: remove debug toggle from rewards page
All checks were successful
CI / update (push) Successful in 3m39s
2026-04-02 07:34:02 +02:00
9027dd9881 tasks: shared task board with sticker rewards, difficulty levels, and calendar
Some checks failed
CI / update (push) Has been cancelled
Complete household task management system behind task_users auth group:
- Task CRUD with recurring schedules, assignees, tags, and optional difficulty
- Blobcat SVG sticker rewards on completion, rarity weighted by difficulty
- Sticker collection page with calendar view and progress tracking
- Redesigned cards with left accent urgency strip, assignee PFP, round check button
- Weekday-based due date labels for tasks within 7 days
- Tasks link added to homepage LinksGrid
2026-04-02 07:32:55 +02:00
7e1181461e recipes: nutrition calculator with BLS/USDA matching, manual overwrites, and skip
Dual-source nutrition system using BLS (German, primary) and USDA (English, fallback)
with ML embedding matching (multilingual-e5-small / all-MiniLM-L6-v2), hybrid
substring-first search, and position-aware scoring heuristics.

Includes per-recipe and global manual ingredient overwrites, ingredient skip/exclude,
referenced recipe nutrition (base refs + anchor tags), section-name dedup,
amino acid tracking, and reactive client-side calculator with NutritionSummary component.
2026-04-01 13:00:55 +02:00
3cafe8955a recipes: revert to substring search, keep fuzzy for prayers/exercises
All checks were successful
CI / update (push) Successful in 2m20s
2026-03-30 13:29:45 +02:00
29763ffaa9 fitness: GPS workout templates with interval pre-selection
All checks were successful
CI / update (push) Successful in 2m17s
Enable creating templates for GPS-tracked workouts with activity type
and optional interval training. GPS templates show activity/interval
info instead of exercise lists in cards, modals, and schedule. Starting
a GPS template pre-selects the interval and jumps to the map screen.
2026-03-30 13:24:58 +02:00
27a29b6f69 fitness: use time-scale x-axis for weight chart to handle date gaps
All checks were successful
CI / update (push) Successful in 2m23s
Weight chart now spaces data points proportionally to actual dates
instead of evenly. Days without a weight log no longer compress adjacent
points together. Uses Chart.js time scale with chartjs-adapter-date-fns.
2026-03-30 09:00:17 +02:00
5bed3f3781 fitness: TTS volume control, audio ducking, and workout start/finish announcements
All checks were successful
CI / update (push) Successful in 2m37s
Add TTS volume slider (default 80%) and audio duck toggle to voice
guidance settings. Announce "Workout started" when TTS initializes and
speak a full workout summary (time, distance, avg pace) on finish.
The finish summary reuses the existing TTS instance via handoff so it
plays fully without blocking the completion screen.
2026-03-30 08:49:14 +02:00
660fec44c2 fix: scrollbars are minimal and out of the way
All checks were successful
CI / update (push) Successful in 2m32s
2026-03-27 09:06:51 +01:00
5b3b2e5e80 fitness: shorter strings for main activity buttons, add missing pages
All checks were successful
CI / update (push) Successful in 2m12s
2026-03-26 15:04:42 +01:00
7f6dcd83a8 fitness: more nord blue as accent color for both light and dark
All checks were successful
CI / update (push) Successful in 2m14s
2026-03-26 14:57:31 +01:00
91582d6009 fitness: GPS Start Button nord blue in all themes
All checks were successful
CI / update (push) Successful in 2m26s
2026-03-26 14:29:41 +01:00
c41a916947 fintess: WIP: interval setup and TTS
All checks were successful
CI / update (push) Successful in 2m17s
2026-03-26 14:11:07 +01:00
3349187ebf feat: GPS workout UI polish and voice guidance improvements
All checks were successful
CI / update (push) Successful in 2m27s
- Start native GPS service in paused state during pre-start (notification
  shows "Waiting to start..." instead of running timer)
- Bump notification importance to IMPORTANCE_DEFAULT for lock screen
- Theme-aware glass blur overlay matching header style (dark/light mode)
- Dark Nord blue background for activity picker, audio stats panel
- Transparent overlay in pre-start, gradient fade for cancel button
- Use Toggle component for voice announcements checkbox
- Persist voice guidance settings to localStorage
- Derive voice language from page language, remove language selector
2026-03-26 10:45:42 +01:00
9e95179175 fix: auto-zoom to street level when first GPS point arrives
All checks were successful
CI / update (push) Successful in 2m19s
When the map starts zoomed out (level 5), snap to zoom 16 on the first
real GPS position instead of keeping the overview level.
2026-03-26 10:08:28 +01:00
c997e74806 feat: add debug mode to Android build script
Adds a `debug` command that temporarily enables cleartext traffic and
points frontendDist at the local dev server, then restores release
config on exit via trap.
2026-03-26 10:05:59 +01:00
c8dafa7c8a fix: live-update GPS position marker and distance during tracking
- Make map variables reactive ($state) so effects fire when map initializes
- Split single effect into polyline update + marker/view tracking
- Marker now always follows latestPoint instead of staying at start position
- Reset prevTrackLen on GPS restart to avoid skipping points
- Hide marker until real GPS position arrives
- Round saved distance to nearest 10m to avoid long floating-point values
2026-03-26 10:05:41 +01:00
5e520122b9 feat: add catechesis section with 10 commandments page
Add Katechese section to the faith area with teachings from
P. Martin Ramm FSSP's Glaubenskurs. Includes landing page,
full 10 Commandments overview with biblical text (Ex 20),
and detailed first commandment page covering the virtue of
religion, its four acts, and inner/outer dimensions.
2026-03-26 09:39:54 +01:00
8b63812734 feat: redesign GPS workout UI with Runkeeper-style map overlay
All checks were successful
CI / update (push) Successful in 2m32s
- Full-screen fixed map with controls overlaid at the bottom
- Activity type selector (running/walking/cycling/hiking) with proper
  exercise mapping for history display
- GPS starts immediately on entering workout screen for faster lock
- GPS track attached to cardio exercise (like GPX upload) so history
  shows distance, pace, splits, and map
- Add activityType field to workout state, session model, and sync
- Cancel button appears when workout is paused
- GPS Workout button only shown in Tauri app
2026-03-25 19:54:30 +01:00
d75e2354f6 feat: add TTS voice guidance during GPS-tracked workouts
Voice announcements run entirely in the Android foreground service
(works with screen locked). Configurable via web UI before starting
GPS: time-based or distance-based intervals, selectable metrics
(total time, distance, avg/split/current pace), language (en/de).

Also syncs workout pause/resume state to the native service — pausing
the workout timer now freezes the Android-side elapsed time, distance
accumulation, and TTS triggers.

Includes TTS engine detection with install prompt if none found, and
Android 11+ package visibility query for TTS service discovery.
2026-03-25 13:13:04 +01:00
a5f2a1d6de fix: resolve all 58 TypeScript errors across codebase
All checks were successful
CI / update (push) Successful in 2m10s
- Add SvelteKit PageLoad/LayoutLoad/Actions types to recipe route files
- Fix possibly-undefined access on recipe.images, translations.en
- Fix parseFloat on number types in cospend split validation
- Use discriminated union guards for IngredientItem/InstructionItem
- Fix cache invalidation Promise<number> vs Promise<void> mismatch
- Suppress Mongoose model() complex union type error in WorkoutSession
2026-03-25 07:58:13 +01:00
3b805861cf feat: add toast notification system, replace all alert() calls
Create shared toast store and Toast component mounted in root layout.
Wire toast.error() into all fitness API calls that previously failed
silently, and replace all alert() calls across recipes and cospend.
2026-03-25 07:40:52 +01:00
0263a18c5f fix: preserve GPS data when saving session edits
All checks were successful
CI / update (push) Successful in 2m18s
The PUT endpoint overwrote the exercises array with client data that
doesn't include gpsTrack/gpsPreview/totalDistance. Now merges existing
GPS data back into incoming exercises before saving.
2026-03-25 07:24:20 +01:00
1e0dd27fa3 fix: shorten dashboard labels to "Burned" and "Covered"
All checks were successful
CI / update (push) Successful in 2m22s
Values already include units (kcal, km), so verbose labels were redundant.
Remove unused distance_covered i18n key.
2026-03-25 07:18:57 +01:00
9caa0fbc24 fix: preserve GPS data when recalculating workout sessions
Replace .save() with $set updateOne so only computed fields (totalVolume,
totalDistance, prs, gpsPreview) are written. Previously the full document
re-serialization could strip gpsTrack arrays.
2026-03-25 07:18:52 +01:00
f3a89d2590 feat: add cardio PRs for longest distance and fastest pace by range
All checks were successful
CI / update (push) Successful in 2m14s
Track longestDistance and fastestPace PRs for cardio exercises with
activity-specific distance ranges: running (0-3, 3-7, 7-21.1, 21.1-42.2,
42.2+ km), swimming (0-0.4, 0.4-1.5, 1.5-5, 5-10, 10+ km), cycling
(0-15, 15-40, 40-100, 100-200, 200+ km), hiking (0-5, 5-15, 15-30,
30-50, 50+ km), rowing (0-2, 2-5, 5-10, 10-21.1, 21.1+ km).

Shared detection logic in cardioPrRanges.ts used by both session save
and recalculate endpoints. Display support in history detail and workout
completion summary.
2026-03-24 20:41:23 +01:00
81bb3a2428 fix: persist and display Volume PRs in workout history
All checks were successful
CI / update (push) Successful in 2m13s
Volume PRs were calculated client-side in the workout summary but never
saved to the database, so they didn't appear in history detail pages.
Add bestSetVolume PR detection to both session save and recalculate
endpoints, and render the new type in the history detail view.
2026-03-24 20:31:18 +01:00
f9f8761c7b remove Android CI workflow and Dockerfile
All checks were successful
CI / update (push) Successful in 2m18s
APK build and deploy is now handled by a local post-commit hook
using scripts/android-build-deploy.sh + rsync.
2026-03-24 20:25:17 +01:00
10233a3804 fix: track Gradle wrapper in git so Docker build finds gradlew
Some checks failed
CI / update (push) Successful in 3m22s
Android APK / build (push) Failing after 18m10s
The Gradle wrapper (gradlew, gradlew.bat, gradle/wrapper/) was
gitignored, causing the Docker APK build to fail with
"`gradlew` not found" since COPY doesn't include ignored files.
2026-03-24 19:06:08 +01:00
d1fbb1c826 fix: use rust:slim-trixie for JDK 21 and latest Rust, trim CI paths
Some checks failed
Android APK / build (push) Failing after 14m16s
CI / update (push) Successful in 2m23s
- Switch to Debian Trixie base for native JDK 21 and latest Rust
- Remove Adoptium APT repo workaround
- Only trigger Android CI on src-tauri/ and build config changes
2026-03-24 18:48:08 +01:00
e9de9a35c8 fix: use Adoptium APT repo for JDK 21 in Android Dockerfile
Some checks failed
Android APK / build (push) Failing after 8m26s
CI / update (push) Has been cancelled
Bookworm only ships JDK 17. Add Adoptium's official APT repository
to install Temurin 21 via package manager.
2026-03-24 18:37:48 +01:00
6685e5731c fix: stop GPS tracking on workout cancellation
Some checks failed
Android APK / build (push) Failing after 4m48s
CI / update (push) Has been cancelled
The cancel button didn't stop the GPS foreground service, leaving it
running after the workout was dismissed.
2026-03-24 18:30:58 +01:00
fe49c5b997 add Android app to README, CI workflow for APK builds
- README: add Fitness section with APK download link
- Dockerfile.android: containerized build with Rust, Android SDK/NDK,
  Java 21, Node 22, pnpm — builds and signs the APK
- CI workflow: builds APK in container on push, deploys to
  bocken.org/static/Bocken.apk via SCP
2026-03-24 18:29:38 +01:00
8fff5f14b5 android: rich GPS notification with pace, request POST_NOTIFICATIONS
- Notification title: "Bocken — Tracking GPS for active Workout"
- Live updates with elapsed time, distance, and pace (min/km)
- Request POST_NOTIFICATIONS permission at runtime (Android 13+)
- Page titles: "- Fitness" → "- Bocken" (missed in prior commit)
2026-03-24 18:29:38 +01:00
28b2494a08 rebrand app from Bocken Fitness to Bocken, track Android project
- Manifest: name/short_name → "Bocken", start_url → "/"
- Tauri: productName → "Bocken", identifier → org.bocken.app, url → "/"
- Cargo: package → bocken, lib → bocken_lib
- Page titles: "- Fitness" → "- Bocken" across all fitness routes
- Build script: auto-regenerate android project on identifier change
- Regenerate app icon from website favicon
- Track Android project source in git (ignore only build output/caches)
- Add native GPS foreground service and AndroidBridge for background
  location tracking (LocationForegroundService, AndroidBridge.kt)
- Add ACCESS_BACKGROUND_LOCATION permission for screen-off GPS
2026-03-24 18:29:38 +01:00
1686 changed files with 821214 additions and 8923 deletions

View File

@@ -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"

View File

@@ -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
View File

@@ -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
View File

@@ -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.

View File

@@ -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.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View 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);

View 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);

View 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);
});

View 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
View 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);

View 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);
});

View 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);

View 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);

View 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);

View 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);
});

View 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
View 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."

View File

@@ -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')}
```

View File

@@ -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()

View File

@@ -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;
while (true) {
const params = new URLSearchParams({ limit: '100' });
if (cursor) params.set('after', cursor);
case 'update':
updateExistingExercises()
.then(() => process.exit(0))
.catch(() => process.exit(1));
break;
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`);
case 'stats':
getExerciseStats()
.then(() => process.exit(0))
.catch(() => process.exit(1));
break;
if (!res.meta.hasNextPage) break;
cursor = res.meta.nextCursor;
await sleep(DELAY_MS);
}
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);
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);
});

View File

@@ -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!"

View File

@@ -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
View File

@@ -143,8 +143,8 @@ dependencies = [
]
[[package]]
name = "bocken-fitness"
version = "0.1.0"
name = "bocken"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",

View File

@@ -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]

View 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
View 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
View 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

View 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")

View 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

View 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>

View File

@@ -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()
}
}

View File

@@ -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.01.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)
}
}
}

View File

@@ -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")
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Bocken</string>
<string name="main_activity_title">Bocken</string>
</resources>

View 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>

View File

@@ -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>

View 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")
}

View 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")
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}
}
}
}

View 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

Binary file not shown.

View 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
View 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
View 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

View File

@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View 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"]}}

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,5 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
bocken_fitness_lib::run();
bocken_lib::run();
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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) {

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

View 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>

View File

@@ -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>

View 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>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,14 +66,14 @@
</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>
@@ -75,9 +81,9 @@
{#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,7 +95,7 @@
<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>
@@ -105,120 +111,61 @@
{/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;

View File

@@ -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>

View File

@@ -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);

View File

@@ -11,6 +11,7 @@
...restProps
} = $props();
// svelte-ignore state_referenced_locally
let shouldLoad = $state(eager);
/** @type {HTMLImageElement | null} */
let imgElement = $state(null);

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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,26 +122,26 @@
{#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>
@@ -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}

Some files were not shown because too many files have changed in this diff Show More