These render the initial dashboard page today and will also be
rendered standalone by the sync routes in the next commits, so the
same markup drives both the full page and the post-sync response.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Self-hosted htmx v2.0.10 (no CDN) plus the toast container and CSS
this and the following tasks need for in-place sync updates.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extracts the dashboard_rows() loop body into a shared
_build_dashboard_row helper so a single rider's row can be re-queried
after a sync action, without changing dashboard_rows()'s behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Shows rider count, activities imported in the last 7 days, 7-day
sync success rate, and how many riders currently need attention,
right above the rider list where an admin looks first.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the one-time UTC-to-local formatting with a per-second
countdown (HH:MM:SS, or MM:SS under an hour) that fits the dark
cockpit theme's instrument-panel feel, falling back to "due now" once
the target passes instead of showing a negative duration.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
input[type="number"] was missing from the form field selector added
in the redesign, so the scheduler settings fields stayed white-on-
white. Caught via manual browser verification.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the light generic theme with a dark palette grounded in the
app's own subject matter (bike computer telemetry): electric-lime
accent, tabular monospace numerals for stats/timestamps, hairline
card borders instead of shadows, and visible focus rings. Every
template already shares the same classes, so no markup changes were
needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Exposes the scheduler's next_tick in the topbar via a safe Jinja
helper (falls back to nothing if the scheduler isn't running yet),
and converts the server-rendered UTC timestamp to the visitor's local
time client-side so it reads correctly regardless of timezone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a self-hosted stylesheet (no CDN dependencies) with a card-based
dashboard and color-coded status badges, and shows the last 10 sync
runs per user on the detail page.
Close the leaked httpx.AsyncClient in MyWhoosh sync runs, ensure hard
failures finish sync_runs as FAILED instead of leaving them stuck at
RUNNING, log (non-benign) exceptions surfaced by sync_all_enabled during
scheduled ticks, classify GarminImportRejected as a non-retryable
per-activity failure, fix a bug where a live Garmin-class action_required
state could be silently cleared by a run that did no Garmin work, use the
activity's DB primary key instead of the unsanitized remote id for
filesystem paths, add regression/coverage tests for the health-state fix
and MFA code threading through the real SyncManager, add idempotency
coverage to the two-user acceptance test, and note the Dockerfile's
single-process assumption.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wrap SyncManager.sync_user in a per-user asyncio.Lock (raising
SyncAlreadyRunning on overlap) and add sync_all_enabled() to fan out
across all enabled users with per-user failure isolation via
asyncio.gather(return_exceptions=True). The prior sync_user body is
renamed to _sync_user_locked with no logic changes.
Add state-transition methods to ActivityRepository for advancing activity
stages (mark_downloaded, mark_converted, mark_imported, mark_duplicate,
mark_failed) with proper retention of last_completed_stage on failure.
Add list_pending_for_user to filter activities for processing. Implement
SyncRunRepository for creating and finalizing sync runs with counts and
summary errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes 9 numbered findings + 7 minor fixes from the whole-plan review of
the MyWhoosh/Garmin service clients (Plan 3):
Garmin uploader (app/garmin/uploader.py):
- Detect Garmin-rejected imports (failures without successes) and raise
new GarminImportRejected instead of reporting them as successful.
- Reclassify 429/rate-limit/500 login failures as transient instead of
falling through to permanent auth errors; unrecognized login failures
are now treated as transient (retryable) rather than GarminAuthError.
- Mirror the auth-token check from the login branch into the import
branch so 401-at-import-time raises GarminAuthError instead of
propagating raw.
- Add common GarminError base class, hoist transient-token tuple to a
shared module constant, check response.status_code==409 before the
duplicate substring fallback, and create the tokenstore dir 0o700.
MyWhoosh client (app/mywhoosh/client.py):
- Add optional max_pages bound to list_activities pagination.
- Add aclose()/__aenter__/__aexit__ so the client's own httpx.AsyncClient
gets closed, while never closing an injected client.
- Guard the two remaining unguarded JSON-decode paths (login body,
download-fit metadata) so malformed bodies raise
MyWhooshIntegrationError instead of raw ValueError/AttributeError.
- Row-level malformation (missing id/activityFileId, unparseable
startDatetime) is now skipped rather than aborting the whole page;
envelope-shape failures still raise. id/activityFileId checks use
explicit None/"" comparisons instead of Python falsiness.
- Replace asserts in _authenticated_post with explicit exceptions;
restrict the reauth retry to 401 only, treat 403 as immediately
terminal; naive startDatetime values are now treated as already-UTC
instead of host-local.
MyWhoosh tokenstore (app/mywhoosh/tokenstore.py):
- load() now treats any corrupt/malformed token file (bad JSON, missing
keys, OS errors) as "absent" instead of raising, so a bad cache no
longer permanently wedges a user.
pyproject.toml:
- Tighten garminconnect pin to >=0.3.10,<1 (import_activity requires
0.3.10+).
Adds/updates tests across tests/mywhoosh/ and tests/garmin/ covering
all of the above, including a fake client that wraps GarminUploadBlocked
in a plain RuntimeError to mirror the real garminconnect library's MFA
error wrapping.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implement GarminUploader adapter around garminconnect library:
- Import activities using import_activity() not upload_activity()
- Treat duplicate activity responses as terminal success
- Raise GarminUploadBlocked for MFA to allow UI code collection
- Distinguish auth, transient, and other errors appropriately
- Helper functions for duplicate detection and activity ID extraction
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Final-review fixes for Plan 2 (fit-rewriter). Every failure mode below now
surfaces as FitFormatError so Plan 3 can classify invalid FIT input as a
non-retryable activity error (spec 10.4).
- Range-check numeric values against the field's declared size before
struct.pack, so an oversized serial number or a 1-byte product field
raises FitFormatError instead of leaking a raw struct.error.
- Reject zero-size field definitions during parsing. A zero-size
device_info field 0 read back as device_index == 0 via
int.from_bytes(b"", ...), which could have let a paired sensor be
rewritten as an Edge 1030 Plus (spec 10.2).
- Add DeviceFieldValue.is_creator so callers can tell the creator
device_info record from sensor records instead of silently keeping
whichever record appeared last.
- Implement the missing spec 10.4 post-patch step: read the patched
buffer back and verify file_id 1/2/8 and creator device_info 2/4/27
hold the target values. A field that could not be written (e.g. a
product_name field too small for the target string) now fails the whole
conversion rather than producing a silent partial patch. Verification
runs before the output is written, so a half-rewritten file never lands
on disk.
- Use the field's actual endianness in _read_field_value's fallback path.
- Add curated re-exports in app/fit/__init__.py for Plan 3.
- Document _iter_data_fields' caller invariant (validate the container
first; end_offset is not clamped).
- Extend the preservation fixture with a product_name string field so the
zero-filling string write path is covered by the byte-preservation
proof, and test convert_fit_device against a 12-byte header.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- C1: drop module-level app singleton in app/main.py so importing the
package no longer validates Settings or creates DATA_DIR; run uvicorn
with --factory in the Dockerfile. pytest now collects and passes with
no ambient env vars.
- I2: add missing app/auth, app/security, app/web __init__.py so
setuptools discovers all five packages.
- I3: resolve the Jinja2 template directory relative to __file__ instead
of the process CWD.
- I4: add .gitignore covering .env, data/, .venv/, caches and build
artifacts so example deployment secrets cannot be committed.
- I5: assert UserRepository.list_enabled() excludes disabled users.
- M6: encode both operands before hmac.compare_digest in validate_csrf so
a non-ASCII token yields 403 instead of an unhandled 500.
- M9: remove unused relationship / HealthState imports.
- M11: make session cookie https_only configurable via SESSION_HTTPS_ONLY
(default unchanged: false).
- M13: dispose SQLAlchemy engines in the db_session and client fixtures.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add an explicit non-empty check before encrypting user-submitted
email/password fields in the create and update user routes, so a
request that bypasses the HTML `required` attribute gets a clean
400 instead of an unhandled ValueError from CredentialCipher.encrypt
propagating as a 500. Applies to all four credential fields on
create, and to the two email fields on update (the password-blank-
means-keep-existing behavior on update is unchanged).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds full CRUD for sync users (create/list/detail/edit) behind admin
auth and CSRF protection. Passwords are encrypted at rest and never
decrypted into a template context; only emails may be decrypted for
display on the edit form. Blank password fields on edit preserve the
existing encrypted password.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>