Add Svelte GUI, FIT encoder and README

Standalone binary embeds the frontend, avoiding the dev-server dependency
that made the window fail to load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:50:01 +02:00
co-authored by Claude Opus 5
parent 7c17ca6158
commit 3a2a787b7d
23 changed files with 3297 additions and 46 deletions
+374
View File
@@ -0,0 +1,374 @@
<script lang="ts">
/**
* Connection screen (FR-9.1–9.3).
*
* The one thing this screen must get right: **connected is not
* controllable**. A trainer can be attached at the BLE level and still refuse
* the FTMS control point, in which case nothing you do moves the resistance.
* So every trainer row carries two independent states, side by side, and the
* ride screen stays gated until control is real.
*/
import { app } from '../lib/app.svelte';
import { api } from '../lib/bridge';
import { connectionText, rssiBars } from '../lib/format';
import type { DeviceInfo, DeviceKind } from '../lib/types';
const devices = $derived(app.devices.devices);
const scanning = $derived(app.devices.scanning);
const trainerReady = $derived(
devices.some((d) => d.kind === 'trainer' && d.controlAcquired),
);
const KIND_LABEL: Record<DeviceKind, string> = {
trainer: 'Smart trainer · FTMS',
clickLeft: 'Zwift Click · left pod',
clickRight: 'Zwift Click · right pod',
heartRate: 'Heart rate monitor',
unknown: 'Unidentified',
};
function isConnected(d: DeviceInfo): boolean {
return d.state === 'Connected' || d.state === 'Controlling';
}
function busy(d: DeviceInfo): boolean {
return d.state === 'Connecting' || d.state === 'Reconnecting';
}
</script>
<div class="screen">
<header>
<div>
<h1>Devices</h1>
<p>
{#if scanning}
Scanning for Bluetooth peripherals…
{:else}
Scan stopped.
{/if}
</p>
</div>
<div class="actions">
{#if scanning}
<button class="btn ghost" onclick={() => app.run(() => api.stopScan())}>Stop scan</button>
{:else}
<button class="btn ghost" onclick={() => app.run(() => api.startScan())}>Scan</button>
{/if}
<button class="btn primary" onclick={() => (app.screen = 'ride')}>
{trainerReady ? 'Go to ride' : 'Ride without a trainer'}
</button>
</div>
</header>
{#if !trainerReady}
<div class="gate">
<span class="dot tone-warn"></span>
<span
>No trainer under control yet. The ride screen will show <strong>simulated</strong>
telemetry until an FTMS trainer accepts the control point.</span
>
</div>
{/if}
<div class="list">
{#each devices as device (device.id)}
{@const conn = connectionText(device.state)}
{@const bars = rssiBars(device.rssi)}
<article class="row" class:live={isConnected(device)}>
<div class="identity">
<span class="name">{device.name}</span>
<span class="meta">{KIND_LABEL[device.kind]} · {device.address}</span>
{#if device.error}
<span class="error">{device.error}</span>
{/if}
</div>
<div class="signal" title="{device.rssi} dBm">
<span class="bars">
{#each [1, 2, 3, 4] as bar}
<i class:on={bar <= bars} style:height="{bar * 25}%"></i>
{/each}
</span>
<span class="dbm">{device.rssi}</span>
</div>
<!-- Two states, never conflated (FR-9.3). -->
<div class="states">
<span class="state">
<span class="label">Bluetooth</span>
<span class="value {conn.tone === 'ok' ? 'tone-ok' : `tone-${conn.tone}`}">
<span class="dot"></span>{busy(device) ? conn.label + '…' : conn.label}
</span>
</span>
{#if device.kind === 'trainer'}
<span class="state">
<span class="label">FTMS control</span>
<span class="value" class:tone-ok={device.controlAcquired} class:tone-warn={!device.controlAcquired}>
<span class="dot"></span>{device.controlAcquired ? 'Acquired' : 'Not acquired'}
</span>
</span>
{:else if device.kind === 'clickLeft' || device.kind === 'clickRight'}
<span class="state">
<span class="label">Zwift unlock</span>
<span
class="value"
class:tone-ok={(device.unlockExpiresInS ?? 0) > 0}
class:tone-bad={(device.unlockExpiresInS ?? 0) <= 0}
>
<span class="dot"></span>
{#if (device.unlockExpiresInS ?? 0) > 0}
{Math.round((device.unlockExpiresInS ?? 0) / 3600)} h left
{:else}
Expired — re-unlock in Zwift
{/if}
</span>
</span>
{:else if device.batteryPct != null}
<span class="state">
<span class="label">Battery</span>
<span class="value tone-idle"><span class="dot"></span>{device.batteryPct}%</span>
</span>
{/if}
</div>
<div class="controls">
{#if isConnected(device)}
<button class="btn ghost" onclick={() => app.run(() => api.disconnect(device.id))}>
Disconnect
</button>
{:else}
<button class="btn" disabled={busy(device)} onclick={() => app.run(() => api.connect(device.id))}>
{busy(device) ? 'Connecting…' : 'Connect'}
</button>
{/if}
<button class="btn ghost danger" onclick={() => app.run(() => api.forget(device.id))}>
Forget
</button>
</div>
</article>
{/each}
{#if devices.length === 0}
<!-- A-4 / FR-1.8: absent is not the same as missing. -->
<div class="empty">
<h2>Nothing found yet</h2>
<p>Most devices sleep until you touch them. To wake them:</p>
<ul>
<li><strong>Trainer</strong> — turn the pedals for a few seconds.</li>
<li><strong>Zwift Click</strong> — press any button on the pod.</li>
<li><strong>Heart rate strap</strong> — wet the contacts and put it on.</li>
</ul>
<p class="quiet">
They will appear here as soon as they advertise. Scanning continues in the background.
</p>
</div>
{/if}
</div>
</div>
<style>
.screen {
display: grid;
grid-template-rows: auto auto 1fr;
height: 100%;
min-height: 0;
}
header {
display: flex;
align-items: flex-end;
gap: var(--gap);
padding: clamp(1.4rem, 3vw, 2.6rem) var(--edge) 1rem;
}
h1 {
margin: 0;
font-size: clamp(1.6rem, 2.6vw, 2.4rem);
font-weight: 300;
letter-spacing: -0.03em;
}
header p {
margin: 0.2rem 0 0;
color: var(--ink-dim);
font-size: 0.92rem;
}
.actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.gate {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0 var(--edge) 0.6rem;
padding: 0.7rem 0.9rem;
border-radius: 0.55rem;
background: rgba(255, 207, 74, 0.06);
color: var(--ink-soft);
font-size: 0.9rem;
}
.gate strong {
color: var(--warn);
font-weight: 600;
}
.list {
overflow-y: auto;
padding: 0.4rem var(--edge) 2rem;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1.5fr) auto minmax(0, 1.4fr) auto;
align-items: center;
gap: var(--gap);
padding: 1rem 0.25rem;
border-bottom: 1px solid var(--hairline);
}
.row.live .name {
color: var(--ink);
}
.identity {
display: flex;
flex-direction: column;
gap: 0.18rem;
min-width: 0;
}
.name {
font-size: 1.12rem;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
font-size: 0.8rem;
color: var(--ink-dim);
}
.error {
font-size: 0.82rem;
color: var(--bad);
}
.signal {
display: flex;
align-items: center;
gap: 0.45rem;
}
.bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 1.15rem;
}
.bars i {
width: 3px;
background: var(--ink-faint);
border-radius: 1px;
}
.bars i.on {
background: var(--ok);
}
.dbm {
font-size: 0.78rem;
color: var(--ink-dim);
min-width: 2.3em;
}
.states {
display: flex;
gap: var(--gap);
flex-wrap: wrap;
}
.state {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.state .value {
display: inline-flex;
align-items: center;
gap: 0.4em;
font-size: 0.92rem;
font-weight: 600;
white-space: nowrap;
}
.controls {
display: flex;
gap: 0.4rem;
justify-content: flex-end;
}
.empty {
max-width: 42rem;
margin: clamp(2rem, 8vh, 6rem) auto;
text-align: left;
color: var(--ink-soft);
}
.empty h2 {
margin: 0 0 0.5rem;
font-size: 1.6rem;
font-weight: 300;
letter-spacing: -0.02em;
color: var(--ink);
}
.empty ul {
margin: 0.6rem 0;
padding-left: 1.1rem;
line-height: 1.9;
}
.empty strong {
color: var(--ink);
font-weight: 600;
}
.quiet {
color: var(--ink-faint);
font-size: 0.88rem;
}
@media (max-width: 1000px) {
.row {
grid-template-columns: 1fr auto;
grid-template-areas:
'identity signal'
'states states'
'controls controls';
}
.identity {
grid-area: identity;
}
.signal {
grid-area: signal;
}
.states {
grid-area: states;
}
.controls {
grid-area: controls;
justify-content: flex-start;
}
}
</style>