New @trails-cool/ui workspace package needs its package.json in the
deps stage so pnpm install --frozen-lockfile can resolve the
workspace:* dependency. Fixes the Dockerfile Package Check and the
journal image build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Establish the visual-redesign foundation (task group 1): a single
shared source of design tokens both apps consume, so Planner and
Journal read from one palette/typography layer instead of drifting
per-app.
- New @trails-cool/ui package exporting theme.css: Tailwind v4 @theme
tokens (warm off-white surfaces, one sage accent, earthy overnight
tones, elevation-gradient colors, shadows) extracted from the
visual-redesign mockup.
- Self-hosted Outfit (body) + Geist Mono (stats) via @fontsource-variable
— privacy-first, no Google Fonts CDN.
- Both apps import the theme and set the base canvas to the warm
off-white token + Outfit as the default font.
Foundation only: tokens are now available as Tailwind utilities
(bg-bg-raised, text-accent, font-mono, …) and raw CSS vars. Wiring
them into specific surfaces (topbar, sidebar, markers, elevation
chart) is later task groups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refines #594: a fixed width made short notes an oversized box and wrapped
long notes early. Use `width: max-content` capped at `max-width: 280` so
the tooltip hugs short notes on one line and grows to 280px before wrapping
long ones (instead of collapsing to the longest word, the pre-#593 bug).
Keeps the centered text.
Verified in local dev with both a short ("Zug zurück?") and a long note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #593. With a long note the tooltip still squeezed to ~1 word
wide: an inline-block with only max-width, inside Leaflet's auto-width
tooltip, shrinks to its longest word rather than filling max-width.
Give it an explicit `width: 200` (a consistent, comfortably wide box that
wraps long notes cleanly) and `text-align: center`. Verified live in the
browser against a long repeated note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The note tooltip's inner span was `display: block` with
`word-break: break-word` and no width, so the Leaflet tooltip sized itself
to the block's *min-content* — which under break-word is ~1 character —
and every character wrapped onto its own line (a tall vertical strip).
Switch to `display: inline-block` (sizes to content up to maxWidth, so it
stays one line when short and wraps at 220px when long) and the standard
`overflow-wrap: break-word`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the map-core codec into route-data.ts so the Yjs session doc stores
the computed route compactly, without changing the routing-host
compute-once-and-share model.
- Codec: add encodeElevations/decodeElevations (delta+varint) so geometry
= encoded [lon,lat] polyline + elevation channel; precision bumped to
1e6 (~0.11 m) to preserve the router's 6-decimal output.
- writeComputedRoute: geometry stored once (encoded polyline + elevations,
no redundant geojson), road metadata run-length encoded.
- Dual-format reads: getCoordinates / readRoadMetadata / new readGeojson
transparently handle the new encoding AND legacy JSON docs (+ oldest
geojson-only) — no migration, no flag day; legacy docs re-encode on the
next recompute. use-elevation-data reads readGeojson (assembled for new
docs).
- Spec correction: a lossy compact codec can't be byte-identical to legacy
(generateGpx emits raw precision), so "GPX byte-compatible" is corrected
to "coordinates preserved within the router's ~0.1 m precision".
Tests: legacy-format read, size assertion (>4x smaller / round-trips),
elevation round-trip. map-core + planner typecheck/lint clean; full
pnpm test 11/11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task group 1 of planner-route-encoding. Framework-free `route-codec.ts`:
- encodePolyline/decodePolyline — fixed-precision (1e5) delta + zig-zag
varint + base64; lossless to ~1 m, version-tagged ("p1:").
- encodeRuns/decodeRuns — run-length codec for the per-coordinate
road-metadata channels, tagged ("r1:").
- isEncodedPolyline/isEncodedRuns — so the read path (group 3) tells the
new encoding apart from legacy JSON.
Not yet wired into route-data.ts (groups 2–3). Verified: map-core
typecheck + lint clean, 52/52 tests (round-trip within 1 m, empty/single,
RLE collapse, >3x smaller than JSON).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the planner WS reconnect-loop incident. Proposes compactly
encoding the computed route in the Yjs session doc (single-source geometry
+ delta/varint polyline + run-length-encoded road metadata, dual-format
backward-compatible reads) to cut a ~305 KB / 77 km route to ~40–60 KB —
WITHOUT changing the routing-host "compute once, share via Yjs" model or
GPX export output.
Artifacts: proposal, design, specs (new planner-route-encoding
capability), tasks. Validates --strict. Ready for /opsx:apply.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cd-apps's post-deploy `docker image prune` runs under `set -euo pipefail`
after the containers are already swapped, so when it collides with a
concurrent deploy/disk-maintenance prune ("Error response from daemon: a
prune operation is already running") it fails an otherwise-successful
deploy (observed on the #587 planner deploy). The cleanup is best-effort;
disk-maintenance.yml is the real image-prune safety net.
- cd-apps: `docker image prune -af || true`.
- disk-maintenance: tolerate the same collision on its prune; the disk-%
threshold check afterward stays the real failure gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Yjs sync WebSocket had MAX_MESSAGE_BYTES = 256 KB (per frame) but
MAX_DOC_BYTES = 5 MB (per session doc). A full-state sync frame carries
the entire doc, so any session doc between 256 KB and 5 MB was allowed to
exist yet could never sync: the client's sync frame tripped the 256 KB
per-frame cap, the server closed it (1008), the client reconnected, resent
the same oversized frame, and looped forever — the UI froze, the connection
flapped Verbunden/Verbinde, and the network tab filled with 101 reconnects.
Real routes hit this fast because the BRouter geometry is stored in the doc:
a 4-waypoint, 77 km cycling route was already 305 KB.
Fix: the per-frame cap must be >= the doc cap (a full-doc sync must fit in
one frame). Set MAX_MESSAGE_BYTES = MAX_DOC_BYTES + 256 KB overhead; the
5 MB doc cap stays the real size/abuse guard. Updated the test that had
codified the inverted ordering.
Verified: planner typecheck + lint clean, yjs-server 9/9, planner suite
183/183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR previews were built + deployed for every apps/packages PR — each one a
journal container + its own database on the flagship, which is a standing
contributor to the disk pressure behind recurring deploy failures and the
2026-07-14 disk-full outage.
Make them opt-in: a preview is built/deployed only when the PR carries the
`preview` label OR a `<!-- preview -->` marker in its description. Gate the
(costly) build-images job, deploy-preview, and — via new labeled/unlabeled
triggers — tear the preview down when the label is pulled, so a de-flagged
PR can't orphan its stack. Main-push / dispatch deploys are unchanged.
Note: the pull_request `paths` filter still applies, so labeling a PR that
touches no apps/packages files won't spin up a preview (nothing to preview).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every compose service used docker's default json-file driver with no
rotation, so a service logging in a loop grew unbounded — a 2.6 GB
container log contributed to a 2026-07-14 disk-full outage (/ at 100%,
Postgres down, apps 500ing, deploys failing at the SCP step).
Add an `x-logging` anchor (10 MB x 3 files = 30 MB max per container) and
apply it to all 11 services. Complements disk-maintenance.yml (which prunes
images but can't touch container logs). Takes effect when cd-infra recreates
the containers. Documented the disk-pressure runbook in docs/deployment.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task group 2 (part b) + verification of elevation-profile-hardening.
compute-days.ts no longer sums every raw point-to-point delta for
cumulative ascent/descent (which overstated day totals the same 20–50%).
It now despikes each segment, runs cumulativeFilteredTotals once over the
despiked track, and maps the running filtered ascent/descent back onto the
flat allPoints indices (carrying forward across ele-less points). Day
totals = cumulative[end] − cumulative[start] therefore match the filtered
route total by construction. DayStage shape + rounding unchanged.
Test: a two-day split over the shared noisy/spiky track reports filtered
(not inflated) per-day ascent, and the day ascents sum exactly to the
whole-route filtered ascent.
Completes the change (11/11). Verified: gpx 112/112; full pnpm typecheck
13/13, lint 13/13, test 11/11 (journal + planner compile unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task group 2 (part a) of elevation-profile-hardening — wire the group-1
primitives into the headline stat and the chart.
- computeElevation (parse.ts): despike each segment's elevation sequence,
then take filteredTotals; gain/loss are now noise-filtered (no more
20–50% inflation from per-point jitter), the profile is built from the
despiked data, and additive gainRaw/lossRaw expose the unfiltered sums.
- elevationSeries: despike per segment (no cross-trkseg interpolation)
before downsampling, so the chart and the headline totals derive from
the same cleaned data.
- types.ts: GpxData.elevation gains gainRaw/lossRaw (additive).
Well-formed monotonic climbs are unchanged (steps exceed the 5 m
threshold → filtered == raw); a shared noisy-track test proves jitter is
filtered and a 300 m single-point spike is interpolated out of both the
totals and the chart.
Verified: gpx 111/111; full pnpm typecheck 13/13, lint 13/13, test 11/11
(journal + planner compile with the additive fields).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task group 1 of elevation-profile-hardening. New pure module
elevation-clean.ts (not yet wired — group 2 does that):
- despike(points): interpolates out isolated peak/pit spikes (opposite-sign
slope outliers both exceeding MAX_SLOPE_PERCENT=100); steep monotonic
terrain (same-sign) is never touched; per-segment, no cross-gap interp.
- filteredTotals(points): hysteresis ascent/descent (NOISE_THRESHOLD_M=5)
that suppresses sub-threshold jitter, returning {gain,loss,gainRaw,lossRaw}.
- cumulativeFilteredTotals(points): running filtered arrays for per-day
splits (day total = cumulative[end] − cumulative[start]).
DESIGN NOTE for review: the spec's task 1.2 says the zero-filtered
fallback should "report raw", but the raw sum-of-deltas re-inflates the
very jitter we remove (a ±2 m wobble sums to several metres) — directly
contradicting task 1.4's "±2 m jitter → 0 totals". Resolved toward the
stated goal: fall back to NET change (end−start), so flat wobble reads ~0
while a genuine small net climb still surfaces. gainRaw/lossRaw still
expose the raw sums.
Verified: gpx typecheck+lint clean, 108/108; full pnpm test 11/11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task groups 5 and 6 of gpx-parser-robustness.
Group 5 — packages/gpx/fixtures/*.gpx (synthetic, no personal data):
route-only, missing-coords, garbage-ele, timestamps-partial,
timestamps-mostly-invalid, multi-track, unicode-name,
namespaced-extensions, plus trimmed Komoot/Wahoo-style exports.
parse-node.test.ts gains a fixture table test that auto-discovers every
*.gpx from disk, asserts finite stats + per-fixture shape, and fails
loudly if a file has no EXPECTATIONS entry (the corpus is the regression
mechanism).
Group 6 — verification: GpxData shape unchanged; journal + planner
compile with no changes; gpx suite 98/98; full pnpm typecheck 13/13,
lint 13/13, test 11/11. Route-only + broken-file import behaviour is
covered by the fixtures + consumer compile; the browser leg rides e2e.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task group 4 of gpx-parser-robustness. Many apps put the only
human-readable title on the <trk>/<rte>, not in <metadata>.
- name/description now fall back to the first <trk>/<rte>'s <name>/<desc>
when <metadata> lacks them (<metadata> still wins when present).
- that track/route's <cmt> is appended to the description (blank-line
separated) when present and not already identical (OM BuildDescription
dedup); used as the description outright when there's no <desc>.
`directChildText` reads direct-child text without relying on `:scope`
(portable across the jsdom + linkedom paths). No GpxData shape change;
well-formed files with metadata are unaffected.
Verified: gpx typecheck+lint clean, 87/87; full `pnpm test` 11/11;
journal + planner typecheck unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI "Unit Tests" (full monorepo) caught that @trails-cool/fit's round-trip
fixture loop included single-point.gpx, which now parses to zero track
points (the parser drops <2-point segments), so gpxToFitCourse correctly
throws its zero-points guard. Drop single-point.gpx from the round-trip
loop and cover the degenerate case explicitly: a lone point → zero points
→ refused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task group 3 of gpx-parser-robustness. Timestamps were passed through raw;
a partially-broken time channel from a flaky recorder silently shrank
moving time and skewed start-time derivation.
New pure `timestamp-repair.ts` applied per segment after parsing:
- validity = Date.parse yields a finite epoch;
- a segment with no valid timestamps is left untouched (untimed track);
- >50% invalid → drop all the segment's timestamps (noise, not signal);
- otherwise → linearly interpolate invalid runs between valid neighbours
(by point index), leading/trailing runs clamp to the nearest valid
timestamp; only repaired points are rewritten as ISO 8601, valid points
keep their original string.
Monotonicity intentionally not enforced (movingTime already skips
non-positive intervals; reordering would be fabrication). No GpxData shape
change. Wired into parseTracks output in parse.ts.
Verified: gpx typecheck + lint clean, 81/81 tests pass, journal + planner
typecheck unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task groups 1–2 of gpx-parser-robustness. The parser trusted its input:
`parseFloat(attr ?? "0")` turned a missing lat/lon into a 0,0 Null Island
point (which passes range validation) and garbage into NaN that poisoned
distance and gain/loss totals; `<rte>`/`rtept` files (Garmin courses, many
exporters) parsed to zero track points and were rejected.
- `parsePoint`: skip a trkpt/rtept whose lat/lon is missing or non-finite
(no more 0,0 default); a non-finite `<ele>` becomes `undefined` so it
never leaks NaN into totals. Parsing stays parseFloat-lenient (trailing
junk like `471.0m` still accepted), gated by Number.isFinite.
- Drop segments left with fewer than 2 points (render nothing / break
distance math).
- Parse `<rte>` as track segments appended after `<trk>` segments, rtept
handled identically — route-only files now import.
No GpxData shape change; well-formed files parse identically. Updated the
geom single-point test to the new drop-invariant.
Verified: gpx typecheck + lint clean, 76/76 tests pass, journal + planner
typecheck unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All 13 tasks complete (implementation shipped in #570–573; staging
verification in #574). Archive via `openspec archive` (CLI 1.6.0):
- Creates openspec/specs/federation-operations/spec.md — the new
capability: durable federation queue, inbound replay defense, instance
blocklist, published protocol doc, delivery observability (Purpose
filled in; CLI leaves a TBD placeholder).
- Applies the MODIFIED requirement to openspec/specs/social-federation:
"Push delivery on local activity create" now guarantees persistent
queueing + a "Fan-out survives a deploy" scenario.
- Moves the change to openspec/changes/archive/2026-07-13-federation-hardening/.
Both specs pass `openspec validate --strict`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ran the post-deploy staging check on staging.trails.cool:
- Durability: enqueued a real delivery, restarted the journal mid-flight;
the job survived the restart (still `created` in Postgres afterward)
and completed after — "Successfully sent activity … to
social.ullrich.is/users/ullrich/inbox", federation_delivery_total
{outcome="delivered"}=1, queue drained to 0. The old in-process queue
would have dropped it.
- Blocklist outbound: a poll-remote-actor for a blocked domain returned
{skipped: "blocked instance"} with no network fetch.
- Operator block/unblock procedure works against the live staging DB.
Inbound-drop leg is covered by the group-2/3 real-Postgres integration
tests + the two-instance e2e harness (firing it live needs a
peer-initiated signed request). All 13 tasks now complete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>