Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes

- Remove duplicated Garmin fields from storage; decode display-only fields
  (activity name/type, lap duration/HR, structured workout raw JSON) from
  RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
  effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
  Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
  Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
  use tight non-zero-based domains, m:ss/km pace formatting, and rounded
  ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
  alongside activity/lap/detail JSON; enlarge the modal and shrink array
  indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
  combined sync run per manual "Sync now" and count genuinely new
  activities instead of re-listing whatever Garmin returned for the queried
  window
- Let a Review Queue activity be manually cleared back to Unclassified, and
  make "Reset all" available even while disconnected from Garmin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 06:33:47 +02:00
parent 9818d35910
commit 518bccf5ab
56 changed files with 2334 additions and 890 deletions

View File

@@ -20,6 +20,10 @@ export function GarminConnection() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
// There's no real Garmin logout -- the backend session just sits idle.
// "Disconnect" only hides that and shows Connect again locally; a real
// login (connect()) clears it.
const [disconnected, setDisconnected] = useState(false);
function refreshStatus() {
api.authStatus().then(setAuth).catch((e) => setError(String(e)));
@@ -47,6 +51,7 @@ export function GarminConnection() {
setError(null);
try {
setAuth(await api.login());
setDisconnected(false);
} catch (e) {
setError(String(e));
} finally {
@@ -54,6 +59,10 @@ export function GarminConnection() {
}
}
function disconnect() {
setDisconnected(true);
}
async function submitMFA() {
if (!code.trim()) return;
setBusy(true);
@@ -90,7 +99,7 @@ export function GarminConnection() {
try {
await api.resetSync();
// Reset runs as a background sync job; wait for it to actually finish
// before reloading, otherwise other pages (e.g. Review Queue) would
// before reloading, otherwise other pages (e.g. Activities) would
// still show the just-deleted activities from their own stale state.
let status = await api.syncStatus();
while (status.in_progress) {
@@ -104,45 +113,48 @@ export function GarminConnection() {
}
}
const status = auth?.status ?? "unknown";
const status = disconnected ? "unknown" : auth?.status ?? "unknown";
return (
<div className="garmin-connection">
<div className="garmin-connection-row">
<span className={`status-dot status-${status}`} />
<span className="status-label">
{status === "authenticated" && "Connected to Garmin"}
{status === "mfa_required" && "MFA code required"}
{status === "failed" && "Connection failed"}
{status === "unknown" && "Not connected to Garmin"}
</span>
<div className="garmin-connection-row garmin-connection-main">
<div className="garmin-connection-actions">
{status !== "authenticated" && status !== "mfa_required" && (
<button disabled={busy} onClick={connect}>
Connect to Garmin
</button>
)}
{status !== "authenticated" && status !== "mfa_required" && (
<button disabled={busy} onClick={connect}>
Connect to Garmin
{status === "authenticated" && (
<>
<button disabled={busy} onClick={sync}>
Sync now
</button>
<button disabled={busy} onClick={disconnect}>
Disconnect
</button>
</>
)}
{/* Reset all only wipes local DB state (see handleSyncReset) -- it
doesn't touch the Garmin session, so it stays available even
while disconnected/not-yet-connected. */}
<button className="button-danger" disabled={busy} onClick={resetAll}>
Reset all
</button>
)}
</div>
{status === "authenticated" && (
<>
<button disabled={busy} onClick={sync}>
Sync now
</button>
<button className="button-danger" disabled={busy} onClick={resetAll}>
Reset all
</button>
{syncStatus?.in_progress && (
<span className="status-label">{syncProgressLabel(syncStatus)}</span>
)}
{syncStatus?.last_run && !syncStatus.in_progress && (
<span className="status-label">
last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
{syncStatus.activities_pending_details > 0 &&
`${syncStatus.activities_pending_details} still need details, click Sync now again`}
</span>
)}
</>
)}
<div className="garmin-connection-status">
<span className={`status-dot status-${status}`} />
{/* Connected/not-connected is already conveyed by the
Connect/Disconnect button itself -- only MFA/failed need a
label, since no button distinguishes those from "not connected". */}
{(status === "mfa_required" || status === "failed") && (
<span className="status-label">
{status === "mfa_required" ? "MFA code required" : "Connection failed"}
</span>
)}
</div>
</div>
{status === "mfa_required" && (
@@ -159,7 +171,21 @@ export function GarminConnection() {
</div>
)}
{auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
{!disconnected && auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
{/* Full-width so a long "last sync"/pending-details message has room
to breathe, instead of being squeezed next to the status dot. */}
{status === "authenticated" && syncStatus?.in_progress && (
<p className="garmin-connection-message">{syncProgressLabel(syncStatus)}</p>
)}
{status === "authenticated" && syncStatus?.last_run && !syncStatus.in_progress && (
<p className="garmin-connection-message">
last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
{syncStatus.activities_pending_details > 0 &&
`${syncStatus.activities_pending_details} still need details, click Sync now again`}
</p>
)}
{error && <p className="error">{error}</p>}
</div>
);