# Changelog All notable changes to LiftTrace are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- ## [1.3.2] - 2026-09-25 Security patch. It matters if more than one account uses your instance; a single-user instance was never exposed. ### Security - **Only the owner can change an exercise.** Any signed-in member could change a library exercise for everyone, or edit or delete another member's own exercises, through the edit and delete routes and through the phone's sync. Imported catalogs were also listed and deleted by name across every member, so deleting yours removed anyone else's with the same name. Everything the app itself offers is unchanged. - **Only an admin can import into or clear the shared exercise library.** Any member could, including clearing all of wger for everyone. Members keep their own on/off switch for each source and can still import a catalog of their own. --- ## [1.3.1] - 2026-09-20 ### Fixed - **Coaching: a program assigned to an athlete never reached their phone** (reported on r/selfhosted). Assigning a program writes the assignment and nothing else, so the program and its workouts kept whatever "last changed" time they had when you built them. A phone only downloads what changed since its last sync, so it received the assignment without the plan it pointed at: the athlete's Programs tab stayed empty, there was nothing to pick a session from, and the Diary's Active Program card never appeared. Workouts you prescribed still arrived, since each of those is new, which is why it looked like only prescriptions came through. It worked only if you happened to assign a program shortly after building it. An assignment now brings its program and that program's workouts with it, whatever their dates say, and a phone already stuck in this state repairs itself on the next sync instead of needing a reinstall. ## [1.3.0] - 2026-09-20 > **Action needed when you update: the container now listens on port 3002.** > If your compose file maps `"3002:3003"`, change it to `"3002:3002"`. If a reverse proxy > or tunnel reaches the container directly (`lifttrace:3003`, or a Traefik > `loadbalancer.server.port=3003` label), point it at `3002`. Until you do, LiftTrace > will not respond after the update. The host port stays 3002, so bookmarks and the > Android app's server address keep working. Installs that set `PORT` themselves are > not affected. Minor release. The headline is that a day is no longer one workout: multiple independent sessions per day, timed sets with a hold timer for planks and carries, and progress photos with before and after comparison. LiftTrace also opens up to outside tools for the first time, with an MCP server, a versioned REST API and outgoing webhooks, all off by default. Plus a rewritten weekly summary, a redrawn muscle recovery body map, and a long run of Android sync and Diary fixes. ### Added - **Multiple independent workout sessions per day** ([#76](https://github.com/TraceApps/lifttrace/issues/76), requested by @yoyo-san). Log a stretching routine and a lifting session on the same date, each with its own exercises, sets and completion state. A session tab strip appears once a second session exists, with a "+" to start one and a delete action on each tab. Loading a template or program over a started session now asks whether to replace it or start a new one alongside it, which also closes a data-loss bug: loading a second template on a day with a completed workout used to delete every exercise from the first one with no confirmation. Streaks and calendar activity still count a day as done if any session on it is complete. - **Timed sets for planks, holds and carries** ([#89](https://github.com/TraceApps/lifttrace/issues/89), requested by @chrisfeagles). An exercise can be tracked by Time instead of Reps, with weight still available for weighted holds. Typing a time works like a microwave: `45` is 0:45, `130` is 1:30. A hold timer inside the time field counts you in, times the hold and ticks the set off for you, reading large enough to see from the floor, keeping the screen awake and counting correctly if the phone locks. Personal records track the longest hold with their own celebration, Statistics and exercise history chart hold time, programs and templates can prescribe a time, Smart-Add understands `plank 3x45s`, CSV export gains a `duration_sec` column, and the Strong, Hevy, FitNotes and Garmin importers stop throwing hold durations away. Timed sets stay out of weight times reps volume and estimated 1RM, where they would only distort the numbers. - **Progress photos.** Attach dated photos to your body-stats history, on their own page at `/progress`, reached from Statistics and from the Body Stats sheet. The timeline groups by month and shows the weight logged that day; Compare stacks any two with a draggable wipe between them, with a "Compare First and Latest" shortcut; the scrubber opens a photo full frame with a date track underneath, or plays the whole record as a time-lapse. Captures default to today but the date is settable, so an old camera roll can be backfilled. iPhone HEIC photos are converted in the browser, since no browser displays HEIC. Photos sync across devices, are included in full backups, are deleted from disk when removed, and never expire. Unlike avatars and exercise media, photo files are not readable from `/uploads` by URL and are served only to the account that owns them. - **MCP server for external AI clients** ([#78](https://github.com/TraceApps/lifttrace/issues/78), requested by @bursaar). LiftTrace speaks the [Model Context Protocol](https://modelcontextprotocol.io) at `/api/mcp`, off by default (`MCP_ENABLED=1`). Read tools for workouts, records, progress, programs and body stats, additive write tools behind `MCP_WRITE_ENABLED=1`, and one destructive tool behind its own flag, scope and per-call confirmation. Writes go through the same server-side merge the app's own saves use, so a concurrent save can't be clobbered. New Settings, API Tokens section issues scoped personal access tokens, hashed at rest and shown once. See the [MCP setup guide](https://traceapps.github.io/docs/lifttrace/mcp/). - **Public REST API** ([#77](https://github.com/TraceApps/lifttrace/issues/77), requested by @bursaar). A versioned API at `/api/v1`, off by default (`PUBLIC_API_ENABLED=1`), for scripts that want plain JSON rather than MCP. Ten routes covering workouts, records, progress, programs, body stats and progress photos, with writes behind a second flag. It uses the same tokens and scopes as MCP, so one token works for both. See `docs/public-api.md`. - **Outgoing webhooks** ([#79](https://github.com/TraceApps/lifttrace/issues/79), requested by @bursaar). Point LiftTrace at a URL and it fires a signed POST when a workout is completed, a personal record is set, a program advances a week, a body stat is logged or a progress photo is added. For n8n, Home Assistant or anything else, without polling. Off by default (`WEBHOOKS_ENABLED=1`). Each delivery is signed with HMAC-SHA256 and retried up to three times, the secret is encrypted at rest and shown once, and target URLs are checked against the same protection used for the radio proxy, so private, loopback and cloud-metadata addresses are blocked and redirects are not followed. See `docs/webhooks.md`. - **Sync status in the sidebar.** On Android connected to a server, the sidebar shows Synced, Syncing, Offline or Can't reach the server. Colour means one thing everywhere now: amber when the phone simply has no network, so nothing is lost, and red when the server is reachable but the sync is failing. - **Exercise browser display density toggle** ([#74](https://github.com/TraceApps/lifttrace/issues/74), requested by @josefelixh). A compact or comfortable view switch next to the category filters at desktop widths. The exercise detail pane also stays put while you browse instead of scrolling away. - **Statistics: overlay a second body measurement on the Body Weight chart.** Pick any measurement tracked in the diary as a dashed second series with its own scale, weight on the left and the overlay on the right. The tab is now titled Body Measurements. - **Trace can see your cardio, and log it.** Cardio is stored separately from lifting, so Trace never saw it: it could tell someone training five days a week that they had done nothing since Tuesday, and would try to record a run as a lifting session. It now reads cardio sessions and logs new ones. It can also read your progress photo history, meaning dates, counts and the weight logged on them, never the images. ### Changed - **The container now listens on port 3002, the same as the host port. Action needed when you update.** The image used to listen on 3003 inside the container while the sample compose file published it on 3002, so the two numbers never matched. Both are 3002 now. If your compose file has `"3002:3003"`, change it to `"3002:3002"`; if a reverse proxy or tunnel reaches the container directly (`lifttrace:3003`, or a Traefik `loadbalancer.server.port=3003` label), point it at `3002`. Until you do, LiftTrace won't respond after the update. Installs that set `PORT` themselves are not affected, and the host port stays 3002, so bookmarks and the Android app's server address keep working. - **The weekly summary says how the week actually went** ([#98](https://github.com/TraceApps/lifttrace/issues/98), requested by @backmind). It used to be a workout count and a volume number with no unit. The email now shows sessions against your weekly goal, working sets, volume with its unit, time trained and personal records, each compared with your average week over the month before, the week's new records, and your sets by muscle group as simple bars that display in any mail client. A View This Week button opens Statistics on those seven days. The push notification gets the same in one line, such as "4 of 5 sessions, 62 sets, 3 PRs, 48,200 lbs volume (+8% vs your 4-week average)". - **Muscle recovery body map, redrawn.** The old figure had its hip 71% of the way down the body instead of 50%, so the legs were about half the length they should be, with shoulders and waist the same width. The figure now follows standard proportions with a real taper, muscle regions are anatomical shapes clipped to the silhouette, and the knee, calf and feet exist. - **Trace settings now match NutriTrace.** Smart Log gets its own switch, on by default, and a Voice Input Language setting for when you speak a different language than your phone is set to. Where AI is configured through environment variables, the section says so, the provider, model and base URL are locked, and the API key field is hidden since the server holds it. - **Claude Fable 5.1 in Trace's model list.** It is now the most capable Claude option; Fable 5 stays selectable, marked as previous. - **Statistics desktop rail headings and comparison cards now translate** ([#83](https://github.com/TraceApps/lifttrace/pull/83)), along with the API Tokens intro link ([#84](https://github.com/TraceApps/lifttrace/pull/84)) and the email settings environment banner, all of which stayed English in every translated locale. ### Fixed **Android and sync** - **Statistics showed zeros and "Failed to load stats"** ([#101](https://github.com/TraceApps/lifttrace/issues/101), reported and diagnosed by @kgenerozov). The phone's own copy of Statistics had no answer for the body map, so the whole page failed, and it had drifted from the server elsewhere: it ignored the date range, started weeks on Sunday, put workouts on the wrong weekday west of UTC, dropped the streak to 0 until you trained, and counted single-arm volume once instead of twice. The phone now matches the server, Statistics asks the server when connected, and a failure shows an error with Retry instead of zeros. - **A workout disappeared from the Diary when the connection dropped** ([#102](https://github.com/TraceApps/lifttrace/issues/102), reported and diagnosed by @kgenerozov). An offline save was queued correctly but the Diary was then cleared. The workout stays on screen, offline edits reach the server when you are back, edits to a session started offline stay on that session, deleting one before it syncs keeps it deleted, and queued saves to the same day stay in order. - **Editing a set could revert a moment later on a flaky connection.** The Diary reloaded from the on-device copy on every background sync, so a copy lagging behind an edit that had already saved visibly undid it. It now reloads only when a sync actually touched that workout, and never applies anything older than what is on screen. - **A stale session id could spawn a duplicate session on every set edit, and deleting a session didn't stick** ([#87](https://github.com/TraceApps/lifttrace/issues/87), diagnosed by @kgenerozov). Deleting a workout is now recorded rather than erased, so other devices learn about it instead of pushing the workout back. - **Sync failed on pull with an "ON CONFLICT" error after the multi-session update** ([#82](https://github.com/TraceApps/lifttrace/issues/82), diagnosed by @kgenerozov). A deletion on a day with more than one session could also be applied to the wrong session. - **Tapping a weight or reps field brought up the system text toolbar over the set row** ([#95](https://github.com/TraceApps/lifttrace/issues/95), reported and diagnosed by @kgenerozov). Nothing is selected now: the value dims when you tap and the first digit replaces it. Backspace, a second tap and the arrow keys still edit normally. - **Reminders ignored the times set in Settings, the streak reminder never fired, and the weekly summary arrived every day** ([#97](https://github.com/TraceApps/lifttrace/pull/97), found and fixed by @backmind). The on-device reminders read setting names the app never stores, so every one fell back to a default, and they were scheduled as repeating alarms that opening the app kept pushing a day out. They now read the real names and fire at the time of day you chose. - **Trace's Base URL and API Key could not be saved in portrait, and AI settings drifted from the web** ([#94](https://github.com/TraceApps/lifttrace/issues/94), reported by @kgenerozov). The Save buttons sat past the edge of the screen; both fields now save when you leave them. A setting changed while offline no longer stays marked as waiting to sync for good. - **The back button closes what's open first.** Back now closes an open sheet, dialog, menu, picker, the full-screen player, the Trace chat or the sidebar before leaving the page. - **Sheets no longer slide under the status bar**, including with the keyboard up. Body Stats, Gym Tools, the Diary date picker, the workout summary, Smart Log and the shared sheet all stay below it and scroll their content instead. - **Dragging the Trace button, the Diary's add button or the progress photo sliders no longer triggers pull-to-refresh.** - **An exercise's library load type was cleared on every sync**, resetting Per Side or Alternating back to unset on the device. **Diary and workouts** - **Auto-Fill Last Weights could skip your last session** ([#103](https://github.com/TraceApps/lifttrace/issues/103), reported and diagnosed by @kgenerozov). It looked at the newest workout an exercise appeared in even if nothing was ticked, so today's unfinished workout could send up template values. It now uses your last session with completed working sets, the same one the Last row shows, and warm-ups are no longer copied in as working sets. - **A workout couldn't be added back after Clear Workout** ([#99](https://github.com/TraceApps/lifttrace/issues/99), reported by @LeVraiRoiDHyrule). Exercises copied into a day now get ids of their own, so a template can be loaded as often as you like. Days already affected work again with no change to your data. - **Taking an exercise out of a superset made the whole superset disappear** ([#96](https://github.com/TraceApps/lifttrace/pull/96), found and fixed by @backmind). The exercise now moves below the block, so the members left behind stay together. - **Starting a new workout right after clearing or deleting one could revert to the old session** ([#86](https://github.com/TraceApps/lifttrace/issues/86), reported by @bauerbyter). - **Reordering exercises silently reverted.** Moving an exercise, or joining one into a superset, applied for a moment and then snapped back once the save reached the server. - **Weight and reps inputs inside a superset are readable again on a phone** ([#75](https://github.com/TraceApps/lifttrace/issues/75), diagnosed by @backmind). With RPE on, both could render at zero width, showing nothing while the value was saved correctly underneath. - **Editing a day in an older program duplicated every exercise on it, and deleting one didn't work** ([#85](https://github.com/TraceApps/lifttrace/issues/85), reported by @iparout). - **Delete Workout could leave the workout in place on a day with more than one session.** - **The bottom of the side columns was hidden behind the Radio player** ([#104](https://github.com/TraceApps/lifttrace/issues/104), reported by @LeVraiRoiDHyrule). On wider screens, rows like Delete Workout could sit behind the player or the rest timer. The columns now leave room for them. - **The Diary Body Stats widget and sheet showed "not logged" for records that exist on the server** ([#80](https://github.com/TraceApps/lifttrace/issues/80), diagnosed by @josefelixh). **Statistics, records and Trace** - **Deleted workouts still counted in Statistics and personal records.** A deleted workout was hidden from the Diary but still held records, added to volume and charts, counted as training for reminders and the weekly summary, and showed up in program progress and coach views. It is now left out of all of them, and re-importing a workout you deleted no longer skips it as a duplicate. - **Trace said lifts were missing from your log when they weren't** ([#92](https://github.com/TraceApps/lifttrace/issues/92), reported and diagnosed by @kgenerozov). The model invented date ranges in the wrong year, found nothing and reported the exercise as unlogged. Trace now checks its own ranges, knows today's real date, and refuses to write to a date more than 60 days out until you confirm it. - **Radio couldn't play from Jellyfin 12** ([#100](https://github.com/TraceApps/lifttrace/issues/100), reported by @LeVraiRoiDHyrule). Radio signs in the way current Jellyfin expects and falls back to the older way for earlier servers. The Test button signs in for real, and a saved sign-in that stops working renews itself. - **The weekly summary counted the wrong days.** It went out on the server's weekday rather than yours, its week covered eight days, and days you opened but never trained counted as workouts. - **Weekly charts put workouts in the wrong week on servers set to a time zone west of UTC.** - **Workout CSV exports had an empty exercise column.** - **The muscle recovery map looked empty if you had not trained recently**, drawing untrained muscles in almost the same colour as the body. - **A Settings category now opens at its top**, and going back returns you to where you were on the list. - **An API token's expiry showed "expires just now" for its entire lifetime**, then flipped to "expires 3d ago" once it lapsed. - **Deleting a workout over MCP, then logging to its date, misbehaved**, reporting success while the real workout stayed, or bringing the deleted one back with its old sets. ### Security - **Progress photos are served behind authentication**, unlike avatars and exercise media which remain readable by URL. - **Full-backup archives were downloadable without signing in.** The default backup directory sits inside the uploads path, which is served ahead of the auth check so images can load, so a backup ZIP there was fetchable by URL even though every backup route is admin-only. That directory is now excluded. Scheduled backups are off by default, so an install that never enabled them had nothing there to reach, and there is no directory listing, so a filename had to be known or guessed. The archive holds a full database dump, so if yours has been internet-facing with backups enabled, a look through your access log for `/uploads/backups/` will settle it either way. - **Uploaded files are stored and served more strictly.** An upload's extension now comes from what kind of file it actually is rather than its name, and everything under `/uploads` is served with `X-Content-Type-Options: nosniff` and a sandboxing content policy. - **Webhook targets are checked for private and cloud-metadata addresses** on every attempt, including retries, and redirects are not followed. - Dependency advisories cleared over the cycle: `multer`, `nodemailer`, `adm-zip`, `devalue`, `fast-uri`, `@xmldom/xmldom`, `qs` and `browserslist`. `npm audit` reports 0 vulnerabilities for both the app and the server, and every open Dependabot alert is already patched on this release. ## [1.2.0] - 2026-08-27 Minor release. Headline is the desktop wide-layout pass across the main routes (Diary, Statistics, Programs, Radio, Exercises, Settings, ExercisePicker), which is why we bumped from the 1.1.3 patch line to 1.2.0. Also lands Spanish + Italian translations, a per-entry uuid + tombstone merge for the workout log (no more last-writer-wins across devices), server forward-proxy support, X-Forwarded-Proto for reverse-proxied deployments, a wave of user-management data-integrity fixes, and a run of polish from community PRs. ### Added - **Desktop wide-layout pass across the main routes.** At `min-width: 1280px` (guarded against the "Force Mobile Layout" toggle), Diary, Statistics, Programs library + detail, Radio, and Exercises use a two- or three-column shell instead of the mobile stack; ExercisePicker opens as a wide modal on the same breakpoint; Settings gets a two-pane rail-and-content shell with a sliding highlight pill and a welcome-hero onboarding grid. Mobile layouts are unchanged. Verified against v1.1.2: none of these routes had any `min-width: 1024px`/`1280px` media queries. - **Diary desktop rail with pin / hide / overlay** (mirrors NutriTrace's pattern). A right-hand rail carries This Week peek, Recent Workouts, Body Stats (summary + Log CTA), Gym Tools (plates + converter), and Workout Actions inline. Rail can be pinned in the grid, hidden (grid reclaims the column and a chevron edge-tab hovers on the right), or opened as a fixed overlay from the edge-tab; state persists to localStorage. Portaled to `document.body` so `position: fixed` resolves against the viewport rather than the `.page-transition` transform trap. - **Spanish translation.** Complete `es` locale, exposed as `Español` in Settings → Language & Region. - **Italian translation.** Complete `it` locale, exposed as `Italiano` in Settings → Language & Region. - **Trace chat renders markdown properly** ([#52](https://github.com/TraceApps/lifttrace/pull/52), thanks @backmind). Lists, bold, tables, and code blocks in the coach's replies now render instead of showing raw asterisks and pipes. Assistant bubbles only; user messages stay plain text. Renderer loads lazily on first Trace open, so users who never open Trace pay no bundle cost. - **Per-entry uuid + tombstone merge for the workout log.** Two devices editing the same day (or one device coming back online) now merge per-exercise and per-set instead of one payload wholesale replacing the other. Deletions propagate reliably via `deleted_uuids` per kind (exercise/set) with server-side tombstones, so a delete on one device doesn't get resurrected by the next sync from another. Ported to standalone Android for schema parity. - **Forward-proxy support (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`).** The server now honors the standard proxy env vars via undici's `EnvHttpProxyAgent`, swapped into `globalThis.fetch` at startup so every outbound request — GitHub release checks, wger / ExerciseDB catalog imports, ntfy / gotify / apprise pushes, SMTP over HTTPS proxies, NutriTrace federation — routes through the configured forward proxy without per-call-site changes. Set the env vars in your compose file the standard way; `NO_PROXY` accepts the standard comma-separated bypass list. A missing scheme surfaces a friendly error at startup instead of a stack trace. ### Changed - **Settings → Units renamed to Settings → Language & Region** ([#62](https://github.com/TraceApps/lifttrace/pull/62), thanks @backmind). The section holds the language picker, date format, and time format alongside the weight unit — three of the four rows were regional settings, not units of measurement. Section header + icon (globe instead of ruler) updated across the collapsible view, the desktop rail, and the sub-page header. English, Spanish, and Italian labels updated in the same commit; settings search picks up `language` as a keyword so the section still comes up under its old name too. - **In-app updates got a much bigger surface** on top of the check that shipped in v1.1.2. A new prompt-based service-worker hook picks up new PWA builds without a hard reload; the check re-runs on tab-focus so a backgrounded session doesn't sit on a stale build; check cadence is user-configurable in Settings → Updates (Hourly / Every 4 hours / Every 12 hours / Daily / Manual); and a red dot appears on the Settings nav icon while an update is pending so a background check surfaces without being noisy. On Android the existing GitHub-release watcher stays in place. - **Radio, NutriTrace Federation, Diagnostics, About, Workout, and Users settings sections now flow through i18n** ([#66](https://github.com/TraceApps/lifttrace/pull/66), [#67](https://github.com/TraceApps/lifttrace/pull/67), thanks @backmind). 38 strings that lived as English literals in those six components — section hints, the About description and disclaimer, the rest-timer tone names and descriptions, a handful of labels built in JavaScript — now resolve through the i18n layer, so Weblate can pick them up. A handful of keys that already existed but were never wired now render their existing translations with no round-trip. - **Profile section title case swept for buttons and headings.** `Danger zone` → `Danger Zone`; `Delete my account` → `Delete My Account`; `Delete your account?` → `Delete Your Account?`; `Delete account` → `Delete Account`. Matches the app's canonical button + heading casing convention (the settings-backup Danger Zone was already correct). ### Fixed - **Enabling user management no longer strands data written in single-user mode** ([TraceApps/docs#2](https://github.com/TraceApps/docs/issues/2)). v1.1.2's first-register handler only reparented workout_log, body_stats_log, programs, user_settings, and ai_chat_history to the new admin; the cardio log and any custom exercises stayed with the placeholder owner and became invisible. All of them are now claimed, in one transaction. The same handover runs whether the first account is created with a password or by the first OIDC sign-in; both paths share one implementation instead of keeping separate copies that drifted. - **Data left behind in single-user mode is adopted on upgrade.** Instances that already enabled user management on an earlier build had their unowned rows stranded for good. Startup now adopts them, once, when exactly one account exists. Zero accounts is ordinary single-user mode and is left alone; two or more is reported in the log rather than guessed at. - **Disabling user management no longer makes the workout log disappear.** No LiftTrace table has a foreign key to `users`, so dropping the accounts left every row pointing at an id that single-user mode cannot read, and because account ids never get reused a later re-enable could not reclaim them either. The data was neither deleted nor reachable. With one account its rows are handed back to single-user mode, making disable and re-enable a lossless round trip; with several, where no single owner exists, they are removed instead of being stranded. Same for the `RECOVERY_TOKEN` lockout path. - **Deleting an account now removes its cardio log rows.** The v1.1.2 delete-user handler cleared each table by hand and the cardio log was never added to that list, so cardio sessions outlived the account. The shared global exercise catalog is explicitly excluded so it stays available to every user instead of being taken over by whoever registers first. The OIDC first-login bootstrap had the same incomplete list; both paths share one implementation. - **Exercise catalog clear + re-import no longer silently unlinks workout history** ([#49](https://github.com/TraceApps/lifttrace/issues/49), diagnosed by @backmind). v1.1.2's clear paths did a hard `DELETE`, so the re-import minted new autoincrement ids while every `exercise_id` stored in `workout_log` / `workout_templates` / `coach_prescriptions` JSON blobs still pointed at the deleted rows. Muscle Balance then bucketed every affected set as "other", per-exercise Progress returned empty, and Records rows landed on "Exercise not found". Clearing now soft-deletes via the existing `deleted_at` column; the next matching re-import resurrects the row in place instead of minting a new one, preserving the id so historical references stay valid. Ported the same treatment to standalone Android for schema parity. - **Password-reset links, invite links, and test-email "From" origin now honor `X-Forwarded-Proto`** when the server sits behind a TLS-terminating reverse proxy (Traefik, Caddy, nginx, Cloudflare). Without this, the link builder read `req.protocol` as `http` even though the user hit the app over `https`, so emailed links landed on the wrong scheme and either 400'd or downgraded the browser session. Requires `trust proxy` to be configured on your proxy hop (already documented in the compose example). Ported from CookTrace PR #42, thanks @clifmo. - **Android release builds trust user-installed CA certificates** ([#58](https://github.com/TraceApps/lifttrace/issues/58), reported by @orpetor). Self-hosters serving LiftTrace over HTTPS with a private-CA cert (Bitwarden-style internal PKI, homelab step-ca, etc.) were hitting `Trust anchor for certification path not found` on the Android release build because v1.1.2's network-security config only trusted the system CA store. Added `` alongside the system entry so any CA the user installed on the device is honored. - **Clearing the Trace chat now asks for confirmation** ([#50](https://github.com/TraceApps/lifttrace/pull/50), thanks @backmind). v1.1.2's clearHistory tap wiped the entire conversation with no confirmation. Also fixes a z-index bug where the confirm dialog opened behind the Trace panel. - **Statistics summary cards and plate calculator no longer show units in capitals** ([#48](https://github.com/TraceApps/lifttrace/pull/48), thanks @backmind). Reads `MAX kg` / `TOTAL min` / `TARGET WEIGHT (kg)` instead of `MAX KG` / `TOTAL MIN` / `TARGET WEIGHT (KG)`. `kg` is kilograms; `KG` is not a unit. - **Settings → Help & Improve no longer shows mojibake where a dash belonged** ([#60](https://github.com/TraceApps/lifttrace/pull/60), thanks @backmind). Two visible strings in the diagnostics panel carried a CP1252-round-tripped em-dash; restored to U+2014. - **Version no longer renders as `vv1.2.0`** ([#61](https://github.com/TraceApps/lifttrace/pull/61), thanks @backmind). `APP_VERSION` already carries the leading `v`, but Settings → About and the desktop sidebar were prefixing a second one. ### Security - No new dependencies affect the security surface. `npm audit` (both root and server) reports 0 vulnerabilities and there are no open Dependabot alerts. --- ## v1.1.2 — 2026-08-11 ### Added - **Latest AI models in the picker.** Claude Opus 5 and Claude Fable 5 join the existing Claude Opus 4.8; Sonnet 5 relabeled. Google Gemini 3.x and OpenAI GPT-5.6 added as presets. Existing saved selections keep working. ### Fixed - **The Login and Profile pages no longer render raw i18n keys as UI copy** (#33). `en.json` declared the `login` and `profile` top-level sections twice; `JSON.parse` silently keeps only the last (biometric-only) block, dropping 21 login keys (`username`, `password`, `sign_in`, `forgot_password`, `recovery.*`) and 34 profile keys at runtime. Both sections now hold every key in one block. Also renamed two Settings sidebar `titleKey`s that pointed at non-existent keys and were rendering raw: AI Assistant now resolves via `settings.trace.section` (was `settings.ai.section`) and Backup & Restore via `settings.backup.section` (was `settings.data.section`). Diagnosed by @backmind. - **Re-importing an exercise catalog no longer duplicates every row** (#34). Every seeder used `INSERT OR IGNORE` against a table with no UNIQUE constraint, so importing free-db (873 rows), Wger, or ExerciseDB a second time silently doubled the whole library. A one-time boot migration merges duplicates: picks the lowest-id row as survivor, folds non-null user edits from duplicates onto it (`load_type`, `tips`, `video_url`, `img_url`, `gif_url`, `category`, `instructions`), rewrites `exercise_id` references inside the JSON blobs in `workout_log` / `workout_templates` / `coach_prescriptions` so past workouts + templates + coach prescriptions keep pointing at the right exercise, then deletes the duplicates. Free-db now populates `external_id` from the upstream stable id (was `null`, which made even the natural dedup key useless since SQLite treats each NULL as distinct). A partial UNIQUE index (`is_global = 1 AND external_id IS NOT NULL`) locks in the fix. All four seeders gain a pre-check + skip-count so a re-import reads as a no-op in the log. Full-backup restore force-runs the dedupe pass unconditionally so a backup taken during the bug can't resurrect duplicates. Standalone Android was never affected but gets the same partial index for schema parity. Diagnosed by @backmind. - **The All range on Statistics no longer hides body measurements taken before your first workout** (#36). The body-weight chart bounded its query by the earliest `workout_log` date, which is the right bound for volume, frequency, progress and records but not for weigh-ins: those are independent of whether you trained that day. Anyone who imported a weight history from another app, or weighed in during a break from training, silently lost those points with no way to see them from the UI. The All range now starts from the same `2000-01-01` floor the code already falls back to when there are no workouts. - **Logging a cardio session with a distance or a heart rate no longer fails** (#41). Both fields are ``, so their bound value is a number once you type in them and `null` once you clear them, but the save path called `.trim()` on them as if they were still the empty string the form starts with. Typing a distance raised `f_distance.trim is not a function`, and clearing it afterwards raised a null-property error that left the form unable to save until it was cancelled and reopened. Both fields now test for emptiness rather than assuming a string. - **Quick-log cardio templates now show up, and pinning two sessions of the same activity no longer gives you two identical chips** (#43). `GET /api/cardio/templates` was registered below `GET /api/cardio/:date`, and Express matches in registration order, so the request was answered by the date handler with `templates` read as a date: an empty list, every time. Pinning a session persisted and reported success, but the chip row only renders when that list has entries, so the feature had never worked since it was added. The endpoint also promised one entry per activity name, most-recently-updated winning, and returned every pinned row instead. - **Pre-caching exercise media for offline use no longer fails with "Exercise not found"** (#44). `GET /api/exercises/media-urls` was registered below `GET /api/exercises/:id`, and Express matches in registration order, so the request reached the id handler, which read `media-urls` as an id, found nothing and replied `404 Exercise not found`. The route now sits above its parameterised sibling. - **Confirmation dialogs with two long button labels no longer overflow the dialog card** (#39). `.btn` carries `white-space: nowrap`, so a flex item can never shrink below its one-line text width. When both labels are long, as in the workout importer's `Skip duplicates` / `Replace existing` pair, the two grew past the 340px card and the left button rendered outside it. The action row now wraps. - **The body stats sheet no longer shows its units in capitals** (#42). Field labels are uppercased by CSS, which took the unit along with the name and produced `WEIGHT (KG)`, `WAIST (CM)` and `WAIST (IN)`. Unit symbols are case-sensitive: `kg` is kilograms while `KG` is not a unit, and the same holds for `cm`, `in` and `lbs`. The symbol now opts out of the transform, so the labels read `WEIGHT (kg)` and `WAIST (cm)`. - **Weight placeholders in the workout editor and the plate calculator now follow the kg/lb setting from Settings → Units.** Both inputs hard-coded pound examples in their placeholder text regardless of that preference: the workout editor's weight target field always showed `e.g. 135`, and the plate calculator's target weight always showed `e.g. 225`. Switching to kg now shows `e.g. 60` and `e.g. 100` instead; only the example text changes, not the input itself. - **The rest field in the workout editor now reads `REST (s)` instead of `REST (S)`.** Field labels are uppercased by CSS, which also uppercased the unit: `S` is the symbol for siemens, while seconds is a lowercase `s`. The unit now sits in its own span that opts out of the transform. - **Pull-to-refresh no longer fires mid-page on editor screens.** The gesture walked from the touch target to the document scroller and gated on `window.scrollY`, but editor pages use their own `position: fixed + overflow-y: auto` scroll container, so a mid-page swipe still counted as top-of-scroll and triggered a sync. It now walks up to the nearest actually-scrolling ancestor and gates on that container's `scrollTop`. --- ## v1.1.1 — 2026-08-04 ### Changed - **Docker images now mirror to Docker Hub alongside GHCR.** Pull from `traceapps/lifttrace:1.1.1` on Docker Hub (`hub.docker.com/r/traceapps/lifttrace`) or `ghcr.io/traceapps/lifttrace:1.1.1` on GHCR (the existing primary). Both registries are kept in lockstep. Publish list also trimmed to just `:latest` and `:dev` (dropped noisy `:main` and per-commit `:sha-*` tags — semver-tagged pulls like `:1.1.1` / `:1.1` / `:1` are unaffected). ### Fixed - **Push notification titles with non-ASCII characters no longer arrive mangled.** The ntfy Title header carries HTTP header bytes only, so a workout name with an em-dash, accented character, or emoji (e.g. "Push — Chest Day") was being dropped or corrupted by the receiving client. Titles are now RFC 2047-encoded (`=?UTF-8?B?…?=`) when they contain any non-ASCII byte, so the notification shade renders them correctly on every ntfy client. ### Security - **`fast-uri` bumped to 3.1.5** (CVE-2026-18446, GHSA-7p8r-x3mc-p8w7, high — host confusion via backslash authority introducer). Transitive via `vite-plugin-pwa → workbox-build → ajv`; dev-only build-time dep but flagged by Dependabot. - **`brace-expansion` bumped to 5.0.9** (GHSA-rgw5-rvv9-x895, high — DoS via unbounded intermediate arrays, bypassing the earlier CVE-2026-14257 mitigation). Prior pin at ^5.0.7 was still affected. --- ## v1.1.0 — 2026-08-03 ### Added - **Cardio session logging** (#23). Opt-in via Settings → Workout → **Track Cardio** (off by default so pure lifters aren't cluttered). When enabled, a Cardio card appears on the Diary for logging a session with activity, duration, optional distance / avg HR / notes; a Cardio metric appears on Statistics with weekly minutes total and an optional weekly target line. All cardio is excluded from volume, PRs, and rest-timer firing so it never corrupts the lifting side. Manual entry only by design; device sync (Fitbit, Garmin, Health Connect, etc.) lives in NutriTrace via federation and is not planned for LiftTrace. - **Pin cardio sessions as quick-log templates.** Tap the pin icon on any cardio session to save it as a one-tap template; pinned templates show up as chips at the top of the Cardio card so the next "same-as-yesterday" run / bike / row is a single tap instead of re-filling the form. Templates carry activity, duration, distance, HR, and notes forward. Tap the pin again to unpin. Matches NutriTrace's Activities quick-log flow. - **Library-level `load_type` on exercises** (#24). The Exercise Editor gains a **Load type** field (Unset / Bilateral / Per side / Alternating) so the setting travels with the exercise instead of being hidden per-workout-instance. Batch-imported catalogs (Strong / Hevy / FitNotes / Jefit CSV) can now be fixed up per-exercise once instead of having to re-select per session. Statistics, muscle-group volume, per-exercise progress, share cards, and CSV export all resolve through the new four-tier chain: per-instance override → library value → per-user Diary preference → 'bilateral'. **Historical volume numbers for imported unilateral / alternating exercises will change on first render** (that's the fix; previous numbers were wrong for lack of load_type), but Statistics and share cards will show different totals than before for the same historical sessions. - **In-app updates.** New Settings → Updates panel checks GitHub Releases for a newer version and, on Android, downloads the signed APK and hands off to the system installer via FileProvider. One primary button drives the whole flow (Check Now → Download & Install → Downloading X%). Skip This Version link when an update's available. Collapsible "What's new" panel below the button renders the release notes inline (markdown) with a "View on GitHub" link. Silent shade notification when the OS notification permission is granted; top-of-app banner as fallback when permission's denied. Opt-in Stable or Dev channels. Same shared TraceApps signing key means Android upgrades in place with no reinstall. - **Pull-to-refresh sync (Android).** In native server mode, swipe down from the top of any page to trigger a manual sync. Matches NutriTrace's behavior for family consistency. - **Smart connection banner.** When sync fails, the banner explains what actually went wrong (no network vs cellular-only vs server unreachable vs HTTP error) with a Retry button, instead of a generic "sync error". Structured classification via `describeConnectionIssue` mirrors NT. - **Cloud icon in hamburger menu goes red on server disconnect.** Previously never lit up (`syncState.online` was initialized `true` and never written); now driven by a real reachability probe + browser online/offline events + server-side classifier, matching NT's behavior. - **Optional email on the "Create Admin Account" form.** Shows up only when SMTP is configured via environment variables (`SMTP_HOST`/`SMTP_USER`/etc. in docker-compose), so we know the server can actually reach that address at that point. Stored on the admin's user record for password-reset and invite emails later. - **Accent-tinted browser chrome.** The browser tab bar / address strip now picks up your current accent color via ``. Running LiftTrace alongside NutriTrace / CookTrace? Pick a distinct accent per install and the tabs read as visually different at a glance. Favicon stays the branded LiftTrace mark. ### Changed - **Full i18n retrofit across the app.** Every hardcoded UI string has been extracted into `src/i18n/en.json` and reads via `svelte-i18n`. Covers Settings (Notifications, Workout, Appearance, Authentication, User Management, Trace, Backup, Email, Radio, Catalog, Statistics, Units, Federation, Diagnostics, About), Diary + WorkoutEditor + WorkoutSummary + SupersetCard + ExerciseCard + SmartLog, Coaching + Programs + ProgramDetail + TemplateSpecRow, Exercises + ExerciseDetail + ExerciseEditor + ExerciseInfo, Statistics charts + MuscleRecovery, Radio (station library + dialogs), Profile, Login, NativeSetup, Trace AI, and shared UI (TimePicker). ~500 new keys added, Weblate-ready. Chicago-style title case for labels/buttons/headings, sentence case for body prose / errors / placeholders / toasts. - **Bitwarden / password managers now show a real app identifier instead of "localhost" (Android).** The Android app used to serve its WebView from `https://localhost/`, so autofill entries saved through Bitwarden / 1Password / etc. showed up as "localhost" (indistinguishable from any other localhost app). LiftTrace now identifies itself as `app.lifttrace.local`, which reads clearly in autofill dialogs and in your saved-credentials list. **One-time upgrade cost:** the origin change orphans locally cached web-only state, so on first launch after upgrading you'll need to re-enter your server URL + log in again (server-connected users), and your theme / accent / display prefs will reset to defaults (standalone users). **Your workout, program, and exercise data is unaffected** (that lives in a local SQLite database that's separate from the WebView). - **SMTP "Username" field relabeled to "Email or Username".** Most SMTP providers want the full email as the username; label change removes the guesswork. ### Fixed - **Server-connection banner no longer covers the phone's notification bar.** The red "server unreachable" banner used to sit edge-to-edge at viewport top:0, which on Android meant it slid up over the status bar / clock / hamburger. Now floats as a rounded card below the status bar and the app's compact header, matching NutriTrace. - **Exercise picker sheet on Statistics rendered off-screen for some browsers** (#25). The Statistics → Exercise Progress picker had its own inline bottom-sheet CSS that had drifted from the shared Sheet component the rest of the app uses; on Vivaldi's persistent-web-app mode the divergent viewport-height + safe-area math pushed the sheet body below the visible area, especially when the search filter returned few results. Consolidated onto the shared Sheet so the picker inherits the same viewport treatment every other sheet gets. - **App icon no longer shows a white halo.** The bundled icon PNGs had ~15px of solid white padding baked into their corners. On tinted browser chrome the halo was visible around the tab favicon; in-app the icon looked framed. Corners now clear cleanly. Icon URLs also cache-busted with the app version so shipped icon fixes actually take effect without users needing to clear their browser cache. - **Create Admin form layout aligned with NT/CT** (parity with NT #122). Two-column layout (Username / Full Name on top, Password / Confirm below with matching eye toggles that share show/hide state) replaces the prior stacked single-column layout. ### Security - **`adm-zip` bumped to 0.6.0** (GHSA-xcpc-8h2w-3j85). - **`brace-expansion` bumped to 5.0.7** (CVE-2026-13149, high). - **`body-parser` bumped** (CVE-2026-12590). - **`fast-uri` bumped** (transitive CVE fix). ## v1.0.2 — 2026-07-28 ### Added - **Trace AI tool use.** Trace now calls 18 typed tools (11 read + 7 write) instead of relying on a pre-stuffed context payload. Assistant can read workouts, exercises, programs, PRs, body stats, coach prescriptions, and take actions like log a workout, add an exercise to today's diary, log a body stat, start a workout from a template, switch active program, or (as a coach) prescribe a workout to a trainee. System prompt trimmed to a small stable core; the model fetches on demand. Provider parity across Claude, OpenAI, Gemini, and any OpenAI-compatible endpoint. - **Muscle Balance body-map on Statistics.** The Volume metric's "Volume by Muscle Group" horizontal-bar list is replaced with a shaded body-map view: front + back silhouettes with each of 18 muscles coloured 0–4 relative to the hardest-worked muscle in the current range. Below it, a **Not Trained in This Period** chip row spells out exactly which muscles the current window skipped. Counts effective sets (primary muscles = 1.0, secondary = 0.4) not weight lifted, since 100 kg of leg press vs 12 kg of lateral raise says nothing meaningful about which muscle worked harder. SVG geometry is fetched lazily on first render so nothing else in the bundle grows. - **Public exercise catalogs import in standalone Android** (#18). wger, Free Exercise DB, and ExerciseDB (open-source) all show up as Import cards in Settings → Exercise Catalog when the Android app runs in local-only mode. Tapping Import fetches the source directly via CapacitorHttp (no server needed, no CORS constraint) and writes to the on-device SQLite mirror. That's roughly 3,000 exercises with images and GIFs available offline on day one. Previously the section returned 501 in standalone; anyone who imported public catalogs before switching to a server keeps them locally as a dormant cache, and the server's catalog takes over in server mode. The paid ExerciseDB (RapidAPI) card still points at server mode for now. ### Changed - **AI proxy rate limit raised to 30 requests / 60 seconds per user** (was lower) to accommodate multi-round tool-use loops. - **AI proxy payload caps raised to 60 messages / 8 MB** (was 60 / 200 KB) to fit tool-result echoes. - **Gemini default bumped to `gemini-2.5-flash`.** Saved selections of retired `gemini-1.5-*` or `gemini-2.0-*` models are quietly remapped at request time so calls don't 404 after Google's retirement dates. ### Fixed - **OpenAI-compatible endpoints accept vision requests again.** Image content blocks are normalised on both the server proxy and `callAIProxy` client wrapper before forwarding, so a request with an image attached goes through whether the block is a string URL or an object with `image_url.url`. - **GPT-5.6-era chat parameters supported.** The AI proxy translates the newer `max_completion_tokens` and `reasoning_effort` fields when talking to models that require them, so calls to GPT-5.6 and equivalents don't 400 on the older `max_tokens` field name. - **Full-backup restore no longer silently drops coach data.** The `coach_feedback` and `coach_activity` tables were included in exports but missing from the restore INSERT column lists, so any coach comments or activity history vanished after a restore-from-backup. Both tables now round-trip cleanly. ## v1.0.1, 2026-07-25 ### Added - **Multi-Week Progression Plans** (#13). Programs can now span a training block of multiple weeks, with a per-week Sets / Reps / Tempo / Rest / Load matrix instead of one flat prescription. Set a program's **Duration (weeks)** and the Workout editor gains a Week tab strip (with a "copy this week → next" shortcut) plus new **Tempo** and **Rest (s)** fields. The Diary resolves which week you're on, by default advancing as you log sessions (calendar mode optional), and prefills that week's targets when you load the workout; inside a programmed block the plan's prescription wins over last-session auto-fill. A per-exercise **Rest** feeds the rest timer. Repeat or regress a week from the Load Workout sheet, and choose whether the plan holds on the final week or repeats. Existing single-week programs are unchanged. - **SSO-only mode via environment variable** (#16). Set `OIDC_ENABLE_EMAIL_PASSWORD_LOGIN=0` (or `false` / `no`) at boot to disable password login server-wide, so users must sign in via an OIDC provider. Locks the corresponding admin UI toggle with an env-lock note. Mirrors the pattern across the TraceApps family. - **`/api/auth/status` now returns configured OIDC providers** so the Android app can render OIDC sign-in buttons on first install, before the user has a session. - **"Custom…" option on the Model dropdown for Claude, OpenAI, and Gemini.** Enter any model ID the vendor supports without waiting for the preset list to catch up. Same behavior the OpenAI Compatible provider has always had. - **Retirement remap for retired Gemini models.** Saved selections of `gemini-1.5-*` or `gemini-2.0-*` (both retired by Google) are quietly upgraded to the current default at request time, avoiding 404s. - **Settings search covers new territory:** `garmin`, SMTP test, voice input, custom exercises. ### Changed - **Claude model presets refreshed.** Sonnet bumped to Sonnet 5, Opus 4.8 added as a "smartest" tier option, older Sonnet 4.6 removed. ### Fixed - **OIDC sign-in now works on Android first-install for OIDC-only servers.** NativeSetup previously required a username + password to submit, blocking users on Authentik / Keycloak / Authelia-backed servers with password login disabled. The setup form is now a two-step flow: enter server URL → app fetches `/api/auth/status` → renders whichever auth methods the server actually supports (password fields only when enabled, OIDC provider buttons with logos when configured, both when both). - **OIDC callbacks no longer fail on the first attempt** with a spurious `callback_failed`. openid-client v5's default 3.5 s outgoing HTTP timeout was tight enough that cold token-exchange requests to slower IdPs would sometimes time out. Bumped to 10 seconds. - **Sets logged just before backgrounding the phone are no longer lost.** The debounced save timer now flushes pending writes on `pause`, `visibilitychange`, and `pagehide`, so a set entered right before switching apps is guaranteed to hit the server or the local queue instead of dying with the timer. - **Advance-week button correctly disables in edge cases**, tightened the check so the button doesn't invite you into a state it can't actually enter. ### Security - **fast-uri bumped to 3.1.4** (GHSA-4c8g-83qw-93j6, high). ReDoS in URI parsing. - **brace-expansion bumped to 5.0.8** (GHSA-mh99-v99m-4gvg, high). DoS via unbounded expansion length. - **body-parser bumped to 2.3.0** (GHSA-v422-hmwv-36x6, low). DoS when an invalid `limit` value silently disables size enforcement. ## v1.0.0, 2026-07-18 First stable release under the new semver scheme. Delivers Garmin FIT strength imports, per-set template parity between the Program editor and Diary set rows, a real Send Test email flow, multi-tag Docker publishing, and a high-severity `adm-zip` CVE patch in the backup restore path. Every future release uses strict semver: PATCH for bug fixes, MINOR for new features, MAJOR for breaking changes. Existing `v1.0.0-rc.1` through `v1.0.0-rc.8` image tags and release assets stay live indefinitely; anyone pinned to a specific rc release is unaffected. ### Added - **Garmin FIT Workout Import.** Export a strength-training session from Garmin Connect as its original `.fit` file and import it under Settings → Data → Workout Import. Recognises sets, reps, weights, rest periods; skips non-strength activities. Sits alongside the existing Strong / Hevy / FitNotes / Jefit CSV importers. - **Per-Set Template Parity with Diary.** The Program editor's per-set spec rows now accept the same fields as the Diary SetRow: warmup flag, RPE target, unilateral L/R rep split, and (in supersets) a round-number override picker. Templates carry those flags into the workout when loaded, so a program can pre-mark warm-up sets or prescribe RPE without hand-editing every session. - **Multi-Tag Docker Publishing.** The `ghcr.io/traceapps/lifttrace` image now publishes four tags per release: `:1.0.0`, `:1.0`, `:1`, `:latest`. Pin `:1.0` to auto-receive patches without opting into future 1.1.0 features. - **`:dev` Docker Tag.** Rolling image built from the `dev` branch, updated on every push. - **Send Test Email Dialog.** Asks where to send the test email, pre-filled with your account email, so admins can verify SMTP with a real inbox instead of a silent auth check. - **Public Contributor Docs.** `ARCHITECTURE.md` and `ROADMAP.md`. ### Changed - **Retired the `-rc.N` suffix.** LiftTrace uses strict `MAJOR.MINOR.PATCH` from here on. - **Send Test actually sends an email now** (previously only verified SMTP auth, returning success even when delivery would fail). - **Branded HTML test email** with the LiftTrace logo, matching invite and reset emails. - **Password field uses a Change button** when the server has a stored password, since the redacted placeholder can never be revealed. Tap Change to enter a new password; the field is otherwise read-only. - **Set-Number Picker Has a Visible Pill + Caret.** The tap affordance on the round-number override was too subtle before; a chip with a dropdown caret makes it discoverable at a glance. ### Fixed - **Set-Number / RPE Picker Taps Sometimes Didn't Register.** On Android WebView, if a finger tap on a picker option slipped a few pixels the click landed on the backdrop and closed the picker silently. Added a 350 ms open-lock plus 40 px minimum tap targets so real-world thumb slop stops missing. - **Email Settings Blank on the Android App.** SMTP config loaded from a relative URL without auth headers, so the Android app got an empty payload and rendered blank fields. Now routes via `apiUrl()` with a bearer token like every other Settings section. ### Security - **`adm-zip 0.5.x → 0.6.0`** (CVE-2026-39244, high). A crafted ZIP file could trigger a 4 GB memory allocation during full-backup restore, crashing the server. Admin-only endpoint, but a compromised admin session was enough to weaponise. The existing zip-slip + zip-bomb defense accumulates uncompressed bytes AFTER the vulnerable `Buffer.alloc` call, so the dep bump is the actual fix. --- ## v1.0.0-rc.8, 2026-07-08 ### Added - **OpenAI-Compatible AI Endpoint via Server Proxy.** Self-hosters running Ollama, LM Studio, LocalAI, vLLM, or any other OpenAI-compatible LLM on a private network can now point the Trace assistant at their local endpoint via `AI_BASE_URL` env var (paired with `AI_PROVIDER=oai-compat`). The server proxies chat requests, so the browser never needs to reach the LLM directly. Docker Compose networks where the LLM sits alongside the LiftTrace container work out of the box. ### Fixed - **Set Row Input Clobber on Android.** Typing a weight or reps value sometimes showed the new digit and then reverted to the previous value, or lost a decimal point (`1.` normalising to `1`). The weight and reps inputs now keep a local string mirror while focused so parent-store round-trips can't overwrite what you're typing. - **Set-Number Picker Hiding Behind the Weight Column.** Tapping the set-position digit to override the round number opened a picker that got clipped by the ExerciseCard's rounded corners and lost its stacking battle with the native number input next to it. The picker now portals to body with viewport-computed coordinates so it renders above the row consistently. - **L/R Alternating Reps Inputs on Narrow Phones.** With Alternating load type selected, the reps column's L and R inputs squeezed into the same width used for a single reps value, clipping the digits. The reps column widens to match the weight column (1fr / 1fr instead of 1.4fr / 0.7fr) whenever the split is active. - **Local Backup on Standalone Android Silently Dropping Images.** The auto-scheduled and manual local backups dumped every SQLite table but not the actual files under `lifttrace-uploads/`, so custom exercise photos and user avatars restored as broken references. The backup format now base64-inlines those files (schema v2), and the restore path writes them back. Backups made with rc.7 and earlier still restore cleanly, just without images (matches the old behaviour). --- ## v1.0.0-rc.7, 2026-07-05 Maintenance release. No user-facing feature changes since rc.6. ### Security - **Dependency Security Bumps.** Multer 1.4.5-lts.1 → 2.2.0 (closes three high-severity CVEs on the LTS line covering unhandled-exception DoS, crafted-request DoS, and unclosed-stream memory leak) and nodemailer 8.0.7 → 9.0.3 (closes five CVEs including raw-option bypass, TLS OAuth cert validation, and CRLF header injection). Vite bumped alongside for the Windows `server.fs.deny` bypass patch. `npm audit` trees at both root and server now report zero vulnerabilities. Self-hosters should pull the new Docker image or rebuild from source. --- ## v1.0.0-rc.6, 2026-07-01 ### Added - **Per-Exercise Sharing.** Every exercise now has a Share button in its detail header that produces a portable JSON file. Send the file to another LiftTrace user through any channel (WhatsApp, email, Drive, Signal), and they can tap the file to open it directly in LiftTrace, which prompts them to add the exercise to their library. On the receiving side, the Exercises page's "+" button also offers Import From File and Import From URL, so anyone can pull an exercise from a public URL (raw.githubusercontent.com links work out of the box; github.com/blob URLs are auto-rewritten). Enables community exercise libraries in a lightweight way: a public repo of JSON files, and users can bookmark or share individual links. - **CSV Workout Export.** The Workout Summary sheet gains a download button that produces a long-format CSV (one row per set) with date, exercise, set number, reps, weight, RPE, warmup flag, completion state, and per-set / exercise / workout notes. Unilateral splits become two rows so left and right stay separate. PWA downloads directly; Android writes the file to Cache and opens the system Share sheet. Made for anyone feeding an external analysis pipeline or spreadsheet. - **Custom Equipment.** Add your own equipment types (Slackboard, Sandbag, Weight Vest, whatever's in your home gym or hotel gym) via a new "+ Add" pill in the Exercise Editor's equipment picker. Custom entries sync across devices via your LiftTrace account. They also appear as dashed-border chips in the Exercises filter row when at least one exercise uses them. - **Multi-Select Equipment Filter** on the Exercises page. The equipment chip strip used to be one-at-a-time; it's now multi-select, so you can pick everything you have access to today (Barbell + Dumbbell + Bodyweight when you're travelling and the hotel gym is bare) and filter the library to only exercises that match. Selection persists across navigation. ### Changed - **Animated Banner Redesigned.** Setting Banner Style to Animated used to show illustrated SVG art in every page's header. Those had cross-viewport rendering issues on narrow phones (the art crowded the title and hid action buttons on the Exercises page), so they've been replaced with the same compact accent bar as Gradient plus a subtle motion effect. Pick which motion under Settings → Appearance → Banner Animation: Shimmer (soft white sweep, default), Drift (slow hue rotation), Pulse (brightness breathing), or Aurora (overlapping accent clouds). All four honour Reduce Motion. If you're upgrading from rc.5 with Animated selected, the setting name stays the same but the look is new. The compact bar also reclaims about 40 pixels of vertical real estate on every page. - **Trace FAB Visualizer Feels Snappier on Android.** The frequency ring around the AI coach FAB now tracks music dynamics with the same amplitude as it does on the PWA. It was previously over-damped because two smoothing layers stacked instead of one, leaving the bars perpetually chasing shrinking targets. ### Fixed - **Cross-Device Workout Save Race.** When editing the same workout from two devices near-simultaneously (phone + PWA), a save would sometimes clobber the other device's just-completed sets with a stale in-memory snapshot. The workout store now refetches and merges before writing, so both devices' edits survive. - **Password Manager Password Generation.** Browsers and password managers now correctly generate passwords that satisfy LiftTrace's policy (uppercase + lowercase + digit + special character, 8+ chars) when signing up, accepting an invite, resetting a password, or changing one from Profile. Previously the "suggest strong password" flow produced passwords without a special character, which the field then rejected. --- ## v1.0.0-rc.5, 2026-06-10 ### Added - **Scheduled Automatic Backups** for both server mode and Android local-only mode. Pick daily or weekly, server backups land in the same directory as manual full-backups, local-mode backups export the device's SQLite mirror to the share sheet. Configure under Settings → Backup → Schedule. - **Multi-Architecture Docker Image**. The public image at `ghcr.io/traceapps/lifttrace` now ships both `amd64` and `arm64` builds, so Raspberry Pi 4 / 5 and other ARM self-hosters can `docker compose up -d` without building from source. - **Default Session Length Raised** from 30 days to 1 year so PWA users stop getting signed out every month. Admins can still set their own session length under Settings → User Management → Session Length. ### Changed - **Better Biometric Failure Path** on Android. When a stored auth token has expired, biometric sign-in now surfaces a clear "Session Expired" prompt instead of silently bouncing back to the Login screen with no explanation. ### Fixed - Sync no longer clobbers local pending edits during a pull. If a workout or setting was edited locally and not yet pushed to the server, an incoming server pull no longer overwrites it with older data. - Sync now clears local auth state on a 401 response so the user gets prompted to sign in again, instead of the app looping silently on every subsequent request. ### Docs - The `INSECURE_COOKIES` env var is now called out inline in the example `docker-compose.yml` with a comment explaining the exact symptom (every request 401s after a successful login because the browser drops the `Secure` cookie over plain HTTP). README troubleshooting entry expanded with the Firefox console message that confirms the diagnosis. Closes the gap that surfaced as issue #4. --- ## v1.0.0-rc.4, 2026-05-29 ### Added - **NutriTrace Federation**, log each completed workout's estimated calories burned to your NutriTrace diary automatically. Set it up in Settings → Integrations → NutriTrace by entering your NutriTrace URL and an API token (created on NutriTrace under Settings → User Management → API Tokens with the `write:workouts` scope). Workouts show up in NutriTrace's Workout History next to Fitbit / Garmin data, and NutriTrace handles the double-count-vs-wearable decision automatically. - **Editable Workout Duration** on the completion summary. The Duration tile is now a button: tap it to pick from quick presets (30 / 45 / 60 / 75 / 90 / 120 min) or enter a custom value. Useful when you forgot to start the timer, or to fix a value after the fact. The kcal estimate updates live, and if NutriTrace federation is on, the edit re-syncs. - **Fallback Calorie Estimate** when no duration is tracked. LiftTrace now estimates burn from your completed set count instead of refusing to show a number. Badged "rough" so you know it's less precise than a timed session. - **"You're All Set" Celebration** at the end of the first-run wizard before landing on the diary. - **Shared Loading Spinner** across Diary, Programs, Coaching, Exercise detail, Statistics, and Workout editor. No more plain "Loading…" text. ### Changed - **Stronger Edit Affordance** on the Workout Summary Duration tile: accent-tinted background, accent border, and a clear edit pencil so it reads as tappable next to the read-only stats. - **Persistent Connected Pill** on the NutriTrace federation card. Once verified, the green status pill stays visible until you edit the URL or token, instead of disappearing when you navigate away. - **Title Case Sweep** across about 20 button labels, menu titles, and section headers that were inconsistently sentence-cased (Clear All Settings, Mark All Seen, Delete User, Add to Favorites, etc.). - **i18n**: 7 new common error strings now route through translation, plus the create-admin form in Settings → User Management. ### Fixed - **Settings → Backup** silently failing when the backup list endpoint returned an error, leaving the user with an empty list and no explanation. Now surfaces a toast with the underlying message. - **Radio** showing an empty grid when Subsonic / Jellyfin was unreachable. Now shows "Couldn't reach your media server" with a Retry button so the failure mode is obvious. - **Workout Complete notification** firing on every set toggle when re-opening a completed workout's summary (rc.3 caught the main flow; this patches the toggle edge case). --- ## v1.0.0-rc.3, 2026-05-25 ### Fixed - **Fresh Docker install crashed on first boot** with `SqliteError: no such table: coach_activity` (issue #2). A migration that adds a column to `coach_activity` was running *before* the `CREATE TABLE` for it, so any database that had never seen an earlier LiftTrace beta would fail to start. Existing installs (which had the table from an earlier beta) were unaffected. The ALTER is now ordered after the CREATE, so `docker compose up` works on a clean volume. --- ## v1.0.0-rc.2, 2026-05-25 ### Added - **Gradient banner style** as a third option for Settings → Appearance → Page Banners (between Animated and Off). A compact-height header filled with your active accent color over a subtle glass overlay; header action icons pick up a matching frosted-glass pill so they stay legible against the saturated background. ### Changed - **Page Banners default for new installs is now Gradient** instead of Animated. Existing users keep whatever they had; the new default only applies to users finishing or skipping the first-launch wizard. - **Goal Celebrations toggle moved** from Settings → Workout to Settings → Appearance, next to Reduce Motion. Same toggle, same behavior; the move groups all visual-effect controls together and matches NutriTrace's layout. - **Notification Delivery card simplified.** Push service status shows a single "Configured" pill with one Test button at the top of the card, instead of a separate subtext block and Send-Test row. ### Fixed - **Re-opening a completed workout summary no longer re-fires the "Workout Complete" notification.** Tapping "View Workout Summary" on a previously-completed session now just opens the summary, instead of re-running the celebration / save / timer-reset path. Affects both local notifications and any configured push service (Gotify, ntfy, Apprise). Also stops the same path from clobbering today's running rest timer if you opened a past day's summary mid-session. --- ## v1.0.0-rc.1, 2026-05-24, First public release candidate LiftTrace goes public. The dev tree (`TraceApps/lifttrace-dev`) has been syncing to the public mirror at `TraceApps/lifttrace` since this release. Same app, just an open repo and signed Android APK in GitHub Releases. What you get out of the box (since the first private build): - **Diary**, daily workout log with sets, reps, weights, RPE, warm-ups, supersets, rest timer with persistent state across navigation, and Smart Add for natural-language entry. - **Programs**, build mesocycles, assign templates by day, progress through weeks. Coach prescriptions flow into Diary automatically. - **Exercises**, full library (wger / free-exercise-db / exercisedb) plus your own custom exercises with images, GIFs, or YouTube. - **Statistics**, volume, PRs, frequency, body stats trends. - **Trace AI**, multi-provider coach (Claude / OpenAI / Gemini / any OpenAI-compatible endpoint) with live workout context, hold-to-voice log on the FAB, frequency visualizer ring when music plays. - **Radio**, built-in player for Subsonic / Jellyfin libraries and streaming Icecast / Shoutcast / HLS internet radio with now-playing metadata. Plays through ExoPlayer on Android (lockscreen + media controls), MSE on web (gapless, locked-screen-safe). - **Coaching**, trainer accounts can build templates and prescribe workouts to athletes. Prescriptions show in Diary on the right day. - **OIDC SSO**, sign in via Authentik, Keycloak, Pocket ID, Authelia, Auth0, Google, or any OIDC 1.0 provider. Multi-provider supported. - **Workout-history import**, bring in your old log from Strong, Hevy, FitNotes, or Jefit (CSV). - **Multi-user**, invite by email or link, sessions configurable up to one year, admin / trainer / user roles, OIDC group → role mapping. - **Wearables-style biometric sign-in** on Android (fingerprint / face). - **Local + server modes on Android**, run fully offline with on-device SQLite, or connect to a self-hosted LiftTrace server for sync. - **Smart Log**, paste a workout in plain English ("bench 3x5 @ 225, A1: curls 3x12 @ 30, A2: pushdowns 3x12 @ 40") and it gets parsed, matched against the library, and saved. This is a release candidate, not a final 1.0. Expect bugfixes and polish in the `-rc.N` series before the `1.0.0` tag drops. ### Security, Android release builds now reject cleartext HTTP The release-signed APK distributed via GitHub Releases enforces a strict network security policy: only HTTPS connections to your LiftTrace server are allowed, and only system-installed CAs are trusted. This protects auth tokens (JWT cookies and Bearer headers) from interception on untrusted networks like public WiFi. If you self-host on plain HTTP (LAN-only, no TLS), you have four options spelled out in [DEPLOY.md](DEPLOY.md) → "Connecting from Android": 1. Real domain + Let's Encrypt (recommended). 2. Cloudflare Tunnel / Tailscale Funnel / Tailscale mesh. 3. Self-signed cert + install your CA on Android. 4. Build the debug APK yourself, `npm run android:debug` produces a permissive APK that accepts `http://` and self-signed certs. Sideload it instead of the release APK. Server-side, `INSECURE_COOKIES=1` continues to opt out of the HTTPS-only auth-cookie flag for non-TLS server deployments. ## v0.10.1-beta.5, 2026-04-30, OIDC Single Sign-On LiftTrace now supports OpenID Connect SSO. Sign in via Authentik, Keycloak, Pocket ID, Authelia, Auth0, Google, or any OIDC 1.0 provider that supports Authorization Code Flow + PKCE + Discovery. What you get: - **Multiple providers**, admins can configure as many IdPs as they want from Settings → User management → OIDC providers. Each one gets its own button on the Login page. - **Provider preset picker**, when adding a provider, pick from Auth0, Authelia, Authentik, Google, Keycloak, Pocket ID, or Custom. Each preset pre-fills sensible defaults (scope, auth method, group claim). - **Auto-link verified emails** (default ON), when an IdP says `email_verified=true` and the email matches an existing LiftTrace user, the accounts link silently on first SSO sign-in. - **Auto-register new users** (default OFF), opt in for blanket onboarding. Leave off for shared IdPs (Google, work SSO). - **Admin role mapping**, pin an "admin" group claim and value; membership promotes the user to admin on every login. - **Profile → Linked accounts**, sign in with your password, then link an SSO provider from your Profile so next time you can use either. - **Allow password login toggle**, disable password login entirely once SSO is configured. `RECOVERY_TOKEN` still works as the lockout escape hatch. - **Android support**, SSO works on the native app too, via Chrome Custom Tabs and a `lifttrace://oidc-callback` deep link. Client secrets are encrypted at rest. Discovery is cached for an hour. PKCE + state + nonce are validated on every callback. ### UX polish in the same release - **No more theme flash**, the app no longer reapplies accent/dark-mode every 30 seconds when settings poll. - **Diary stops re-fetching on every nav**, switching tabs no longer causes a brief meal-card flash if the data is already loaded for today. - **Settings text fits**, long labels in OIDC and elsewhere no longer push toggles or action icons off-screen. ### For self-hosters Backups now include OIDC providers and per-user links. The `client_secret` column stays encrypted in the dump; restoring to a host with a different `JWT_SECRET` (and no `TOKEN_ENC_KEY` override) will require re-entering secrets in Settings. --- ## v0.10.1-beta.4, 2026-04-28, Real FFT visualizer on Android The FAB equalizer ring now mirrors the PWA: real frequency-domain data driven directly off the audio that's playing, not procedural sine waves. The previous Android path used `android.media.audiofx.Visualizer` to sniff the ExoPlayer audio session, but on most recent Pixel / Samsung ROMs that service returns `INIT_CHECK_FAILED (-3)` for media-output sessions regardless of how the session is allocated , the OS audio policy blocks it. We had a procedural sine-wave fallback so the ring stayed lively, but the bars no longer reacted to the actual music. Fix: tap the PCM stream inside ExoPlayer's own audio sink via a custom `AudioProcessor` (`FftAudioProcessor.java`). It accumulates samples into a 256-frame window, applies a Hann window, runs a Cooley-Tukey FFT, packs the bins into the same byte layout Android's `Visualizer.getFft()` produces, and emits at ~30Hz. The JS-side `_decodeFft` parser doesn't need to change. Why it always works now: the processor lives inside our decoder pipeline, before the audio hits `AudioTrack`. No system service or permission involved. Drop-in for every device where ExoPlayer plays sound. The legacy `Visualizer` attempt is left in place as a parallel source, if a device happens to allow it, both feeds run and the visualizer just keeps the most recent frame. The procedural fallback in `src/lib/native-player.js` stays too as a last-resort safety net (it auto-disables the moment a real FFT frame arrives). --- ## v0.10.1-beta.3, 2026-04-28, i18n parity batch (192 keys) Continues the parity push toward NutriTrace's 210-key baseline by extracting the strings that surface across the most-used UI shells , the dialog primitives, the Trace FAB, and the Diary action toasts / date-nav buttons. Newly wired through `$_()`: - `Sheet.svelte`, close button label + tooltip via `common.close`. - `ActionSheet.svelte`, sheet cancel button via `common.cancel`. - `Trace.svelte`, FAB aria-label + tooltip (`trace.fab_label`, `trace.fab_tooltip`), the chat clear button (`trace.clear_conversation`), the close icon, the attach-image button, and the input placeholder (`trace.ask_placeholder`). - `Diary.svelte`, the workout-name placeholder, tap-to-rename tooltip, the four date-nav buttons (previous / next / jump-to-date / today), the six action-button labels (gym tools, body stats, workout actions +`_long` aria-label variants), seven action toasts (cleared, timer reset, copied, four superset state changes), and two error toasts (no workout yesterday, copy failed). Net new keys: 26 (`diary` + `trace` blocks). en.json now at 192 keys. Bridges roughly half the remaining gap to NutriTrace's en.json. --- ## v0.10.1-beta.2, 2026-04-29, i18n thorough wiring (166 keys) beta.1 created the i18n keys but didn't wire most of them into components. This beta does the actual extraction so a translator copying en.json to fr.json and picking Français in Settings → Units sees real strings flip live, not just the language picker label itself. Surfaces wired through \$_(): - BottomNav: 6 nav labels (Diary / Exercises / Programs / Stats / Radio / Settings). - Sidebar: same nav labels + sign-out tooltip. - All 7 main route page titles (Diary, Exercises, Programs, Statistics, Coaching, Settings, Profile) plus the Profile page's save button. - All 15 settings section headers in their respective Settings*.svelte sub-components (Appearance / Workout / Statistics / Trace / Radio / Catalog / Backup / Notifications / Email / Users / Mode / Workout Import / Diagnostics / About / Units). - SettingsAppearance: Theme / Accent / Navigation labels. - SettingsUnits: Language picker label. - Login: subtitle, all field labels + placeholders, sign-in button with loading state, forgot-password + locked-out toggles, full recovery box copy (explainer / prompt / action / disabling state), success copy, all error toasts. - ForgotPassword: title, intro, email label, send-link button, sent confirmation with email interpolation, errors. - ResetPassword: title, validating + invalid + success states, set-for username interpolation, password labels + strength placeholder, submit + saving states, errors. - AcceptInvite: title, validating + invalid + success states, intro variants (with/without prefilled email), all four field labels + placeholders, submit + creating states, errors. - Profile: Personal Info / Security headers, Full Name / Email / Nickname / Birthday / Gender labels + placeholders, gender-unset option, password change flow (current + new + confirm labels, change-password button with loading state, save + password-changed toasts). - Wizard: nav buttons (Back / Next / Get Started / Skip / Let's go), all 6 step titles + descriptions, both variants of the usermgmt step (forceAccountCreation vs multi-user toggle), all step-specific field placeholders + button labels + interpolated progress messages for the library import, theme picker labels. en.json holds 166 keys. Server-side strings, Diary primary actions, exercise editor / smart-log / per-route deep extraction still pending , those land as volunteer translators identify which screens they need. --- ## v0.10.1-beta.1, 2026-04-28, i18n, subpath, Docker secrets, shared DatePicker Ports four patterns from NutriTrace's v1.0.0-rc.6 release. None affect existing deployments by default, every feature opts in via env var or component swap. ### Added - **Internationalization (i18n) scaffolding.** `svelte-i18n` wired up with one JSON file per locale under `src/i18n/`. Language picker added at the top of Settings → Units & Format. en.json covers 45 keys, nav labels, page titles for the 11 main routes, all 15 settings section headers, and common buttons (Save, Cancel, Delete, etc.). New `npm run i18n:check` script reports per-locale completeness. Deeper extraction (workout editor strings, smart-log modal, exercise editor, settings sub-section internals) follows in subsequent batches as volunteer translators request specific screens. - **Reverse-proxy / subpath support via `BASE_URL` env var.** Lets users mount LiftTrace at `/lifttrace/` or any other prefix without URL rewriting in the reverse proxy. Server mounts all middleware + routes inside an Express sub-router at the prefix; client reads basePath from `window.__LT_CONFIG__` injected at HTML serve time. Default empty `BASE_URL` is identical to pre-feature behavior. Vite `base: './'` so asset URLs are relative; PWA manifest `start_url`/`scope` are `'./'` for subpath PWA install. `apiFetch.js` interceptor extended to also run in PWA mode when basePath is set, prefixing `/api/` and `/uploads/` URLs, single point of interception means no per-call- site changes were needed. - **Docker / Swarm-style secret file env vars.** New `server/docker-entrypoint.sh` reads any `*_FILE` env var at container startup, loads the referenced file, and exports the value as the corresponding env var before Node starts. Covers `JWT_SECRET_FILE`, `RECOVERY_TOKEN_FILE`, `SMTP_PASS_FILE`, `AI_API_KEY_FILE`. Errors loudly if both `NAME` and `NAME_FILE` are set or if the file is unreadable. - **Shared `DatePicker` + `DateInput` components.** New `src/components/ui/DatePicker.svelte` calendar (month/year nav, year/month grid pickers, day grid, locale-aware), and `src/components/ui/DateInput.svelte` wrapper combining a masked text input + calendar trigger button. Manual date entry is masked: only digits accepted, separators auto-inserted in the user's chosen format (ISO/US/EU), capped at 8 digits. Profile birthday now uses DateInput instead of the browser-native ``. ### Fixed - Static asset references in components (Sidebar brand icon, all auth screens' logos, NativeSetup logo, Settings → About icon) used absolute `/icons/...` paths and would have 404'd at subpath. All now route through `resolveAssetUrl()` which prefixes with basePath in PWA mode. ### Notes - LiftTrace doesn't have CSRF middleware (only auth.js in server/middleware/), so the CSRF rejection bugs that affected NutriTrace's settings.js + login flow during its rc.6 testing don't apply here. The fetch interceptor handles apiUrl consistency without any per-call-site changes, much simpler than NutriTrace's pattern. - LiftTrace's wizard is structurally simpler than NutriTrace's (6 steps, no dob/gender duplicate, already uses Trace branding). No wizard cleanup pass needed. --- ## v0.10.0-beta.9, 2026-04-28 ### Fixed, Native auth bearer token never being stored beta.8 taught the server to read the JWT from `Authorization: Bearer …`, but two upstream gaps meant the token was never actually being set in localStorage on native, so the header always went out empty: 1. **Server `/api/auth/login` only set a cookie**, never returned the JWT in the response body. NativeSetup called `setAuthToken(data.token)` → `data.token` was `undefined` → `localStorage.removeItem('lt:authToken')`. Same for `/register`, `/reset-password`, `/accept-invite`. 2. **Login.svelte (the regular login screen) didn't call `setAuthToken` at all**, only relied on the cookie. So when a user got bounced from NativeSetup → loadAuthState 401 → Login screen, even a successful re-login left no token stored, and every subsequent API call landed unauthenticated. Fix: - Server: `/login`, `/register`, `/reset-password`, `/accept-invite` now return `{ user, token }` in the response body. Cookie still set for browser PWA path; bearer token now available for native. - Client: `Login.svelte` + `Wizard.svelte` + `ResetPassword.svelte` + `AcceptInvite.svelte` now call `setAuthToken(data.token)` after a successful response. Server redeploy required AND new APK install. After both, the NativeSetup → migrate path stops bouncing to Login, and the regular Login screen actually persists the bearer token across launches. --- ## v0.10.0-beta.8, 2026-04-28 ### Fixed, Native Android server-mode authentication The server's `authenticate` middleware was reading the JWT only from the `lt_token` cookie. Native Capacitor builds use the patched fetch in `src/lib/apiFetch.js`, which sends the JWT as `Authorization: Bearer …` and explicitly sets `credentials: 'omit'` so cookies don't ride along (WebViews don't reliably persist cross-launch cookies). Result: every authenticated request from the Android app was hitting `req.cookies` = nothing → server replied 401 "Not authenticated" → migration uploads silently dropped, sync pulls cached as failures, and `loadAuthState` returned a null user even though the token was valid. Server middleware now accepts the token from EITHER the cookie OR the Authorization header, same pattern NutriTrace already uses. Browser PWA builds keep working off the cookie; native Android works off the Bearer header. Server redeploy required (`docker compose pull && up -d` or equivalent). The Android APK doesn't need rebuilding, it was already sending the header correctly; the server just wasn't reading it. --- ## v0.10.0-beta.7, 2026-04-27 ### Added, Standalone → server data migration First-launch wizard (and Settings → "Connect to server") now detects local SQLite data when transitioning out of standalone mode and presents a three-option dialog before silently switching modes: - **Upload to server**, push every local row through the existing `PUT /api/workout/:date`, `PUT /api/body-stats/:date`, `POST /api/programs`, `POST /api/templates`, `POST /api/exercises`, `PUT /api/settings` endpoints. Workouts and body-stats use `UNIQUE(user_id, date)` for clean dedup; programs and custom exercises accept duplicates. - **Replace with server**, destroy the local SQLite (via existing `destroyLocalDb`) and let `pullSnapshot` repopulate from the server. - **Merge both**, upload local first, then run `runSync()` to refresh the local cache so the UI reflects the merged state. The dialog shows per-table counts up front (`12 workouts, 8 body-stats entries, 3 programs (12 templates), 5 custom exercises`) and the upload pass shows live progress + a final success/error tally per table, both improvements over the silent NutriTrace pattern this mirrors. New helper at `src/lib/migrate.js`. ### Internal, Material Symbols bundled locally (v0.10.0-beta.6 hotfix) The icon-name FOUT on Android cold-launch ("menu", "fitness_center", etc. flashing as text before the font arrived) is fixed by bundling the Material Symbols Rounded variable woff2 into `public/fonts/` and declaring an `@font-face` with `font-display: block`. Inter stays on Google Fonts since the system-ui fallback is cosmetically fine. --- ## v0.10.0-beta.6, 2026-04-27 ### Changed, Unified Android playback on Media3 ExoPlayer Radio AND library tracks (Subsonic / Jellyfin / local) now both flow through the same native ExoPlayer + MediaSession. Replaces the prior split where streams went native and library tracks went through the JS MSE pipeline + capacitor-music-controls plugin. Wins: - **One MediaSession**, eliminates the dual-session bug that caused intermittent dead lockscreen taps and notification flicker. - **One notification UX** across radio + library. Lockscreen swaps between [Stop] (live radio) and [Prev | Next | Stop] (library) via Media3's custom command layout. - **Native ExoPlayer for everything**, same decoder that already works for HE-AAC/HLS/iHeart now also plays Jellyfin/Subsonic streams with HTTP/2 + redirect handling out of the box. - **`audiofx.Visualizer` ring on every track** (not just radio). - **Drops capacitor-music-controls-plugin**, kills the package we had to monkey-patch around (NPE in Java + Capacitor 8 thenable bug). The Web PWA path is unchanged, `