Compare commits

..

1 commit

Author SHA1 Message Date
Ullrich Schäfer
6a2f0bf089 Drop has-public-content gate from profile loader
A public profile (`profile_visibility = 'public'`) with zero public
routes or activities now returns 200 and renders the empty profile
shell instead of 404. The earlier change kept the legacy "AND has
public content" gate to preserve a no-existence-leak guarantee, but
that conflated the implicit behavior with the new explicit setting.
With profile_visibility now a deliberate toggle, the cleaner contract
is: visibility = public means the profile renders, period.

Followability already only depends on profile_visibility, so this
brings the public-page contract in line with that. The `private`
toggle remains the way to hide a profile from visitors.

Updates social-feed change spec + design to match (drops the
"AND has at least one public" predicate and the matching 404
scenario; adds an explicit "empty public profile renders an empty
shell" scenario).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:11:46 +02:00
940 changed files with 12989 additions and 56647 deletions

View file

@ -1,275 +0,0 @@
---
name: ia-review
description: Take a snapshot of the apps' information architecture (sitemap, navigation, audience-gating per route) and write or refresh `docs/information-architecture.md`. Use when the user wants to review the IA, plan a navigation/surface redesign, or check for drift since the last review.
license: MIT
metadata:
author: trails.cool
version: "1.0"
---
Walk the apps' route table + navigation surfaces, build a sitemap, surface
tensions and open questions, and capture the result in
`docs/information-architecture.md` (fresh write or refresh against the
prior snapshot).
The output is a *review document for the user* — not a unilateral plan.
Decisions are made by the user during the conversation that follows; the
doc captures the snapshot and the open questions that need answering.
---
## When to use
- The user explicitly asks for an IA review ("review the IA", "what's
the information architecture look like").
- Before a navigation/surface redesign, so the redesign is informed by
a current snapshot rather than a vibe.
- After a chunk of new features — pages, modals, settings sections —
to check whether the IA is drifting (busy navbar, duplicated surfaces,
orphaned routes).
- When the user says "we should do another IA review" after the prior
doc has aged.
---
## Steps
1. **Detect mode (fresh vs refresh)**
Check whether `docs/information-architecture.md` already exists.
- **No file:** fresh review. Build the snapshot from scratch.
- **File exists:** refresh review. Read it first; preserve the
decisions/backlog the user has accumulated and only update the
snapshot sections (sitemap, navigation, observations, open
questions). Decisions previously crossed out stay crossed out.
In refresh mode, also read the snapshot date at the top — anything
shipped *since* that date is what your refresh should focus on.
2. **Identify the apps in scope**
trails.cool ships two front-ends; the IA question lives mostly in
the Journal:
- `apps/journal/` — user accounts, social, content. Main IA
surface.
- `apps/planner/` — anonymous, ephemeral. ~5 routes; include for
completeness but don't dwell.
If the project structure has changed and there's a new app, include
it.
3. **Read the route tables**
For each app:
```
apps/<app>/app/routes.ts
```
This is the authoritative URL → route-file mapping. Both apps use
explicit registration (per CLAUDE.md), so `routes.ts` is complete.
4. **Read the navigation surfaces**
- `apps/journal/app/root.tsx` (and `apps/planner/app/root.tsx` if
it has navigation) — the top navbar lives here. Read both the
loader (to see what data the navbar consumes — counts, badges,
user fields) and the `NavBar` component (to see what entries
render).
- `apps/journal/app/components/Footer.tsx` — the footer.
- Any auth-gate / Terms-gate logic in the root loader.
5. **Sample key route loaders to understand audience**
For each top-level route, scan its loader to determine:
- Does it require a session? (loaders typically `redirect("/auth/login")`
for anonymous visitors when so.)
- Does it serve different content per session? (e.g., `home.tsx`
branches on `user`.)
- Does it have an access rule beyond auth? (locked-account 404s,
visibility checks, etc.)
You don't need to read every route — pick the top-level ones and
any that look like they might gate differently than the URL hints.
6. **Build the sitemap**
Group routes by audience: **Public surface** (anonymous-reachable)
and **Authenticated surface** (signed-in only). Within each group,
order by topic (auth, profile, content, settings, legal, etc.).
Use plain code blocks with one URL per line and a one-line gloss
per entry. Keep the format scannable; don't repeat what the URL
already says.
7. **Map navigation surfaces**
Two short tables/snippets:
- **Navbar (signed-in)** — entries left-to-right.
- **Navbar (signed-out)** — entries left-to-right.
- **Footer** — links + any meta text.
Note any entry whose visibility is conditional (badge counts, etc.).
8. **Identify "feed concepts" and other duplications**
trails.cool has historically had multiple feed-like surfaces. Any
IA review should ask: how many lists of activities are there? Is
the same data reachable from multiple URLs? Is there a URL that
shows different products to different audiences?
Capture these in a small table or section if they exist.
9. **Map cross-app linking**
Journal ↔ Planner cross-links (JWT callback URLs, "Try the
Planner" buttons, etc.). One short list.
10. **List observations**
Walk the snapshot and call out tensions worth discussing. Useful
prompts:
- **Busy clusters** — three or more controls for the same concept
side-by-side in the navbar.
- **Redundant paths** — same destination reachable from multiple
surfaces with no clear reason.
- **Dead-end routes** — pages reachable only by typing the URL,
no in-app link.
- **Missing surfaces** — common user need with no in-app path
(e.g., "find people to follow" with no `/explore`).
- **Audience mismatch** — same URL serving meaningfully different
products to anon vs auth.
- **Visual inconsistency** — adjacent navbar entries with
different treatment (icon vs text, different baselines).
- **Mobile hazards** — clusters that will wrap badly under
small viewport widths.
Each observation should be one short paragraph. Each is a
question for the user to answer, not a decision you've made.
11. **List open IA questions**
Distinct from observations: these are larger directional choices
where the answer determines what other observations even matter.
Examples: "Should `/` and `/feed` merge for signed-in users?",
"Where does an `/explore` page live, if at all?", "Mobile
pattern — hamburger? Bottom tab bar?"
Keep these as bullets the user can answer in one line each.
12. **Write the doc**
Output to `docs/information-architecture.md`. Use this top
structure:
```markdown
# Information Architecture Review
*Snapshot date: YYYY-MM-DD.* If the navbar, route table, or feed
model has shifted since then, treat this doc as stale and refresh
against `apps/journal/app/routes.ts` + `apps/journal/app/root.tsx`.
A snapshot of where every page lives, who sees it, and how visitors
navigate between them. Intended for review — flag anything that
doesn't make sense or should change.
## Apps
[...]
## Journal sitemap
### Public surface (logged-out)
[...]
### Authenticated surface (logged-in)
[...]
### Navigation surfaces
[...]
## Logged-in vs logged-out home
[...if `/` does double duty...]
## [Any "N feed concepts" / duplication sections]
[...]
## Cross-app linking
[...]
## Planner sitemap
[...short...]
## Observations worth discussing
[...]
## Open IA questions
[...]
```
Use today's date for the snapshot. Reference the source-of-truth
files at the top so the next review knows what to compare against.
13. **In refresh mode, preserve the user's accumulated decisions**
The prior doc may already contain:
- Resolved observations (struck through with a *Resolved: ...*
note).
- An "Implementation backlog" section with streams.
- An "Open exploration" section.
These are the *user's work*, not the snapshot. Carry them forward
untouched unless one is plainly obsolete (e.g., the feature it
references no longer exists). When in doubt, leave it and let the
user prune.
If a previously-flagged observation is no longer present in the
current code (e.g., it was implemented), update its status note
rather than removing it — preserves history.
14. **Surface a ranked next-action list**
After writing the doc, summarize in 46 lines what changed since
the prior review (or what the most actionable observations are if
fresh). End with a question: which open IA question does the user
want to tackle first?
Don't start implementation work — this skill is for the
*snapshot*. The decisions and the implementation backlog grow
through the conversation that follows.
---
## What this skill is NOT
- **Not an implementation skill.** Don't write code, don't open PRs.
The doc is the deliverable; decisions and code follow in normal
conversation.
- **Not a unilateral redesign.** Observations are questions for the
user. Don't bake "decisions" into the snapshot — those go in the
backlog only when the user has actually answered the question.
- **Not a spec change.** OpenSpec specs describe what's shipped; this
doc describes the IA *as it stands* with tensions flagged. Any
resulting spec updates happen during implementation, not during the
review.
---
## Guardrails
- Always include the snapshot date at the top of the output doc — IA
drifts; future-you needs to know whether to trust the snapshot or
refresh it.
- Reference the source-of-truth files (`routes.ts`, `root.tsx`) so the
next review's diff is mechanical.
- Keep observations as questions, not decrees. The user makes the
call.
- In refresh mode, preserve the user's accumulated decisions verbatim.
Only the snapshot sections are yours to rewrite.
- Don't invent routes or features. If you can't find evidence for it
in the code, don't put it in the snapshot.
- Keep it scannable. The doc is for review; verbose explanations bury
the signal.

View file

@ -1,216 +0,0 @@
---
name: spec-drift-review
description: Walk every spec in `openspec/specs/`, compare it to the shipped code, and produce a categorized drift report (high/medium/low severity per spec, plus code-without-spec findings and structural suggestions). Use when the user wants to check spec drift, after a chunk of features has shipped, or when planning a spec catch-up PR.
license: MIT
metadata:
author: trails.cool
version: "1.0"
---
Walk the specs directory + the shipped code, compare them claim by
claim, and produce a structured drift report. The report is the
deliverable; fixes happen in a follow-up PR after the user has reviewed
the findings.
The goal is to keep `openspec/specs/` honest — specs are useless if they
don't describe the actual product, and worse than useless if they
contradict it.
---
## When to use
- The user explicitly asks to check spec drift ("are the specs in sync",
"review specs against code").
- After a multi-feature chunk has shipped without per-feature spec
promotion — drift accumulates fastest in catch-up phases.
- Before reorganizing the specs directory (split / merge / rename).
- When a spec contradicts the codebase and you're not sure which is
right.
---
## Steps
1. **Get the lay of the land**
Run these in parallel:
```bash
ls openspec/specs/
ls openspec/changes/ # in-flight work — NOT drift
cat openspec/CAPABILITIES.md # if it exists, it's the index
openspec list --json # any active changes that explain drift
```
**Important:** any spec referenced in an active openspec change is
*expected* to drift from current code — that drift is the work in
progress. Note these and exclude them from the report.
2. **Identify the code-side anchors per spec**
For each spec at `openspec/specs/<capability>/spec.md`, find the
primary code locations that implement it. Most capability specs map
to one or more of:
- **Routes:** `apps/journal/app/routes/*.tsx` / `*.ts`
authoritative for URL behavior, redirects, access gating.
- **Server lib:** `apps/journal/app/lib/<topic>.server.ts` — most
business logic.
- **Schema:** `packages/db/src/schema/journal.ts` — table shapes,
visibility values, defaults, indexes.
- **i18n:** `packages/i18n/src/locales/{en,de}.ts` — user-facing
strings, often telling.
- **Tests:** integration tests are a great oracle for the *intended*
behavior; mismatch with the spec usually means the spec is stale.
Don't read every file. Pick the 13 anchors per spec that the
requirements most plausibly map to.
3. **Compare claim by claim**
For each requirement in a spec, ask:
- **Does the code do this?** If not, is it because the requirement
was retired or because it was never shipped?
- **Does the code do *more* than this?** New scenarios shipped
without a spec update.
- **Does the code do this *differently*?** Different URL,
different default, different status code, different rule.
- **Does this requirement reference a name that no longer exists?**
Renamed routes, deleted helpers, removed tables.
Track findings with severity:
| Severity | What it means |
|----------|---------------|
| **High** | Spec actively misleads — claims a behavior the code does not exhibit. A reader implementing against the spec would write the wrong code. |
| **Medium** | Spec is incomplete — code has scenarios the spec doesn't describe. Reader gets less information than they should but isn't actively misled. |
| **Low** | Wording drift — comments/cross-refs mention a renamed thing, but the requirement statements are still accurate. |
4. **Find code-without-spec**
Walk the route tree in `apps/journal/app/routes.ts` and the topic
files in `apps/journal/app/lib/`. For each top-level concept ask:
- Is this concept covered by a spec?
- If yes, does the spec mention this surface?
- If no, should it be?
Genuine "no spec" cases are usually: new feature shipped without
spec promotion, or piece of infrastructure deemed too internal for a
spec. Both are valid; flag the former, leave the latter alone.
5. **Structural review**
Spec organization itself can drift:
- **Specs that grew too big** — multiple unrelated requirements
under one spec. Candidates for a split (we previously split
`account-settings` into `profile-settings`, `account-management`,
`connected-services`).
- **Specs that overlap** — the same requirement appears in two
specs, or one spec keeps cross-referencing another. Candidate for
a merge or a clearer ownership boundary.
- **Specs that should exist but don't** — a capability is shipped
and substantial enough to merit its own spec but is currently
squeezed into another. (We added `sse-broker` and `notifications`
this way.)
- **`CAPABILITIES.md` drift** — if the index exists, check that
every spec dir has an entry and every entry points at a real
spec. The index is the easy thing to forget when adding a spec.
6. **Compose the report**
Output to the conversation as a markdown structure. Don't write a
doc unless the report is unusually large and the user asks for one —
most drift reports get acted on inside a single PR and don't need a
long-lived artifact.
Suggested structure:
```markdown
# Spec drift review — YYYY-MM-DD
**Active changes excluded from this review:**
- <name> (touches: <specs>)
## High-severity drift
### `<spec-name>`
- **Requirement: <name>** says <X>; code at `<file:line>` does <Y>.
[link / one-line action]
- …
## Medium-severity drift
### `<spec-name>`
- <description + code anchor>
- …
## Low-severity drift (wording / cross-refs)
- `<spec-name>`: <description>
- …
## Code-without-spec
- <feature> at `<file>` — should this be in <spec-name>, or a new
spec? Recommendation: <…>
## Structural suggestions
- Split: <spec-name><new-1>, <new-2>
- Merge: <spec-a> + <spec-b><new>
- New spec: <name> covering <area>
- `CAPABILITIES.md` updates: <list>
```
7. **Propose next actions, then stop**
End the review with three concrete options the user can pick from:
- **Ship a catch-up PR for everything** — works when drift is
mostly low/medium and the fix is mechanical.
- **Fix high-severity first, defer the rest** — works when high
items are urgent and the rest can wait for natural per-feature
spec updates.
- **Restructure first, then catch up** — works when structural
suggestions (split / merge / new spec) are large enough that
fixing claims inside the wrong spec shape would just have to be
redone.
Don't pick for them. Don't start fixing yet.
---
## What this skill is NOT
- **Not a fixer.** This skill produces a report. Apply happens after
the user has decided which findings to act on.
- **Not a CI check.** It's interactive — judgment calls (severity,
splits, merges) are part of the value, not an automation target.
- **Not for in-flight work.** Active openspec changes legitimately
cause spec/code mismatch; flag them in the "excluded" section and
move on.
- **Not a code review.** The question is "does the spec match the
code", not "is the code good." Code-quality observations belong in
PR review, not here.
---
## Guardrails
- Always start by reading active openspec changes — drift caused by
in-flight work is not drift.
- Don't write spec edits during the review. The user picks which
findings to act on; edits happen after.
- When code and spec disagree, the prevailing rule on this project is
**code is source of truth** unless the user says otherwise. Surface
the conflict; don't preemptively decide.
- Skip generated/scaffold files (e.g. `.react-router/types/**`) — they
are derived, not product.
- Don't pad the report with low-severity wording drift unless the user
asks for it. Concentrate signal.
- Keep observations specific (file + line + claim), not abstract. A
finding without an anchor isn't actionable.

View file

@ -1,15 +1,12 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -29,7 +26,6 @@ Implement tasks from an OpenSpec change.
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
@ -39,7 +35,7 @@ Implement tasks from an OpenSpec change.
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Context file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
@ -51,7 +47,7 @@ Implement tasks from an OpenSpec change.
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output

View file

@ -1,15 +1,12 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -29,7 +26,6 @@ Archive a completed change in the experimental workflow.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
@ -52,7 +48,7 @@ Archive a completed change in the experimental workflow.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
@ -67,19 +63,19 @@ Archive a completed change in the experimental workflow.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
Create the archive directory if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
- If no: Move the change directory to archive
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
@ -98,7 +94,7 @@ Archive a completed change in the experimental workflow.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
@ -111,7 +107,7 @@ All artifacts complete. All tasks complete.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
@ -124,7 +120,7 @@ All artifacts complete. All tasks complete.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
@ -141,7 +137,7 @@ Review the archive if this was not intentional.
## Archive Failed
**Change:** <change-name>
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.

View file

@ -1,7 +1,6 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
@ -12,8 +11,6 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
@ -62,10 +59,10 @@ Depending on what the user brings, you might:
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
┌────────┐ ┌────────┐ │
│ State │────────▶│ State │ │
│ A │ │ B │ │
└────────┘ └────────┘ │
│ ┌────────┐ ┌────────┐
│ │ State │────────▶│ State │
│ │ A │ │ B │
│ └────────┘ └────────┘
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
@ -110,10 +107,11 @@ Think freely. When insights crystallize, you might offer:
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
@ -121,14 +119,14 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"

View file

@ -1,7 +1,6 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---
@ -17,8 +16,6 @@ When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
@ -36,7 +33,7 @@ When ready to implement, run /opsx:apply
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
@ -45,7 +42,6 @@ When ready to implement, run /opsx:apply
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
@ -63,10 +59,10 @@ When ready to implement, run /opsx:apply
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

View file

@ -1 +0,0 @@
../.agents/skills

View file

@ -1,19 +1,16 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
generatedBy: "1.2.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -33,7 +30,6 @@ Implement tasks from an OpenSpec change.
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
@ -43,7 +39,7 @@ Implement tasks from an OpenSpec change.
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
@ -55,7 +51,7 @@ Implement tasks from an OpenSpec change.
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output

View file

@ -1,19 +1,16 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
generatedBy: "1.2.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -33,7 +30,6 @@ Archive a completed change in the experimental workflow.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
@ -56,7 +52,7 @@ Archive a completed change in the experimental workflow.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
@ -71,19 +67,19 @@ Archive a completed change in the experimental workflow.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
Create the archive directory if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
- If no: Move the change directory to archive
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
@ -102,7 +98,7 @@ Archive a completed change in the experimental workflow.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.

View file

@ -1,13 +1,12 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
generatedBy: "1.2.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
@ -16,8 +15,6 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## The Stance
@ -59,10 +56,10 @@ Depending on what the user brings, you might:
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
┌────────┐ ┌────────┐ │
│ State │────────▶│ State │ │
│ A │ │ B │ │
└────────┘ └────────┘ │
│ ┌────────┐ ┌────────┐
│ │ State │────────▶│ State │
│ │ A │ │ B │
│ └────────┘ └────────┘
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
@ -105,10 +102,11 @@ Think freely. When insights crystallize, you might offer:
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
@ -116,14 +114,14 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
@ -229,7 +227,7 @@ User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
CLI TOOL DATA STORAGE │
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:

View file

@ -1,13 +1,12 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
generatedBy: "1.2.0"
---
Propose a new change - create the change and generate all artifacts in one step.
@ -21,8 +20,6 @@ When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
@ -40,7 +37,7 @@ When ready to implement, run /opsx:apply
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
@ -49,7 +46,6 @@ When ready to implement, run /opsx:apply
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
@ -67,10 +63,10 @@ When ready to implement, run /opsx:apply
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

View file

@ -1,14 +0,0 @@
# Keep Docker build contexts small and hermetic. CI builds from a clean
# checkout so this mostly matters for local builds (e2e/federation
# harness), where node_modules would otherwise bloat the context to
# gigabytes — and worse, `COPY . .` would overlay host-installed
# node_modules over the image's own pnpm install.
**/node_modules
**/.turbo
**/build
**/.react-router
.git
e2e/results
playwright-report
test-results
**/*.log

View file

@ -1,10 +0,0 @@
# Root-level local dev defaults.
# Copy to `.env.development` (gitignored) to override.
# All values here work out of the box — no changes needed for standard local dev.
DATABASE_URL=postgres://trails:trails@localhost:5432/trails
BROUTER_URL=http://localhost:17777
# Change these for any environment that is not purely local.
JWT_SECRET=dev-secret-not-for-production
SESSION_SECRET=dev-secret-not-for-production

View file

@ -20,22 +20,10 @@ updates:
update-types: ["version-update:semver-major"]
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
# These packages are version-pinned by the Expo SDK (see
# expo/bundledNativeModules.json) — upgrade them via an Expo SDK
# bump / `npx expo install --fix`, never on their own. Dependabot
# bumping them past the SDK's expected version broke the native
# build (expo-modules-core macro mismatch, June 2026) because CI
# never compiles native code. `react` stays unignored: the web
# apps own its version via the workspace catalog, and apps/mobile
# excludes it from expo version checks.
# react-native is pinned by the Expo SDK — upgrade it via an Expo
# SDK bump, not on its own. Community react-native-* libraries are
# intentionally NOT ignored here; they can be bumped independently.
- dependency-name: "react-native"
- dependency-name: "react-native-gesture-handler"
- dependency-name: "react-native-reanimated"
- dependency-name: "react-native-safe-area-context"
- dependency-name: "react-native-screens"
- dependency-name: "react-native-worklets"
- dependency-name: "@sentry/react-native"
- dependency-name: "jest-expo"
- package-ecosystem: "github-actions"
directory: "/"

View file

@ -13,15 +13,6 @@ concurrency:
group: deploy-apps
cancel-in-progress: true
# Public Sentry DSNs for the trails.cool flagship instance. Public by
# design — Sentry DSNs are transmitted unencrypted from the client JS
# bundle, embedding them in this workflow is no worse than embedding
# them in the runtime env. Self-hosted forks should either replace
# these with their own DSNs or remove the lines to ship without Sentry.
env:
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
jobs:
build-images:
name: Build & Push Docker Images
@ -34,7 +25,7 @@ jobs:
matrix:
app: [journal, planner]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: docker/login-action@v4
with:
@ -57,12 +48,8 @@ jobs:
tags: |
ghcr.io/trails-cool/${{ matrix.app }}:latest
ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }}
# VITE_SENTRY_DSN bakes the client-side DSN into the journal's
# built bundle. Only journal has a client Sentry init; planner
# ignores the build-arg if present.
build-args: |
SENTRY_RELEASE=${{ github.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token
@ -72,7 +59,7 @@ jobs:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Decrypt secrets
run: |
@ -83,16 +70,6 @@ jobs:
echo "DOMAIN=trails.cool" >> infrastructure/app.env
# Flagship marker — see cd-infra.yml for what this gates.
echo "IS_FLAGSHIP=true" >> infrastructure/app.env
# Federation on (social-federation rollout 12.5, flipped
# 2026-06-07 after the staging + Mastodon soak). The
# FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env
# decrypted above. Rollback: delete these two lines, merge,
# rerun cd-apps — instant off, federation surfaces 404.
echo "FEDERATION_ENABLED=true" >> infrastructure/app.env
echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env
# Sentry DSNs (public — see workflow top-level env for context).
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env
- name: Copy files to server
uses: appleboy/scp-action@v1
@ -100,7 +77,7 @@ jobs:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/caddy"
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile"
target: /opt/trails-cool
strip_components: 1
@ -121,10 +98,6 @@ jobs:
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
# Abort the deploy on the first failure. Without this, a failed
# schema push deploys new code against an old schema (the
# 2026-06-06 schema-drift incident).
set -euo pipefail
cd /opt/trails-cool
# Login to ghcr.io
@ -133,69 +106,17 @@ jobs:
# Pull and deploy app containers
docker compose --env-file app.env pull journal planner
# Hand-written data migrations (idempotent) run BEFORE drizzle-kit
# push so unique-key reshapes can collapse duplicate rows first.
docker compose --env-file app.env run --rm journal node --experimental-strip-types /app/packages/db/src/migrate-data.ts
# drizzle-kit exits 0 even when it aborts on an interactive
# prompt it can't show (no TTY in CI) — that exact lie hid a
# month of staging schema drift. Treat any Error in its output
# as a failed deploy.
docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
if grep -q "Error:" /tmp/drizzle-push.log; then
echo "drizzle-kit push reported an error — failing the deploy"
exit 1
fi
docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
# --remove-orphans cleans up containers whose service was deleted
# from the compose file, matching cd-infra's behaviour.
docker compose --env-file app.env up -d --remove-orphans journal planner
# Gate on container health: a deploy that leaves journal or
# planner unhealthy must fail loudly, not report green.
for svc in journal planner; do
for i in $(seq 1 24); do
status=$(docker inspect -f '{{.State.Health.Status}}' "trails-cool-$svc-1" 2>/dev/null || echo missing)
[ "$status" = "healthy" ] && break
sleep 5
done
if [ "$status" != "healthy" ]; then
echo "$svc did not become healthy (last status: $status)"
docker compose --env-file app.env logs "$svc" --tail 50 || true
exit 1
fi
done
# Reload Caddy with the Caddyfile we just scp'd. cd-apps
# ships infrastructure/Caddyfile alongside docker-compose.yml
# (see scp step above), but containers don't auto-pick-up
# config changes. cd-infra reloads Caddy as part of its
# deploy; cd-apps did NOT, which meant any Caddyfile change
# touching only `apps/`/`packages/` paths sat on disk
# unapplied until the next cd-infra run. The reload is
# idempotent (Caddy validates first, swaps live, no
# downtime) so doing it on every cd-apps deploy is safe
# even when Caddyfile is unchanged. `|| true` keeps the
# deploy from failing if Caddy itself is unhealthy.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
# Clean up (best-effort). This runs AFTER the containers are
# swapped + Caddy reloaded, so the deploy already succeeded —
# a transient "a prune operation is already running" collision
# with a concurrent deploy/disk-maintenance prune must NOT fail
# an otherwise-green deploy. disk-maintenance.yml is the real
# image-prune safety net.
docker image prune -af || true
# Clean up
docker image prune -af
docker compose ps
# Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives
# in secrets.infra.env (decrypted by cd-infra.yml into the
# merged /opt/trails-cool/.env on the server). cd-apps's
# own app.env intentionally does NOT carry it — apps don't
# need it at runtime. So we read from the merged `.env`
# that cd-infra populated. If cd-infra has never run on
# this host, .env may not exist; the `2>/dev/null` and
# the `if -n` guard make the annotation a silent no-op
# rather than a deploy failure in that case.
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-)
# Annotate deploy in Grafana
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2- 2>/dev/null)
if [ -n "$GRAFANA_TOKEN" ]; then
docker compose exec -T grafana curl -sf -X POST \
-H "Authorization: Bearer $GRAFANA_TOKEN" \

View file

@ -20,7 +20,7 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: docker/login-action@v4
with:
@ -42,7 +42,7 @@ jobs:
runs-on: ubuntu-latest
environment: infra
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Decrypt shared secret
id: decrypt
@ -87,7 +87,6 @@ jobs:
TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \
docker-compose.yml Caddyfile promtail-config.yml \
download-segments.sh .env \
poi-extract \
| base64 -w0)
# One SSH session: untar, (re)start containers, report status
@ -100,7 +99,7 @@ jobs:
set -euo pipefail
mkdir -p ~/brouter && cd ~/brouter
echo "$TARBALL_B64" | base64 -d | tar -xzf -
chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py
chmod +x download-segments.sh
# Segment seeding is a one-shot operator task (~10 GB, a few
# minutes). CD must not rerun it on every deploy.

View file

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
environment: infra
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Decrypt secrets
run: |
@ -43,7 +43,7 @@ jobs:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/caddy,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards,infrastructure/scripts"
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards"
target: /opt/trails-cool
strip_components: 1
@ -64,11 +64,6 @@ jobs:
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
# Abort on first failure. The 2026-06-06/07 outage: a network
# recreation stopped postgres, a later step failed, and the
# deploy left production down for ~9h while the job's partial
# progress looked plausible. Fail fast, verify health at the end.
set -euo pipefail
cd /opt/trails-cool
# .env was placed by the SCP step (decrypted app + infra secrets)
@ -84,77 +79,20 @@ jobs:
docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true
fi
# Capture whether prometheus was recreated. A fresh container already
# reads the new config on startup; sending it SIGHUP immediately can
# kill Prometheus 3.10 during early boot (exit 2 observed on
# 2026-06-09).
PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
# Full restart: gh workflow run cd-infra.yml -f restart_all=true
if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then
docker compose --env-file .env up -d --remove-orphans
else
# Restart infra services (config reloads handled below).
# Restart infra services (except Caddy — just reload its config).
# --remove-orphans cleans up containers whose service was deleted
# from the compose file (e.g., the flagship `brouter` removal in
# PR #297 left an orphan that had to be removed by hand).
docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
fi
PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
# Apply config-only changes that `up -d` skips — it recreates a
# container only when its compose *definition* changes, not when a
# mounted config file's content changes. The configs are mounted as
# directories (not single files), so a reload/restart re-reads the
# freshly scp'd file; a single-file mount would have pinned the old
# inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when
# the same container stayed up; a recreated container already loaded
# the new config on startup. Loki and Promtail reload their main
# config only on restart; Caddy reloads gracefully (validates, swaps
# live, no downtime).
if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then
docker compose --env-file .env kill -s SIGHUP prometheus
fi
docker compose --env-file .env restart loki promtail
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
docker compose ps
# Gate on the stack actually being up: postgres healthy, journal
# back to healthy after the DB bounce, and Prometheus answering
# /-/ready. A deploy that leaves any of them down must fail loudly
# (see the 2026-06-06/07 outage, plus the 2026-06-09 Prometheus
# startup/HUP regression).
for ctr in trails-cool-postgres-1 trails-cool-journal-1; do
for i in $(seq 1 36); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing)
[ "$status" = "healthy" ] && break
sleep 5
done
if [ "$status" != "healthy" ]; then
echo "$ctr did not become healthy (last status: $status)"
exit 1
fi
done
PROMETHEUS_READY=
for i in $(seq 1 36); do
prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing)
if [ "$prom_status" = "running" ]; then
prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1)
if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then
PROMETHEUS_READY=1
break
fi
fi
sleep 5
done
if [ -z "$PROMETHEUS_READY" ]; then
echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)"
exit 1
fi
# Annotate deploy in Grafana
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-)
if [ -n "$GRAFANA_TOKEN" ]; then

View file

@ -1,520 +0,0 @@
name: CD Staging
# Builds, deploys, and tears down the persistent staging stack and per-PR
# preview environments. See `openspec/changes/staging-environments/` for the
# design (decisions on shared Postgres, port allocation, per-PR Caddyfile
# snippets) and CLAUDE.md "Staging & Previews" for the operator-facing view.
on:
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
# Also redeploy when the staging plumbing itself changes, so a port
# bump or compose edit doesn't sit unapplied until the next apps/ push.
- "infrastructure/docker-compose.staging.yml"
- ".github/workflows/cd-staging.yml"
pull_request:
# `labeled`/`unlabeled` so toggling the `preview` label on an existing PR
# starts / tears down its preview (see the opt-in gate on the jobs below).
types: [opened, synchronize, reopened, closed, labeled, unlabeled]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
workflow_dispatch: {}
# Per-target concurrency: persistent staging deploys serialize against
# themselves, each PR's preview lifecycle serializes against itself.
concurrency:
group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }}
cancel-in-progress: true
# Public Sentry DSNs (same as cd-apps.yml). See that workflow for the
# "public by design" rationale.
env:
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
jobs:
# ── Build ─────────────────────────────────────────────────────────────
# Tags:
# main push → :staging + :<sha>
# PR open/sync/reopen → :pr-<N> + :pr-<N>-<sha>
# Skipped entirely on PR close (teardown doesn't need new images).
build-images:
name: Build & Push Docker Images
# PR previews are opt-in to keep flagship disk in check (each preview is a
# journal container + database): build a PR's images only when it carries
# the `preview` label or a `<!-- preview -->` marker in its description.
# Main-push / dispatch always build.
if: >
github.event_name != 'pull_request' ||
(github.event.action != 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.body, '<!-- preview -->')))
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
strategy:
matrix:
app: [journal, planner]
outputs:
tag_primary: ${{ steps.tags.outputs.primary }}
tag_sha: ${{ steps.tags.outputs.sha }}
steps:
- uses: actions/checkout@v7
- id: tags
name: Compute image tags
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
PRIMARY="pr-${{ github.event.number }}"
SHA="pr-${{ github.event.number }}-${{ github.event.pull_request.head.sha }}"
else
PRIMARY="staging"
SHA="${{ github.sha }}"
fi
echo "primary=$PRIMARY" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Decrypt Sentry auth token
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env
grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token
- uses: docker/build-push-action@v7
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
push: true
tags: |
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.primary }}
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }}
build-args: |
SENTRY_RELEASE=${{ steps.tags.outputs.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token
# ── Deploy persistent staging (main push or manual dispatch) ─────────
deploy-staging:
name: Deploy Staging
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
needs: [build-images]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- name: Decrypt secrets
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env
{
echo "DOMAIN=staging.trails.cool"
echo "STAGING_DATABASE=trails_staging"
# Loki binds 10.0.0.2:3100 on the vSwitch interface, so the
# staging stack can't share 3100 — see CLAUDE.md "Staging &
# Previews" port table.
echo "JOURNAL_HOST_PORT=3110"
echo "PLANNER_HOST_PORT=3111"
echo "JOURNAL_IMAGE_TAG=staging"
echo "PLANNER_IMAGE_TAG=staging"
echo "SENTRY_RELEASE=${{ github.sha }}"
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
# Federation soak on persistent staging only (social-federation
# rollout 12.2). FEDERATION_KEY_ENCRYPTION_KEY comes from the
# SOPS env decrypted above; previews never set this flag.
echo "FEDERATION_ENABLED=true"
# Verbose Fedify logs during the soak (signature verification
# detail). Dial down to info once federation is proven out.
echo "FEDERATION_LOG_LEVEL=debug"
} >> infrastructure/staging.env
- name: Copy compose file + env to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env"
target: /opt/trails-cool
strip_components: 1
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
cd /opt/trails-cool
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Bootstrap the shared network so cd-staging works regardless of
# whether cd-infra has already run with the new docker-compose.yml.
# Once cd-infra runs, postgres is permanently joined via compose;
# until then, attach it imperatively.
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
if [ -n "$PG_CONTAINER" ]; then
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
fi
# Ensure trails_staging database exists with postgis. Production
# init scripts only run on first data-dir init, so a freshly
# created database has no extensions.
docker compose exec -T postgres psql -U trails -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='trails_staging'" \
| grep -q 1 \
|| docker compose exec -T postgres createdb -U trails trails_staging
docker compose exec -T postgres psql -U trails -d trails_staging -c \
"CREATE EXTENSION IF NOT EXISTS postgis"
# Pull and deploy staging containers (journal + planner via "persistent" profile)
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent pull
# drizzle-kit exits 0 even when it aborts on an interactive
# prompt (no TTY in CI) — set -e alone can't catch it. That lie
# hid a month of staging schema drift (2026-06-06 incident).
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
if grep -q "Error:" /tmp/drizzle-push.log; then
echo "drizzle-kit push reported an error — failing the deploy"
exit 1
fi
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent up -d --remove-orphans
# Reload Caddy so new staging routes (or Caddyfile changes shipped
# via cd-infra) are live. Idempotent.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
# Reclaim superseded image layers. cd-apps prunes after its own
# deploys, but a day of staging/preview deploys while cd-apps is
# red can fill the disk on its own (2026-06-07: 100% full,
# postgres down). The 1h filter avoids racing layers another
# in-flight deploy just pulled.
docker image prune -af --filter "until=1h" || true
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps
# ── PR preview deploy ────────────────────────────────────────────────
deploy-preview:
name: Deploy PR Preview
# Opt-in only (see build-images): the `preview` label or a `<!-- preview -->`
# marker in the PR body. Also skips GH-Actions-only Dependabot PRs — there
# is no app image to preview.
if: >
github.event_name == 'pull_request' &&
github.event.action != 'closed' &&
!startsWith(github.head_ref, 'dependabot/github_actions/') &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.body, '<!-- preview -->'))
needs: [build-images]
runs-on: ubuntu-latest
environment: production
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v7
- id: ports
name: Compute preview ports + project name
run: |
PR=${{ github.event.number }}
# journal = 3200 + 2N, planner unused (PR previews are journal-only)
JOURNAL_PORT=$((3200 + 2 * PR))
PLANNER_PORT=$((3201 + 2 * PR))
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "journal_port=$JOURNAL_PORT" >> "$GITHUB_OUTPUT"
echo "planner_port=$PLANNER_PORT" >> "$GITHUB_OUTPUT"
echo "host=pr-$PR.staging.trails.cool" >> "$GITHUB_OUTPUT"
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
- name: Decrypt secrets + assemble env
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
# Use a per-PR filename so concurrent SCP transfers don't overwrite each other.
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
{
echo "DOMAIN=${{ steps.ports.outputs.host }}"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
echo "JOURNAL_HOST_PORT=${{ steps.ports.outputs.journal_port }}"
echo "PLANNER_HOST_PORT=${{ steps.ports.outputs.planner_port }}"
echo "JOURNAL_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
echo "PLANNER_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
# PR-preview journals all share the persistent staging planner.
echo "PLANNER_URL=https://planner.staging.trails.cool"
echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}"
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
# Federation on previews too (social-federation rollout 12.4):
# makes any PR preview a second live trails instance that
# persistent staging can follow across — the trails-to-trails
# soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the
# SOPS env decrypted above. Preview teardown orphans remote
# follower rows on the other side; remotes handle dead
# instances via ordinary delivery-failure expiry.
echo "FEDERATION_ENABLED=true"
echo "FEDERATION_LOG_LEVEL=debug"
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
- name: Generate per-PR Caddyfile snippet
run: |
mkdir -p infrastructure/sites
cat > infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile <<EOF
# Auto-generated by cd-staging.yml for PR ${{ steps.ports.outputs.pr }}. Do not edit.
${{ steps.ports.outputs.host }} {
import security_headers
import block_scanners
log {
output stdout
format json
}
reverse_proxy host.docker.internal:${{ steps.ports.outputs.journal_port }} {
lb_try_duration 30s
lb_try_interval 250ms
}
}
EOF
- name: Copy files to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env,infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile"
target: /opt/trails-cool
strip_components: 1
- name: Deploy preview via SSH
uses: appleboy/ssh-action@v1
env:
PR: ${{ steps.ports.outputs.pr }}
PROJECT: ${{ steps.ports.outputs.project }}
DB: ${{ steps.ports.outputs.database }}
JOURNAL_PORT: ${{ steps.ports.outputs.journal_port }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PR,PROJECT,DB,JOURNAL_PORT
script: |
set -euo pipefail
cd /opt/trails-cool
ENV_FILE="staging-pr-${PR}.env"
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN "$ENV_FILE" | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Serialize all preview deploys with a server-side lock so concurrent
# CI jobs (e.g. a Dependabot batch) can't race on eviction or compose state.
exec 9>/tmp/trails-preview-deploy.lock
flock --timeout 300 9 || { echo "Timed out waiting for deploy lock after 300s"; exit 1; }
# Same network bootstrap as deploy-staging — see comment there.
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
if [ -n "$PG_CONTAINER" ]; then
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
fi
# Concurrent preview limit (max 3): if we're at the cap and this
# PR isn't already running, evict the oldest preview project.
ACTIVE=$(docker compose ls --format json --filter "name=trails-pr-" | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\n".join(d["Name"] for d in data))' 2>/dev/null || true)
if [ -n "$ACTIVE" ] && ! echo "$ACTIVE" | grep -qx "$PROJECT"; then
COUNT=$(echo "$ACTIVE" | grep -c '^trails-pr-' || true)
if [ "$COUNT" -ge 3 ]; then
# Pick the oldest by container CreatedAt of any service in the project.
OLDEST=$(docker ps -a --filter "name=trails-pr-" --format '{{.Names}} {{.CreatedAt}}' \
| awk '{ split($1,a,"-"); print "trails-pr-"a[3], $2" "$3" "$4 }' \
| sort -k2 \
| head -1 \
| awk '{print $1}')
if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then
echo "At cap; evicting oldest preview: $OLDEST"
OLD_PR=${OLDEST#trails-pr-}
OLD_ENV="staging-pr-${OLD_PR}.env"
# Fall back to base secrets if the per-PR env was already cleaned up.
EVICT_ENV=$( [ -f "$OLD_ENV" ] && echo "$OLD_ENV" || echo "staging.env" )
docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file "$EVICT_ENV" down --remove-orphans || true
docker compose exec -T postgres dropdb -U trails --if-exists "trails_pr_$OLD_PR" || true
rm -f "sites/pr-$OLD_PR.caddyfile" "$OLD_ENV"
fi
fi
fi
# Ensure per-PR database exists with postgis. (See deploy-staging
# for why we have to enable the extension explicitly.)
docker compose exec -T postgres psql -U trails -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='$DB'" \
| grep -q 1 \
|| docker compose exec -T postgres createdb -U trails "$DB"
docker compose exec -T postgres psql -U trails -d "$DB" -c \
"CREATE EXTENSION IF NOT EXISTS postgis"
# Pull, migrate, deploy (journal-only — no --profile means planner skipped)
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" pull journal
# Same drizzle-kit exit-code-0-on-error guard as the persistent
# staging deploy above.
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
if grep -q "Error:" /tmp/drizzle-push.log; then
echo "drizzle-kit push reported an error — failing the preview deploy"
exit 1
fi
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" up -d --remove-orphans journal
# Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step)
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
# Same disk-hygiene prune as the persistent staging deploy —
# preview pushes are the highest-volume image source.
docker image prune -af --filter "until=1h" || true
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps
# Find any prior preview comment so we can update it in place rather
# than spamming a new one each push. The marker line at the bottom of
# the body is what `body-includes` matches on.
- name: Find existing preview comment
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.number }}
comment-author: "github-actions[bot]"
body-includes: "<!-- cd-staging:preview -->"
- name: Upsert preview comment on PR
uses: peter-evans/create-or-update-comment@v5
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.number }}
edit-mode: replace
body: |
🚀 **PR preview deployed**
- **Journal:** https://${{ steps.ports.outputs.host }}
- **Planner (shared staging):** https://planner.staging.trails.cool
- **Database:** `${{ steps.ports.outputs.database }}` (separate from production / persistent staging)
- **Build:** [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit `${{ github.event.pull_request.head.sha }}`
Updates automatically on push. Tears down when this PR closes.
<!-- cd-staging:preview -->
# ── PR preview teardown ──────────────────────────────────────────────
teardown-preview:
name: Tear Down PR Preview
# Tear down on close, or when the `preview` opt-in is removed (label pulled
# and no `<!-- preview -->` marker left) so a de-flagged PR doesn't orphan
# its preview stack on the flagship.
if: >
github.event_name == 'pull_request' &&
(github.event.action == 'closed' ||
(github.event.action == 'unlabeled' &&
!(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.body, '<!-- preview -->'))))
runs-on: ubuntu-latest
environment: production
permissions:
pull-requests: write
steps:
- id: ports
name: Compute project + database name
run: |
PR=${{ github.event.number }}
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
- name: Decrypt secrets (needed to satisfy compose env vars during down)
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
{
echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))"
echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))"
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
- name: Copy compose + env (teardown still needs the file)
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env"
target: /opt/trails-cool
strip_components: 1
- name: Tear down via SSH
uses: appleboy/ssh-action@v1
env:
PR: ${{ steps.ports.outputs.pr }}
PROJECT: ${{ steps.ports.outputs.project }}
DB: ${{ steps.ports.outputs.database }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PR,PROJECT,DB
script: |
set -euo pipefail
cd /opt/trails-cool
ENV_FILE="staging-pr-${PR}.env"
# Stop and remove containers + volumes for this PR
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" down --remove-orphans || true
# Drop the per-PR database (idempotent)
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
# Remove the per-PR Caddy snippet, env file, and reload
rm -f "sites/pr-$PR.caddyfile" "$ENV_FILE"
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
- name: Find existing preview comment
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.number }}
comment-author: "github-actions[bot]"
body-includes: "<!-- cd-staging:preview -->"
- name: Update preview comment on close
if: steps.find-comment.outputs.comment-id
uses: peter-evans/create-or-update-comment@v5
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
🧹 **PR preview torn down** (PR ${{ github.event.pull_request.merged && 'merged' || 'closed' }}).
Database `trails_pr_${{ steps.ports.outputs.pr }}` dropped, containers removed, Caddyfile snippet cleared.
[Teardown run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
<!-- cd-staging:preview -->

View file

@ -19,7 +19,7 @@ jobs:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Gitleaks
@ -42,27 +42,14 @@ jobs:
name: Dockerfile Package Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- run: bash scripts/check-dockerfiles.sh
openspec:
name: OpenSpec Validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm openspec validate --all --strict --no-interactive
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -75,7 +62,7 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -88,7 +75,7 @@ jobs:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -101,7 +88,7 @@ jobs:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -110,72 +97,6 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm build
visual-tests:
name: Visual Tests
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright deps only
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run visual regression tests
id: visual-tests
run: pnpm --filter @trails-cool/planner test:visual
- name: Post diff comment on PR
if: failure() && steps.visual-tests.outcome == 'failure' && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort)
if [ -z "$diffs" ]; then exit 0; fi
artifact_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
body="## Visual regression failures"$'\n\n'
body+="The following tests produced screenshot diffs:"$'\n\n'
for diff in $diffs; do
name=$(basename "$diff" | sed 's/-diff-chromium-[a-z]*\.png//' | sed 's/-/ /g')
body+="- \`$name\`"$'\n'
done
body+=$'\n'"**[Download the \`visual-snapshots-diff\` artifact]($artifact_url)** to inspect the diffs locally."$'\n\n'
body+="To update snapshots if the change is intentional:"$'\n'
body+="\`\`\`"$'\n'
body+="pnpm --filter @trails-cool/planner test:visual:update"$'\n'
body+="\`\`\`"
gh pr comment ${{ github.event.pull_request.number }} --body "$body"
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: visual-snapshots-diff
path: apps/planner/.vitest-attachments/
include-hidden-files: true
retention-days: 7
e2e:
name: E2E Tests
needs: build
@ -183,7 +104,7 @@ jobs:
env:
DATABASE_URL: postgres://trails:trails@localhost:5432/trails
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -191,12 +112,64 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache PostGIS Docker image
id: postgis-cache
uses: actions/cache@v5
with:
path: /tmp/postgis-image.tar
key: postgis-16-3.4
- name: Load or pull PostGIS image
run: |
if [ -f /tmp/postgis-image.tar ]; then
docker load < /tmp/postgis-image.tar
else
docker pull postgis/postgis:16-3.4
docker save postgis/postgis:16-3.4 > /tmp/postgis-image.tar
fi
- name: Start PostgreSQL
run: |
docker run -d --name postgres \
-e POSTGRES_USER=trails \
-e POSTGRES_PASSWORD=trails \
-e POSTGRES_DB=trails \
-p 5432:5432 \
postgis/postgis:16-3.4
# Wait for pg_isready
for i in $(seq 1 30); do
docker exec postgres pg_isready -U trails > /dev/null 2>&1 && break
sleep 1
done
# Wait for PostGIS extension to be ready
for i in $(seq 1 10); do
docker exec postgres psql -U trails -c "SELECT PostGIS_Version();" > /dev/null 2>&1 && break
sleep 1
done
- name: Push database schema
run: pnpm db:push
- name: Build and cache BRouter
id: brouter-cache
uses: actions/cache@v5
with:
path: /tmp/brouter
key: brouter-1.7.8
- name: Download BRouter
if: steps.brouter-cache.outputs.cache-hit != 'true'
run: |
mkdir -p /tmp/brouter
wget -q "https://github.com/abrensch/brouter/releases/download/v1.7.8/brouter-1.7.8.zip" -O /tmp/brouter/brouter.zip
cd /tmp/brouter && unzip -o brouter.zip && mv brouter-1.7.8/* . && rmdir brouter-1.7.8 && rm brouter.zip
- name: Cache BRouter segment
id: segment-cache
uses: actions/cache@v6
uses: actions/cache@v5
with:
path: /tmp/brouter-segments
key: brouter-segment-E10_N50-v1.7.9
key: brouter-segment-E10_N50
- name: Download Berlin segment
if: steps.segment-cache.outputs.cache-hit != 'true'
@ -204,54 +177,26 @@ jobs:
mkdir -p /tmp/brouter-segments
wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5
- name: Pre-seed BRouter segment volume
- name: Start BRouter
run: |
docker volume create trails_brouter_segments
docker run --rm \
-v /tmp/brouter-segments:/src:ro \
-v trails_brouter_segments:/dst \
alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5"
- name: Start services
run: docker compose -f docker-compose.dev.yml up -d --wait --build
cd /tmp/brouter
java -Xmx256M -Xms64M \
-DmaxRunningTime=300 \
-cp brouter-1.7.8-all.jar \
btools.server.RouteServer \
/tmp/brouter-segments profiles2 profiles2 \
17777 2 &
# Wait for BRouter to start
for i in $(seq 1 30); do
curl -sf http://localhost:17777/brouter?lonlats=13.4,52.5\|13.5,52.5\&profile=trekking\&format=geojson > /dev/null 2>&1 && break
sleep 2
done
env:
BROUTER_URL: http://localhost:17777
- name: Wait for BRouter routing
run: |
for i in $(seq 1 60); do
curl -s 'http://localhost:17777/brouter?lonlats=13.4,52.5|13.5,52.5&profile=trekking&format=geojson' 2>/dev/null | grep -q "FeatureCollection" && echo "BRouter ready" && break
[ "$i" = "60" ] && echo "BRouter not ready after 120s" && exit 1
sleep 2
done
- name: Push database schema
run: pnpm db:push
- name: Seed database
run: pnpm db:seed
- name: Run integration tests
# These talk to real Postgres. The unit-test job has no DB so
# the `*.integration.test.ts` files skip there; this job has
# the DB up + schema pushed, so flip the gate env vars to "1"
# and let them run. Each gate is read by one file — see
# `runIntegration` in each test.
#
# --no-file-parallelism: integration tests share the journal
# schema and clean up by `DELETE FROM ... WHERE email LIKE
# '%@example.test'`. Parallel files step on each other's rows
# and trip FK constraints. Running sequentially is still <3s.
run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts
env:
EXPLORE_INTEGRATION: "1"
FOLLOW_INTEGRATION: "1"
DEMO_BOT_INTEGRATION: "1"
NOTIFICATIONS_INTEGRATION: "1"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v6
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
@ -273,12 +218,6 @@ jobs:
run: pnpm test:e2e
env:
BROUTER_URL: http://localhost:17777
# E2E=true is the explicit opt-out from the fail-loud
# requireSecret() / getDatabaseUrl() guards — playwright boots
# the server via `react-router serve` (NODE_ENV=production) but
# against the local dev Postgres + local cookie secrets.
E2E: "true"
INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }}
- name: Playwright job summary
if: ${{ !cancelled() }}
@ -316,87 +255,3 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 30
journal-image-smoke:
# Build the journal's *production* Docker image (the `runtime` stage)
# and actually boot it. Nothing else in CI does this: typecheck /
# lint / test / build all run against the source tree, and the e2e
# job boots the journal via `react-router-serve`, not the production
# `node server.ts` entrypoint. The runtime stage copies source files
# in by name (server.ts, app/lib, serve-static.ts, ...), so a refactor
# that adds a file `server.ts` imports — without a matching COPY —
# builds green everywhere and only crash-loops once deployed
# (ERR_MODULE_NOT_FOUND). That has taken prod down more than once
# (app/lib, app/jobs, serve-static.ts). Booting the real image and
# hitting /api/health closes that gap: a missing static OR dynamic
# import never reaches a healthy 200.
name: Journal Image Smoke Test
runs-on: ubuntu-latest
services:
postgres:
image: imresamu/postgis:16-3.4
env:
POSTGRES_USER: trails
POSTGRES_PASSWORD: trails
POSTGRES_DB: trails
ports:
- 5432:5432
# The postgis image restarts mid-init while it creates the
# extension; the health check only passes once the final server
# is up, so dependents don't race the init restart.
options: >-
--health-cmd "pg_isready -U trails"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgres://trails:trails@localhost:5432/trails
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
# seedOAuthClient + the demo/notifications job worker run on boot
# and write to real tables, so the image needs a schema to come up
# healthy. The postgis extension is auto-created by the image.
- name: Push database schema
run: pnpm db:push
- name: Build journal runtime image
run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke .
- name: Boot image and wait for healthy
run: |
# --network host: reach the service Postgres at localhost:5432
# and publish the server on localhost:3000 in one shot.
# E2E=true is the documented opt-out from the fail-loud
# getDatabaseUrl() prod guard (CI points at a local Postgres).
docker run -d --name journal-smoke --network host \
-e NODE_ENV=production -e E2E=true \
-e DATABASE_URL="$DATABASE_URL" \
journal-smoke
code=000
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000)
echo "attempt $i: /api/health -> $code"
[ "$code" = "200" ] && break
if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then
echo "::error::journal container exited during boot"
break
fi
sleep 2
done
if [ "$code" != "200" ]; then
echo "::error::journal production image failed to boot healthy (see logs below)"
docker logs journal-smoke 2>&1 || true
exit 1
fi
echo "journal production image booted healthy"
- name: Container logs
if: always()
run: docker logs journal-smoke 2>&1 | tail -40 || true

View file

@ -1,94 +0,0 @@
name: Dependabot auto-fix
# Post-processing that runs on every dependabot PR and pushes the result
# back to the PR branch. Two fixups, in one workflow so there is a single
# checkout / commit / push (two workflows racing to push the same branch
# would collide on a non-fast-forward):
#
# 1. pnpm dedupe — `pnpm install` alone doesn't dedupe peer copies of a
# package (e.g. two versions of i18next, each holding their own
# singleton state). That split caused a hydration mismatch on #272
# until a manual `pnpm dedupe` collapsed them.
#
# 2. openspec update — the OpenSpec agent skills (.agents/skills/openspec-*)
# and opsx slash commands (.claude/commands/opsx/*) are generated files
# stamped with the CLI version that produced them. When dependabot bumps
# @fission-ai/openspec they go stale until regenerated (see PR #567,
# which did the 1.2.0 -> 1.6.0 regen by hand). `openspec update --force`
# rewrites them to match the freshly-installed CLI.
#
# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a
# fine-grained PAT (or GitHub App token) with `contents: write` on
# this repo. The default `GITHUB_TOKEN` would work for the push but
# would NOT trigger a subsequent CI run on that push (GitHub's
# anti-loop safeguard), leaving the PR with stale green CI from
# before the fixup commit. A PAT re-triggers CI so the reviewer
# sees test results for the state they'd actually be merging.
on:
pull_request:
branches: [main]
permissions:
contents: write
jobs:
autofix:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Require DEPENDABOT_DEDUPE_TOKEN
env:
TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
if [ -z "$TOKEN" ]; then
echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow"
echo "::error::requires a PAT so the fixup commit re-triggers CI."
echo "::error::See .github/workflows/dependabot-auto-fix.yml header."
exit 1
fi
- uses: actions/checkout@v7
with:
ref: ${{ github.head_ref }}
# With `persist-credentials: true`, this PAT is stashed by the
# action (under $RUNNER_TEMP in recent versions) and made
# available to subsequent git operations in this workspace —
# so the later `git push` authenticates as the PAT, which is
# what gets CI to re-trigger on the fixup commit. `true` is
# the checkout default; pinned explicitly here because it's
# load-bearing for this workflow.
token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
persist-credentials: true
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile=false
- name: Dedupe lockfile
run: pnpm dedupe
- name: Regenerate OpenSpec tool files
# --force so the generated files always match the installed CLI,
# even if `update` would otherwise consider them up to date. No-op
# (no diff) when @fission-ai/openspec wasn't bumped in this PR.
run: pnpm exec openspec update --force
- name: Commit fixups
env:
GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
git add pnpm-lock.yaml .agents/skills/openspec-* .claude/commands/opsx
if git diff --cached --quiet; then
echo "Nothing to fix up — lockfile deduped and OpenSpec files current."
exit 0
fi
# Build a message naming only the parts that actually changed.
parts=""
git diff --cached --name-only | grep -q '^pnpm-lock.yaml$' && parts="pnpm dedupe"
if git diff --cached --name-only | grep -qE '^(\.agents/skills/openspec-|\.claude/commands/opsx)'; then
parts="${parts:+$parts + }openspec update"
fi
git config user.name "dependabot[bot]"
git config user.email "49699333+dependabot[bot]@users.noreply.github.com"
git commit -m "[github-actions] $parts"
git push
echo "Pushed fixups: $parts"

74
.github/workflows/dependabot-dedupe.yml vendored Normal file
View file

@ -0,0 +1,74 @@
name: Dependabot dedupe
# Dependabot opens a PR after a bump, but `pnpm install` alone doesn't
# dedupe peer copies of packages (e.g. two versions of i18next, each
# holding their own singleton state). That split caused a hydration
# mismatch on #272 until a manual `pnpm dedupe` collapsed them.
#
# This workflow fires on every dependabot PR, runs `pnpm dedupe`, and
# pushes the resulting lockfile update back to the PR branch so the
# subsequent CI run tests the deduped tree.
#
# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a
# fine-grained PAT (or GitHub App token) with `contents: write` on
# this repo. The default `GITHUB_TOKEN` would work for the push but
# would NOT trigger a subsequent CI run on that push (GitHub's
# anti-loop safeguard), leaving the PR with stale green CI from
# before the dedupe commit. A PAT re-triggers CI so the reviewer
# sees test results for the state they'd actually be merging.
on:
pull_request:
branches: [main]
permissions:
contents: write
jobs:
dedupe:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Require DEPENDABOT_DEDUPE_TOKEN
env:
TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
if [ -z "$TOKEN" ]; then
echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow"
echo "::error::requires a PAT so the dedupe commit re-triggers CI."
echo "::error::See .github/workflows/dependabot-dedupe.yml header."
exit 1
fi
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
# With `persist-credentials: true`, this PAT is stashed by the
# action (under $RUNNER_TEMP in recent versions) and made
# available to subsequent git operations in this workspace —
# so the later `git push` authenticates as the PAT, which is
# what gets CI to re-trigger on the dedupe commit. `true` is
# the checkout default; pinned explicitly here because it's
# load-bearing for this workflow.
token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
persist-credentials: true
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile=false
- run: pnpm dedupe
- name: Commit dedupe changes
env:
GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
if [ -n "$(git status --porcelain pnpm-lock.yaml)" ]; then
git config user.name "dependabot[bot]"
git config user.email "49699333+dependabot[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "[github-actions] pnpm dedupe"
git push
echo "Deduped lockfile pushed."
else
echo "Lockfile already deduped."
fi

View file

@ -1,56 +0,0 @@
name: Disk Maintenance
# Daily safety net for flagship disk usage. The deploy workflows prune
# superseded image layers after their own runs, but that protection
# disappears exactly when it's needed most: a deploy that fails early
# never reaches its prune step, while other workflows keep pulling
# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging
# deploys, disk 100% full, postgres down on a Saturday morning).
#
# Also doubles as a redundant alert channel: the run FAILS when the
# disk is still above the threshold after pruning, so a scheduled-run
# failure email lands even if the Grafana disk alert drowns in other
# noise (which is what happened during the incident).
on:
schedule:
# Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00)
- cron: "30 4 * * *"
workflow_dispatch: {}
concurrency:
group: disk-maintenance
cancel-in-progress: false
jobs:
prune-flagship:
name: Prune unused images (flagship)
runs-on: ubuntu-latest
environment: production
steps:
- name: Prune and check disk headroom
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
echo "before: $(df -h / | tail -1)"
# 12h filter: never touch layers a same-day deploy may still
# be assembling; running containers' images are never pruned.
# `|| true`: a concurrent deploy's prune can make this collide
# with "a prune operation is already running" — that's benign
# (the other prune is freeing space too), and the disk-% gate
# below is the real assertion, so don't fail on the collision.
docker image prune -af --filter "until=12h" || true
echo "after: $(df -h / | tail -1)"
PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if [ "$PCT" -ge 85 ]; then
echo "Disk still at ${PCT}% after pruning — needs a human."
echo "Largest docker consumers:"
docker system df
exit 1
fi
echo "Disk at ${PCT}% — healthy."

View file

@ -1,112 +0,0 @@
name: Staging Cleanup
# Sweeps the production server for orphaned PR-preview resources whose PRs
# have closed without the cd-staging teardown job running (e.g., the
# teardown failed, the workflow file was changed mid-flight, or the PR was
# closed while runners were down). Runs weekly and can be triggered ad-hoc.
on:
schedule:
# Every Monday at 04:00 UTC
- cron: "0 4 * * 1"
workflow_dispatch: {}
concurrency:
group: staging-cleanup
cancel-in-progress: false
jobs:
cleanup:
name: Sweep orphaned previews
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
pull-requests: read
steps:
- name: List active preview projects
id: list
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script_stop: true
script: |
cd /opt/trails-cool
# Emit one project name per line, e.g. "trails-pr-123"
docker compose ls --format json --filter "name=trails-pr-" \
| python3 -c 'import json,sys
try:
data = json.load(sys.stdin)
except Exception:
data = []
for d in data:
n = d.get("Name","")
if n.startswith("trails-pr-"):
print(n)' \
|| true
- name: Determine which PRs are still open
id: orphans
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROJECTS: ${{ steps.list.outputs.stdout }}
run: |
set -euo pipefail
ORPHANS=()
if [ -z "${PROJECTS:-}" ]; then
echo "No active preview projects."
echo "orphans=" >> "$GITHUB_OUTPUT"
exit 0
fi
while IFS= read -r project; do
[ -z "$project" ] && continue
pr="${project#trails-pr-}"
# If gh can't find the PR (deleted) or it's not OPEN, treat as orphan.
state=$(gh pr view "$pr" --repo "${{ github.repository }}" --json state -q .state 2>/dev/null || echo "MISSING")
if [ "$state" != "OPEN" ]; then
echo "Orphan: PR #$pr (state=$state) → tear down $project"
ORPHANS+=("$pr")
fi
done <<< "${PROJECTS}"
IFS=,
echo "orphans=${ORPHANS[*]:-}" >> "$GITHUB_OUTPUT"
- name: Tear down orphans
if: steps.orphans.outputs.orphans != ''
env:
ORPHANS: ${{ steps.orphans.outputs.orphans }}
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: ORPHANS
script: |
set -euo pipefail
cd /opt/trails-cool
IFS=, read -ra PRS <<< "$ORPHANS"
for PR in "${PRS[@]}"; do
[ -z "$PR" ] && continue
PROJECT="trails-pr-$PR"
DB="trails_pr_$PR"
echo "→ tearing down $PROJECT"
# `down` needs the same env file the deploy used; staging.env on
# disk may belong to a different PR, so synthesize a minimal one.
cat > /tmp/cleanup.env <<EOF
DOMAIN=pr-$PR.staging.trails.cool
STAGING_DATABASE=$DB
JOURNAL_HOST_PORT=$((3200 + 2 * PR))
PLANNER_HOST_PORT=$((3201 + 2 * PR))
JWT_SECRET=cleanup
SESSION_SECRET=cleanup
BROUTER_URL=http://placeholder
BROUTER_AUTH_TOKEN=placeholder
EOF
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file /tmp/cleanup.env down --remove-orphans || true
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
rm -f "sites/pr-$PR.caddyfile" "staging-pr-${PR}.env"
done
rm -f /tmp/cleanup.env
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true

View file

@ -1,103 +0,0 @@
name: Update visual snapshots
# How to use this workflow
# ========================
#
# Visual snapshots live in `apps/planner/app/**/__screenshots__/` and are
# committed to the repo. They are generated by Vitest browser mode running
# the Planner's `*.browser.test.tsx` files against real Chromium via Playwright.
#
# When to update snapshots:
# - You intentionally changed the look of the elevation chart (new color mode,
# layout change, etc.) and the old snapshots are now wrong.
# - You added a new `*.browser.test.tsx` test and need the initial snapshots.
#
# Two ways to trigger this workflow:
#
# 1. Manual dispatch (workflow_dispatch):
# Go to Actions → "Update visual snapshots" → "Run workflow".
# Choose the branch you want updated. The workflow will commit the new
# snapshots back to that branch.
#
# 2. PR label (update-snapshots):
# Add the `update-snapshots` label to any PR. The workflow will update
# snapshots on the PR's head branch. Remove the label after to avoid
# re-triggering on every subsequent push.
#
# After the workflow commits updated snapshots, pull the branch locally:
# git pull origin <your-branch>
#
# Running locally:
# pnpm --filter @trails-cool/planner test:visual # run tests
# pnpm --filter @trails-cool/planner test:visual:update # update snapshots
#
# Platform note:
# Snapshots are generated on ubuntu-latest to keep CI and local results
# consistent. Snapshots generated on macOS or Windows will have subtle
# font-rendering differences and will fail on CI. Always use this workflow
# (or a Linux machine / Docker) to produce the canonical snapshots.
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to update snapshots on"
required: false
default: ""
pull_request:
types: [labeled]
jobs:
update-snapshots:
# Only run for manual dispatch, or when the label is "update-snapshots"
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.label.name == 'update-snapshots')
runs-on: ubuntu-latest
permissions:
contents: write # needed to push snapshot commits back
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref || github.event.inputs.branch || github.ref }}
# Use a token with push rights so the commit-back step can push
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
run: pnpm exec playwright install chromium --with-deps
- name: Update visual snapshots
run: pnpm --filter @trails-cool/planner test:visual:update
- name: Commit updated snapshots
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: update visual snapshots [skip ci]"
file_pattern: "apps/planner/app/**/__screenshots__/**"
commit_user_name: "github-actions[bot]"
commit_user_email: "github-actions[bot]@users.noreply.github.com"
- name: Upload snapshots as artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: visual-snapshots
path: apps/planner/app/**/__screenshots__/
if-no-files-found: ignore

2
.gitignore vendored
View file

@ -10,10 +10,8 @@ dist/
.crit.json
e2e/results/
test-results/
.env.development
playwright-report/
playwright-results.json
.claude/worktrees/
.claude/settings.local.json
.claude/scheduled_tasks.lock
docs/reviews/internal/

101
CLAUDE.md
View file

@ -9,9 +9,7 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with
Full architecture: `docs/architecture.md`
Philosophy: `docs/philosophy.md`
Roadmap: `docs/roadmap.md`
Ideas (pre-spec explorations): `docs/ideas/`
OpenSpec changes: `openspec/changes/`
OpenSpec change: `openspec/changes/phase-1-mvp/`
## Principles
@ -27,7 +25,7 @@ OpenSpec changes: `openspec/changes/`
- **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack)
- **Maps**: Leaflet + OpenStreetMap tiles
- **CRDT**: Yjs + y-websocket (Planner only)
- **Federation**: Fedify (Journal only)
- **Federation**: Fedify (Journal only, Phase 2)
- **Database**: PostgreSQL + PostGIS
- **Media storage**: S3-compatible (Garage)
- **Routing engine**: BRouter (Java, runs as separate Docker container)
@ -41,15 +39,11 @@ apps/
planner/ — Planner app (React Router 7)
journal/ — Journal app (React Router 7 + Fedify)
packages/
types/ — Shared wire types both apps exchange (Waypoint)
map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side
types/ — Shared TypeScript interfaces (Route, Activity, Waypoint)
ui/ — Shared React components (Tailwind)
map/ — Leaflet map wrappers and tile layer configs
gpx/ — GPX parsing, generation, validation
fit/ — FIT file generation (Wahoo route push)
i18n/ — react-i18next config + translations
api/ — Shared API contracts (endpoints, pagination, error types, versioning)
db/ — Drizzle schema, database client, migration helpers
jobs/ — pg-boss setup, worker, and background job types
sentry-config/ — Shared Sentry configuration
infrastructure/ — Terraform + Docker Compose
openspec/ — OpenSpec specs and changes
docs/ — Architecture, philosophy, tooling docs
@ -74,40 +68,6 @@ pnpm db:push # Push Drizzle schema to local PostgreSQL
pnpm db:studio # Open Drizzle Studio (DB browser)
```
### Local HTTPS dev (rare — most contributors never need this)
The default dev loop runs the journal on plain HTTP at
`http://localhost:3000`. WebAuthn passkeys, magic links, sessions, the
Terms gate, SSE — everything works over HTTP because the WebAuthn spec
treats `localhost` as a secure context regardless of scheme. CI's e2e
suite runs over plain HTTP too.
There is exactly one feature that requires local HTTPS: **Wahoo OAuth**.
Wahoo (and most OAuth providers) reject `http://` redirect URIs, so the
`/api/sync/connect/wahoo` callback flow can only complete against an
HTTPS dev server. To run that flow:
```bash
HTTPS=1 ORIGIN=https://localhost:3000 pnpm --filter @trails-cool/journal dev
```
`HTTPS=1` enables the `@vitejs/plugin-basic-ssl` cert + the ALPN
HTTP/1.1 workaround in `apps/journal/vite.config.ts`. `ORIGIN` makes the
WebAuthn server expect the HTTPS origin (set this together with HTTPS=1
or you'll get origin-mismatch errors). Use `pnpm --filter` (not
`pnpm dev`) because turbo doesn't pass `HTTPS` through unless added to
its `globalPassThroughEnv``pnpm --filter` bypasses turbo entirely.
**Don't set `ORIGIN=https://localhost:3000` in your `apps/journal/.env`
unless you intend to always run with `HTTPS=1`.** Mismatched values
break the e2e suite and generic dev. See
`apps/journal/.env.example` for what each var means.
If you find yourself wanting `HTTPS=1` for any reason other than Wahoo
testing, write it down here so the assumption stays auditable —
"everything but Wahoo works over HTTP locally" is what keeps CI and
local config symmetric.
## Testing Strategy
- **Unit tests** (Vitest + jsdom): For packages, components, utilities, and app logic.
@ -123,8 +83,8 @@ local config symmetric.
- **Route registration**: Both apps use explicit `routes.ts` (not file-based routing). When adding a new route file, you **must** add it to `apps/*/app/routes.ts` or it won't be compiled into the build.
- All user-facing strings must use i18n (`useTranslation()` hook, never hardcode strings)
- Database row types are derived from the Drizzle schema (`@trails-cool/db`); API wire shapes are the Zod contracts in `@trails-cool/api`; only types both apps exchange (e.g. Waypoint) live in `@trails-cool/types`
- Map constants (colors, tiles, POI categories, z-indexes) go in `@trails-cool/map-core`; React/Leaflet map components live in the app that uses them
- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions
- Map components go in `@trails-cool/map`, not in individual apps
- GPX parsing/generation goes in `@trails-cool/gpx`
- Database schemas: `planner.*` for Planner data, `journal.*` for Journal data
- Route geometry must be stored as PostGIS LineString (extracted from GPX on save)
@ -183,15 +143,13 @@ Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a
## Deployment
Five CD workflows triggered by path or event:
Three separate CD workflows triggered by path:
| Workflow | Triggers on | Deploys | Target |
|----------|-------------|---------|--------|
| `cd-apps.yml` | `apps/`, `packages/`, `pnpm-lock.yaml` | journal, planner | flagship (`root@trails.cool`) |
| `cd-infra.yml` | `infrastructure/` (except `brouter-host/**`) | caddy, postgres, prometheus, loki, grafana, exporters | flagship (`root@trails.cool`) |
| `cd-brouter.yml` | `docker/brouter/`, `infrastructure/brouter-host/**` | brouter + caddy sidecar | dedicated (`trails@ullrich.is:2232`) |
| `cd-staging.yml` | main push or PR open/sync/close on `apps/`, `packages/` | persistent staging + per-PR previews | flagship (alongside production) |
| `staging-cleanup.yml` | weekly cron + manual | sweeps orphaned PR previews | flagship |
### Hosts
@ -223,49 +181,6 @@ ssh -i ~/.ssh/trails-brouter-deploy -p 2232 trails@ullrich.is
### Grafana
`https://grafana.internal.trails.cool` — GitHub OAuth (trails-cool org)
### Staging & Previews
A persistent staging stack and ephemeral PR previews share the flagship server with production.
| Surface | URL | Database | Triggered by |
|---------|-----|----------|--------------|
| Persistent staging journal | `https://staging.trails.cool` | `trails_staging` | push to `main` |
| Persistent staging planner | `https://planner.staging.trails.cool` | `trails_staging` | push to `main` |
| PR preview journal | `https://pr-<N>.staging.trails.cool` | `trails_pr_<N>` | PR open/sync |
PR previews are **journal-only** — their `PLANNER_URL` points at the persistent staging planner so we don't pay 256MB per preview for an extra planner. The persistent staging planner's CSP allows `connect-src wss://*.staging.trails.cool` so PR-preview journals can talk to it.
**Port scheme** (host-published, reverse-proxied by Caddy via `host.docker.internal`):
- Persistent staging: journal `3110`, planner `3111` (3100 collides with Loki on the vSwitch interface)
- PR `<N>` preview: journal `3200 + 2N`, planner `3201 + 2N` (planner unused for previews)
**Compose project namespacing** keeps each preview isolated:
- Persistent staging: `-p trails-staging`
- PR `<N>`: `-p trails-pr-<N>`
The shared file `infrastructure/docker-compose.staging.yml` covers both — env vars (`DOMAIN`, `STAGING_DATABASE`, `JOURNAL_HOST_PORT`, `JOURNAL_IMAGE_TAG`, …) parametrize per target. Persistent staging uses `--profile persistent` to also start the planner; PR previews omit the profile.
**Caddy routing.** Persistent staging has fixed site blocks in `infrastructure/Caddyfile`. Per-PR site blocks are written by `cd-staging.yml` to `/opt/trails-cool/sites/pr-<N>.caddyfile` (mounted into Caddy at `/etc/caddy/sites/`) and picked up via `import sites/*.caddyfile` on a Caddy reload. No on-demand TLS; standard automatic HTTPS issues a per-host cert.
**Database isolation.** Each preview gets its own database on the production Postgres instance, schema applied via `drizzle-kit push --force`. Created on PR open, dropped on close. The persistent staging DB is never touched by previews.
**Concurrent preview cap.** Max 3 concurrent PR previews. When a 4th opens, the deploy job evicts the oldest project before deploying.
**Cleanup.** `cd-staging.yml`'s teardown job runs on PR close. `staging-cleanup.yml` runs weekly to catch orphans whose teardown never ran.
**Debugging.** SSH to the flagship (`ssh -i ~/.ssh/trails-cool-deploy root@trails.cool`) and run `docker compose -f docker-compose.staging.yml -p trails-pr-<N> logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up.
## Agent Skills & Config
Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink).
```
.agents/skills/ ← canonical location
.claude/skills ← symlink → ../.agents/skills
```
Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup.
## OpenSpec Workflow
Specs live in `openspec/`. Use these slash commands:

View file

@ -1,198 +0,0 @@
# trails.cool domain glossary
This file names the domain concepts used in the codebase. New terms get added
here as decisions crystallize during architecture work; the goal is that one
concept has one name everywhere — specs, code, conversations.
If you're naming a new module, a new column, or a new UI surface, look here
first. If the term you need isn't here, propose it (don't invent a synonym).
---
## GPX Save
The atomic unit of persisting spatial data in the Journal. Any write of a GPX track — whether a new route, an updated route, a new activity, or a route derived from an activity — goes through a single path that validates, writes the row, and writes the PostGIS geometry in one transaction.
### gpx-save module
`apps/journal/app/lib/gpx-save.server.ts`. The sole owner of GPX validation and geometry persistence. Imported by `routes.server.ts`, `activities.server.ts`, and `demo-bot.server.ts`. Nothing else calls `setGeomFromGpx` directly.
### GpxValidationError
Typed error thrown by `validateGpx` when the GPX string cannot produce a valid LineString. Conditions: fewer than 2 track points, or coordinates outside valid ranges (lat 90..90, lon 180..180). Callers catch this to return a user-facing 400.
### validateGpx
`(gpx: string) → Promise<ParsedGpx>`. Entry point of the gpx-save module. Parses the GPX string once and validates the result. Returns the `ParsedGpx` so callers can extract stats without re-parsing. Throws `GpxValidationError` on invalid input. Called at the start of `createRoute`, `updateRoute`, `createActivity`, and `createRouteFromActivity` — before any DB write.
### atomic GPX save
The invariant: a route or activity row with a `gpx` column set **always** has a corresponding `geom` column set. Enforced by wrapping the row insert/update, the PostGIS geometry write, and the version snapshot in a single `db.transaction()`. A PostGIS failure rolls back the row write; partial state (row exists, geom NULL) is not possible through the normal save path.
---
## Connected Services
The user-facing surface for linking external accounts and devices to a Journal
account. Spec: `openspec/specs/connected-services/`.
### ConnectedService
A single linked external account or device, owned by one user. Stored in the
`connected_services` table (renamed from `sync_connections`). At most one row
per `(user_id, provider)`.
### provider
String identifier for the external system: `wahoo`, `komoot`, `apple-health`,
and future `coros`, `garmin`, `strava`. The provider determines the
`credential_kind` and which capabilities (import / push / webhook) the
connection has, via the provider's manifest.
### credential kind
Discriminator on `connected_services` describing the credential shape stored
in the `credentials` JSONB blob. Three kinds today:
- **oauth** — OAuth2 access token, refresh token, expiry. Wahoo, and the
expected shape for Coros / Garmin / Strava.
- **web-login** — email + encrypted password + session jar. Komoot. No
official API; we authenticate against the provider's normal web login and
reuse the resulting session cookies. Refresh = re-login. Web-login
breakage (form changes, captchas, password rotation) surfaces at the
import layer, not at the credential layer.
- **device** — no remote credential. Apple Health (and future Health Connect
on Android). Data arrives via authenticated mobile API uploads, not
server-initiated pulls. The `credentials` blob is empty; the connection
exists so the UI can show "Apple Health is paired."
Credential kind is determined by the provider via its manifest, but stored
explicitly on the row so queries don't need to join the manifest.
### granted_scopes
Column on `connected_services`, populated only for `credential_kind = oauth`,
NULL otherwise. Lists the OAuth scopes the user actually granted (e.g.
`routes_write`). Feature gates query this column directly; missing a scope
triggers re-authorization.
### provider_user_id
The external service's identifier for the user. Used to route incoming
webhooks to the right local user. Nullable (Apple Health has none in the
remote-id sense).
### CredentialAdapter
Per-kind module that knows how to maintain credentials of that kind:
- `oauth.refresh(creds) → creds | NeedsRelink`
- `web-login.relogin(creds) → creds | InvalidCredentials`
- `device` — no-op
Adapters do not import data, push routes, or handle webhooks. They only own
the credential lifecycle.
### ConnectedServiceManager
The deep module callers see. Owns:
- `link(userId, provider, credentials)` / `unlink(serviceId)`
- `withFreshCredentials(serviceId, fn)` — refreshes via the right
`CredentialAdapter` if expired, calls `fn(creds)`, marks the connection
`needs_relink` if refresh fails.
- `markNeedsRelink(serviceId, reason)` — called by import / push / webhook
layers when they observe a credential failure (e.g. Komoot web-login
fails, Wahoo returns 401 after a successful refresh).
Per-provider importers / pushers / webhook handlers always go through
`withFreshCredentials` — they never read the `credentials` JSONB directly.
### provider manifest
Per-provider declaration co-located with the provider's code
(`providers/wahoo/manifest.ts`, etc.). Declares:
- the provider's `credential_kind`
- which capabilities the provider implements (import? push? webhook?)
- references to the per-capability modules
A small `providers/registry.ts` imports each manifest. Adding a provider is
one new directory plus one import line.
## Sync Capabilities
Three orthogonal capabilities a provider may implement. Each is its own seam
when there are ≥2 adapters; today most are single-adapter and held to a
named shape so the second adapter doesn't reshape the interface.
### Importer
Pulls workouts / activities from the external service into the Journal.
Wahoo (OAuth pull), Komoot (web-login pull), Apple Health (mobile-pushed) all
implement this, with very different mechanics. Dedup via `sync_imports`.
### RoutePusher
Pushes a Journal route out to the external service. One adapter today
(Wahoo); the seam exists so Coros / Garmin / Strava push won't reshape it.
The public seam is `pushRoute(connectedService, route) → {remoteId, version}`.
Provider-specific concerns — FIT Course conversion, the `route:<id>`
`external_id` convention, the PUT→POST-on-404 fallback — are **handled
internally by the adapter**, not exposed on the seam. Idempotency is tracked
via `sync_pushes`.
### WebhookReceiver
Handles inbound webhooks. Today: Wahoo workout-published. Routes incoming
webhooks to the right user via `provider_user_id`. Unknown
`provider_user_id` returns 200 and is silently dropped (no leak).
## Storage tables
- `connected_services` — user ↔ provider links (renamed from
`sync_connections`).
- `sync_imports` — dedup cache for imported workouts, keyed by
`(user_id, provider, workout_id)`.
- `sync_pushes` — push state per `(user_id, route_id, provider)`
`remote_id`, `last_pushed_version`.
---
## Authentication
User identity in the Journal. Two **authentication methods** are supported
and are intentionally the entire surface (see ADR-0005): **passkey**
(WebAuthn) as the preferred method and **magic-link + 6-digit code**
(email) as a fallback for users without passkey support or for cross-device
sign-in. There is no plan for social sign-in (Google/Apple/etc.) — passkeys
already deliver the one-tap UX, and adding centralized identity providers
would conflict with the privacy-first ethos and ActivityPub federation.
OAuth2/PKCE (the `mobile-app` flow) is **not** a third authentication
method. It is a **session transport** for native clients: users still
authenticate via passkey or magic-link in a WebView, then the mobile app
exchanges the resulting authorization code for long-lived bearer tokens.
The peer of OAuth2 transport is the cookie session, not passkey or
magic-link.
### completeAuth
The single chokepoint for the post-verify orchestration of every web
auth flow. Lives at `apps/journal/app/lib/auth/completion.ts` (see
ADR-0004). Called by every route handler that has just verified a
user's identity (passkey login finish, magic-link code verify, magic
link consumer). Does three things in order:
1. If `isNewRegistration`, records the accepted Terms version
(`recordTermsAcceptance`).
2. Creates the session cookie via `createSession`.
3. Returns `redirect(returnTo ?? "/")` with the session `Set-Cookie`
header attached.
Identity-method-specific work (WebAuthn ceremony verification, magic
token consumption) stays in the per-method functions and runs *before*
`completeAuth`. The chokepoint deliberately knows nothing about how
identity was proved.
### Terms gate
Cross-cutting middleware enforcing that `users.terms_version` matches
the current `TERMS_VERSION` constant before any non-allow-listed
authenticated request succeeds. Two enforcement points:
- **Web (cookie sessions)**: the root loader redirects stale-terms users
to `/auth/accept-terms`. Allow-list: `/auth/accept-terms`,
`/auth/logout`, `/legal/*`. `/oauth/authorize` is *not* on the
allow-list, so OAuth code issuance is gated by this same redirect
before mobile sees an authorization code.
- **API (bearer tokens)**: `requireApiUser` returns
`403 { code: "TERMS_OUTDATED", currentTermsVersion }` for stale-terms
bearer-token traffic. (Added in `mobile-terms-gate`, 2026-05-08.)
`completeAuth` only **records** terms on registration; it does not
enforce them. Enforcement remains middleware's job.

View file

@ -1,198 +0,0 @@
# Federation protocol
trails.cool's Journal federates over [ActivityPub](https://www.w3.org/TR/activitypub/),
implemented with [Fedify](https://fedify.dev). This document describes the
wire protocol precisely enough for another implementation to interoperate
deliberately — actor discovery, the object and activity types we emit and
accept, addressing, signatures, deduplication, delivery retry, and
moderation. Examples use `trails.example` for our instance and
`remote.example` for a peer.
Federation is per-instance opt-in (`FEDERATION_ENABLED`). When it is off,
every federation surface returns 404 — a disabled instance is
indistinguishable from one without the feature. Only users with
`profile_visibility = 'public'` federate; a private user's actor,
WebFinger, inbox, and outbox all 404, so their existence never leaks.
## Actor discovery
### WebFinger
`GET /.well-known/webfinger?resource=acct:alice@trails.example` resolves a
handle to an actor IRI:
```json
{
"subject": "acct:alice@trails.example",
"links": [
{ "rel": "self", "type": "application/activity+json", "href": "https://trails.example/users/alice" }
]
}
```
### Actor
`GET https://trails.example/users/alice` with `Accept: application/activity+json`
returns a `Person`. The actor IRI and the human profile `url` are the same
by design (browsers get HTML at that URL via content negotiation):
```json
{
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
"id": "https://trails.example/users/alice",
"type": "Person",
"preferredUsername": "alice",
"name": "Alice",
"summary": "trail runner",
"url": "https://trails.example/users/alice",
"inbox": "https://trails.example/users/alice/inbox",
"outbox": "https://trails.example/users/alice/outbox",
"publicKey": {
"id": "https://trails.example/users/alice#main-key",
"owner": "https://trails.example/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"
},
"assertionMethod": [ { "type": "Multikey", "…": "…" } ],
"attachment": [
{ "type": "PropertyValue", "name": "🥾 trails.cool", "value": "<a href=\"https://trails.example/users/alice\" rel=\"me\">trails.example/users/alice</a>" },
{ "type": "PropertyValue", "name": "Instance", "value": "<a href=\"https://trails.example\">trails.example</a>" }
]
}
```
`publicKey` is the RSA key Mastodon reads for HTTP-Signature verification;
`assertionMethod` carries the same keys as Multikeys for newer stacks.
### NodeInfo (software discovery)
`GET /.well-known/nodeinfo` links to `GET /nodeinfo/2.1`:
```json
{
"version": "2.1",
"software": { "name": "trails-cool", "version": "1.2.3", "homepage": "https://trails.cool/" },
"protocols": ["activitypub"],
"usage": { "users": {}, "localPosts": 0, "localComments": 0 }
}
```
`software.name` is the machine-readable "this is a trails instance" marker
used by the trails-to-trails outbound check. Usage counts are deliberately
zeroed — publishing per-instance counts is a privacy decision we have not
made.
## Objects and activities
Activities correspond to a user's journal entries. The object model is
deliberately Mastodon-compatible: a `Create(Note)` whose HTML `content`
summarizes the activity and whose `url` links to the journal detail page.
(A first-class `trails:Route` object type is planned with route-federation;
today everything is a Note.)
### Note
```json
{
"id": "https://trails.example/activities/01H…",
"type": "Note",
"attributedTo": "https://trails.example/users/alice",
"content": "<p>Morning trail run — 12.4 km, 480 m up</p>",
"url": "https://trails.example/activities/01H…",
"published": "2026-07-13T07:12:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"]
}
```
The Note IRI (`/activities/<id>`) is dereferenceable and serves
`application/activity+json` — Mastodon's search-fetch and strict re-fetch
of pushed objects both rely on this.
### Create / Delete
A publish is a `Create` wrapping the Note; the activity id is the object IRI
with a `#create` fragment. A retraction is a `Delete` wrapping a `Tombstone`
at the same object IRI:
```json
{ "id": "https://trails.example/activities/01H…#create", "type": "Create",
"actor": "https://trails.example/users/alice",
"object": { "…": "the Note above" },
"published": "2026-07-13T07:12:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"] }
```
Note that a `Delete` poisons the object URI on the remote forever (remotes
tombstone it); re-publishing the same URI after a Delete is silently
refused by strict remotes.
### Follow graph
The inbox is **narrow** — only follow-graph activities are processed;
anything else is acknowledged (`202`) and dropped.
| Inbound | Effect |
|---|---|
| `Follow` (remote → local public actor) | auto-accepted; we push back an `Accept(Follow)` |
| `Undo(Follow)` | removes the follow |
| `Accept(Follow)` | settles our outgoing Follow; triggers the first outbox poll |
| `Reject(Follow)` | drops our pending outgoing Follow |
## Addressing
Public activities are addressed to `https://www.w3.org/ns/activitystreams#Public`
and **push-delivered** to each accepted remote follower's inbox (fan-out,
one delivery per follower). We do not implement shared-inbox delivery.
Remotes do not backfill history — only pushed or individually-fetched
objects appear on a peer.
## Signatures
All inbound activities must carry a valid HTTP Signature; Fedify verifies it
against the sending actor's `publicKey` (fetched and cached). Unsigned or
badly-signed requests are rejected. Outbound deliveries are signed with the
sending user's key. An actor changing keys requires the remote to re-fetch
the actor document.
## Deduplication
Delivery is at-least-once, so receivers must be idempotent. trails dedups
inbound activities two ways:
- **`Create(Note)`** — idempotent via a unique constraint on the activity's
origin IRI (`remote_origin_iri`); a redelivered Create is a no-op.
- **Follow-graph activities** (`Follow` / `Undo` / `Accept` / `Reject`) — the
activity IRI is recorded in `federation_processed_activities` on first
receipt (insert-or-drop before any side effect); a redelivery is dropped
and counted (`federation_inbox_dropped_total{reason="duplicate"}`).
Records are retained ≥ 30 days, which comfortably exceeds the
HTTP-Signature date-freshness window, then swept.
## Delivery retry
Delivery queueing and retry state are **durable** — they survive process
restarts and deploys (backed by PostgreSQL via pg-boss; Fedify owns the
retry policy). On a `5xx` or timeout, a delivery retries with exponential
backoff, giving up after a bounded budget (~8 attempts spanning roughly a
day) before a permanent failure is logged. Deliveries are paced to at most
1 request/second per remote host. Metrics:
`federation_delivery_total{outcome}` and `federation_queue_depth`.
## Moderation
An operator can block a federation instance by domain (exact-host match).
A blocked instance is **inert in both directions**:
- its inbound activities are silently dropped (`202`, no error oracle) and
counted (`federation_inbox_dropped_total{reason="blocked"}`);
- it receives no deliveries (blocked recipients are filtered from fan-out);
- we never fetch its actors or outboxes.
Blocking is effective immediately (checked per request / per job, no cache).
The operator procedure (a SQL insert/delete against
`journal.federation_blocked_instances`) is documented in the
[deployment runbook](docs/deployment.md#blocking-an-instance).
---
*Kept current as federation capabilities change. Specs:
`openspec/specs/social-federation` and `openspec/specs/federation-operations`.*

View file

@ -96,14 +96,6 @@ docker compose up -d
See [docs/architecture.md](docs/architecture.md) for details on self-hosting
configuration.
## Federation
The Journal federates over ActivityPub. The wire protocol — actor
discovery, object/activity types with JSON examples, addressing,
signatures, deduplication, delivery retry, and moderation — is documented
in [FEDERATION.md](FEDERATION.md), which is precise enough for another
implementation to interoperate against.
## Philosophy
- **Privacy by design** — The Planner collects zero user data

View file

@ -1,63 +0,0 @@
# Journal local-dev environment.
# Copy to `.env` (gitignored) and edit. Most contributors don't need
# anything in this file — the journal app boots on plain HTTP at
# http://localhost:3000 with sensible defaults.
# ────────────────────────────────────────────────────────────────────
# DO NOT SET unless you know you need it
# ────────────────────────────────────────────────────────────────────
#
# `ORIGIN` is what the WebAuthn ceremony uses as `expectedOrigin`.
# When unset it defaults to `http://localhost:3000`, which is what
# Playwright sends and what the browser sees in plain-HTTP dev.
#
# The ONLY reason to set this is if you're running the dev server
# over HTTPS (see HTTPS=1 below) and want passkey registration to
# succeed against the HTTPS origin. If you set it to `https://...`
# without also running the server over HTTPS, you'll get
# "Unexpected registration response origin" errors in dev and the
# local e2e suite (which always hits HTTP) will fail registration.
#
# ORIGIN=https://localhost:3000
# Flagship marker. Renders the project marketing block on the
# anonymous home and gates a few "this is the canonical instance"
# behaviors. Self-hosted instances leave this unset; flagship CI
# sets it to `true`. Either is fine for local dev — set it if you
# want to see the full marketing layout, leave it unset if you
# want the self-host home view.
# IS_FLAGSHIP=true
# ────────────────────────────────────────────────────────────────────
# Wahoo OAuth (only needed when actively testing the Wahoo import)
# ────────────────────────────────────────────────────────────────────
#
# Wahoo's OAuth flow is the one feature that genuinely needs HTTPS
# locally: the provider rejects `http://` redirect URIs. To exercise
# the connect/disconnect/import flow end-to-end, you need:
#
# 1. Local HTTPS dev server: run `HTTPS=1 pnpm --filter
# @trails-cool/journal dev` (the basic-ssl plugin in
# vite.config.ts wires up the cert; see the comment there for
# the ALPN workaround).
# 2. ORIGIN=https://localhost:3000 (uncomment above).
# 3. Wahoo client credentials below, and a redirect URI of
# `https://localhost:3000/api/sync/callback/wahoo` registered
# in your Wahoo developer dashboard.
#
# WAHOO_CLIENT_ID=
# WAHOO_CLIENT_SECRET=
# WAHOO_WEBHOOK_TOKEN=
# Garmin Connect Developer Program credentials (spec: garmin-import).
# Requires an approved program application; without these the Garmin
# provider is hidden on /settings/connections. The OAuth callback to
# register with Garmin is `<origin>/api/sync/callback/garmin`, the
# notification endpoint `<origin>/api/sync/webhook/garmin`.
# GARMIN_CLIENT_ID=
# GARMIN_CLIENT_SECRET=
# Integration test secret (only needed if running the integration
# test suite that drives the API directly). Generate with
# `openssl rand -hex 32`.
# INTEGRATION_SECRET=

View file

@ -1,4 +1,4 @@
FROM node:26-slim AS base
FROM node:25-slim AS base
# curl is used by the docker-compose healthcheck (node:25-slim is Debian
# trixie-slim and ships neither curl nor wget by default).
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
@ -9,29 +9,24 @@ FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/journal/package.json apps/journal/
COPY packages/types/package.json packages/types/
COPY packages/ui/package.json packages/ui/
COPY packages/map/package.json packages/map/
COPY packages/gpx/package.json packages/gpx/
COPY packages/i18n/package.json packages/i18n/
COPY packages/sentry-config/package.json packages/sentry-config/
COPY packages/api/package.json packages/api/
COPY packages/map-core/package.json packages/map-core/
COPY packages/ui/package.json packages/ui/
COPY packages/db/package.json packages/db/
COPY packages/jobs/package.json packages/jobs/
COPY packages/fit/package.json packages/fit/
RUN pnpm install --frozen-lockfile
FROM base AS build
ARG SENTRY_RELEASE
# Client-side Sentry DSN baked into the bundle at build time. Empty (or
# unset) produces a Sentry-free client. Public-by-design: the DSN
# appears in the shipped client JS regardless.
ARG VITE_SENTRY_DSN=""
COPY --from=deps /app/ ./
COPY . .
RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \
SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \
SENTRY_RELEASE="$SENTRY_RELEASE" \
VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \
pnpm --filter @trails-cool/journal build
FROM base AS runtime
@ -40,7 +35,6 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules
COPY --from=build /app/apps/journal/build ./apps/journal/build
COPY --from=build /app/apps/journal/server.ts ./apps/journal/server.ts
COPY --from=build /app/apps/journal/serve-static.ts ./apps/journal/serve-static.ts
COPY --from=build /app/apps/journal/app/lib ./apps/journal/app/lib
COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs
COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json

View file

@ -1,87 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Form, Link } from "react-router";
import { useTranslation } from "react-i18next";
import { Avatar } from "./Avatar";
interface Props {
user: { username: string; displayName: string | null };
}
export function AccountDropdown({ user }: Props) {
const { t } = useTranslation("journal");
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Click-outside + Escape close. Mounted only while open to keep the
// listener cost zero in the steady state.
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={containerRef} className="relative">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={user.displayName ?? user.username}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Avatar displayName={user.displayName} username={user.username} size="md" />
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full z-20 mt-2 w-56 origin-top-right rounded-md border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"
>
<div className="border-b border-gray-100 px-3 py-2">
<p className="truncate text-sm font-medium text-gray-900">
{user.displayName ?? user.username}
</p>
<p className="truncate text-xs text-gray-500">@{user.username}</p>
</div>
<Link
to={`/users/${user.username}`}
role="menuitem"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t("nav.profile")}
</Link>
<Link
to="/settings"
role="menuitem"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t("nav.settings")}
</Link>
<Form method="post" action="/auth/logout">
<button
type="submit"
role="menuitem"
className="block w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
{t("nav.logout")}
</button>
</Form>
</div>
)}
</div>
);
}

View file

@ -1,42 +0,0 @@
// Initials-only avatar. We don't have an image-upload story yet (the
// `users` table has no avatar URL column), so initials are the
// implementation. When images land, this component is the single
// place to add the image fallback.
interface Props {
displayName: string | null;
username: string;
size?: "sm" | "md" | "lg";
className?: string;
}
function initialsOf(displayName: string | null, username: string): string {
const source = (displayName ?? username).trim();
if (source.length === 0) return "?";
// Two-letter initials: first letter of the first two whitespace-
// separated words, falling back to the first two characters when
// the source is a single token.
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
}
return source.slice(0, 2).toUpperCase();
}
const SIZE_CLASS: Record<NonNullable<Props["size"]>, string> = {
sm: "h-7 w-7 text-[11px]",
md: "h-9 w-9 text-sm",
lg: "h-12 w-12 text-base",
};
export function Avatar({ displayName, username, size = "md", className = "" }: Props) {
const initials = initialsOf(displayName, username);
return (
<span
className={`inline-flex shrink-0 items-center justify-center rounded-full bg-gray-200 font-semibold text-gray-700 ${SIZE_CLASS[size]} ${className}`}
aria-hidden="true"
>
{initials}
</span>
);
}

View file

@ -3,14 +3,10 @@ import { useLocale } from "./LocaleContext";
/**
* Renders a date formatted with the server-detected locale,
* ensuring SSR and client output match (no hydration flicker).
* Pass `withTime` for surfaces where the hour/minute matter
* (e.g. notifications), keeping plain dates as the default.
*/
export function ClientDate({ iso, withTime = false }: { iso: string; withTime?: boolean }) {
export function ClientDate({ iso }: { iso: string }) {
const locale = useLocale();
const d = new Date(iso);
const text = withTime
? d.toLocaleString(locale, { dateStyle: "short", timeStyle: "short" })
: d.toLocaleDateString(locale);
return <time dateTime={iso}>{text}</time>;
return (
<time dateTime={iso}>{new Date(iso).toLocaleDateString(locale)}</time>
);
}

View file

@ -4,9 +4,6 @@ interface Entry {
username: string;
displayName: string | null;
domain: string;
/** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
profileUrl: string;
remote: boolean;
}
interface Props {
@ -45,10 +42,9 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) {
) : (
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{entries.map((entry) => (
<li key={`${entry.username}@${entry.domain}`} className="px-4 py-3">
<li key={entry.username} className="px-4 py-3">
<a
href={entry.profileUrl}
{...(entry.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
href={`/users/${entry.username}`}
className="flex items-center justify-between hover:underline"
>
<span className="text-sm font-medium text-gray-900">

View file

@ -1,54 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { ElevationProfile } from "./ElevationProfile.tsx";
import type { ElevationSample } from "@trails-cool/gpx";
afterEach(cleanup);
const labels = { highest: "Highest", lowest: "Lowest" };
const series: ElevationSample[] = [
{ d: 0, e: 100, lat: 0, lng: 0 },
{ d: 500, e: 160, lat: 0, lng: 0.005 },
{ d: 1000, e: 120, lat: 0, lng: 0.01 },
];
describe("ElevationProfile", () => {
it("renders nothing for a too-short series", () => {
const { container } = render(
<ElevationProfile series={[series[0]!]} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("svg")).toBeNull();
});
it("renders the chart and highest/lowest summary", () => {
const { container, getByText } = render(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("svg")).not.toBeNull();
// area + line paths
expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(2);
expect(getByText("160 m")).toBeTruthy(); // highest
expect(getByText("100 m")).toBeTruthy(); // lowest
});
it("draws an active marker when activeIndex is set", () => {
const { container } = render(
<ElevationProfile series={series} activeIndex={1} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("circle")).not.toBeNull();
expect(container.querySelector("line")).not.toBeNull();
});
it("reports seek on pointer down", () => {
const onSeek = vi.fn();
const { container } = render(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={onSeek} labels={labels} />,
);
const svg = container.querySelector("svg")!;
// jsdom getBoundingClientRect returns zeros; we only assert the handler fires.
fireEvent.pointerDown(svg, { clientX: 10 });
expect(onSeek).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,122 +0,0 @@
import { useRef } from "react";
import type { ElevationSample } from "@trails-cool/gpx";
import { formatElevationM, formatDistanceKm } from "~/lib/stats";
const W = 1000;
const H = 220;
const PAD = { top: 16, right: 8, bottom: 22, left: 46 };
const PLOT_W = W - PAD.left - PAD.right;
const PLOT_H = H - PAD.top - PAD.bottom;
/**
* Read-only elevation profile chart (SVG, responsive via viewBox). Distance on
* x, elevation on y, with a gradient area fill. Reports the hovered sample
* index via `onActive` (for the map marker) and the clicked index via `onSeek`
* (centre the map), and draws a marker at `activeIndex` (set by the map when the
* route line is hovered). Renders nothing for an empty/too-short series.
*/
export function ElevationProfile({
series,
activeIndex,
onActive,
onSeek,
labels,
className,
}: {
series: ElevationSample[];
activeIndex: number | null;
onActive: (index: number | null) => void;
onSeek: (index: number) => void;
labels: { highest: string; lowest: string };
className?: string;
}) {
const ref = useRef<SVGSVGElement>(null);
if (series.length < 2) return null;
const maxD = series[series.length - 1]!.d || 1;
let minE = Infinity;
let maxE = -Infinity;
for (const s of series) {
if (s.e < minE) minE = s.e;
if (s.e > maxE) maxE = s.e;
}
const eRange = Math.max(1, maxE - minE);
const baseY = PAD.top + PLOT_H;
const x = (d: number) => PAD.left + (d / maxD) * PLOT_W;
const y = (e: number) => PAD.top + (1 - (e - minE) / eRange) * PLOT_H;
const linePath = series
.map((s, i) => `${i === 0 ? "M" : "L"}${x(s.d).toFixed(1)},${y(s.e).toFixed(1)}`)
.join(" ");
const areaPath = `${linePath} L${x(maxD).toFixed(1)},${baseY} L${PAD.left},${baseY} Z`;
const active = activeIndex != null ? series[activeIndex] : null;
function indexFromClientX(clientX: number): number {
const rect = ref.current!.getBoundingClientRect();
const px = ((clientX - rect.left) / rect.width) * W; // → SVG user units
const d = ((px - PAD.left) / PLOT_W) * maxD;
let best = 0;
let bestDelta = Infinity;
for (let i = 0; i < series.length; i++) {
const delta = Math.abs(series[i]!.d - d);
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
return best;
}
return (
<div className={className}>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span>
{labels.highest} <span className="font-semibold text-gray-900">{formatElevationM(maxE)}</span>
{" · "}
{labels.lowest} <span className="font-semibold text-gray-900">{formatElevationM(minE)}</span>
</span>
{active && (
<span className="tabular-nums">
{formatDistanceKm(active.d)} · {formatElevationM(active.e)}
</span>
)}
</div>
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="h-40 w-full touch-none select-none"
role="img"
aria-label="Elevation profile"
onPointerMove={(e) => onActive(indexFromClientX(e.clientX))}
onPointerLeave={() => onActive(null)}
onPointerDown={(e) => onSeek(indexFromClientX(e.clientX))}
>
<defs>
<linearGradient id="elev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.35" />
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.03" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#elev-fill)" />
<path d={linePath} fill="none" stroke="#2563eb" strokeWidth="2" vectorEffect="non-scaling-stroke" />
{active && (
<g>
<line
x1={x(active.d)}
y1={PAD.top}
x2={x(active.d)}
y2={baseY}
stroke="#9ca3af"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<circle cx={x(active.d)} cy={y(active.e)} r="4" fill="#2563eb" stroke="#fff" strokeWidth="1.5" />
</g>
)}
</svg>
</div>
);
}

View file

@ -8,36 +8,21 @@ interface FollowState {
interface Props {
username: string;
// Whether the followed profile is private/locked. Drives the "Request to
// follow" label vs. plain "Follow" before any click happens.
isPrivateTarget: boolean;
initialState: FollowState | null;
}
type Display = "follow" | "request" | "pending" | "unfollow";
function displayFor(state: FollowState | null, isPrivateTarget: boolean): Display {
if (state?.following) return "unfollow";
if (state?.pending) return "pending";
return isPrivateTarget ? "request" : "follow";
}
export function FollowButton({ username, isPrivateTarget, initialState }: Props) {
export function FollowButton({ username, initialState }: Props) {
const { t } = useTranslation("journal");
const [state, setState] = useState<FollowState>(
initialState ?? { following: false, pending: false },
);
const [isInFlight, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const display = displayFor(state, isPrivateTarget);
const onClick = () => {
setError(null);
startTransition(async () => {
// For "pending" we treat the click as cancel-request: same /unfollow
// endpoint deletes the row whether it's accepted or pending.
const path = state.following || state.pending
const path = state.following
? `/api/users/${username}/unfollow`
: `/api/users/${username}/follow`;
try {
@ -55,33 +40,21 @@ export function FollowButton({ username, isPrivateTarget, initialState }: Props)
});
};
const label = (() => {
switch (display) {
case "unfollow":
return t("social.unfollow");
case "pending":
return t("social.pendingCancel");
case "request":
return t("social.requestToFollow");
case "follow":
default:
return t("social.follow");
}
})();
const baseClass = display === "follow" || display === "request"
? "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
: "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50";
const label = state.following ? t("social.unfollow") : t("social.follow");
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={onClick}
disabled={isInFlight}
className={baseClass}
disabled={isPending}
className={
state.following
? "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
: "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
}
>
{isInFlight ? "…" : label}
{isPending ? "…" : label}
</button>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>

View file

@ -1,152 +0,0 @@
import { useEffect, useState } from "react";
import { Form, Link, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { Avatar } from "./Avatar";
interface Props {
user: { username: string; displayName: string | null };
}
// Mobile drawer for the navbar. Replaces the "everything in a row"
// desktop layout with a hamburger trigger and a slide-out panel that
// holds all the same destinations. Bell + the dropdown's account
// section move inside; the bell badge still surfaces on the trigger
// itself via the parent navbar.
export function MobileNavMenu({ user }: Props) {
const { t } = useTranslation("journal");
const [open, setOpen] = useState(false);
const location = useLocation();
// Close the drawer on navigation. React Router pushes a new location
// when a Link is followed; this effect picks that up.
useEffect(() => {
setOpen(false);
}, [location.pathname]);
// Close on Escape; lock body scroll while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open]);
const linkClass = (path: string) => {
const active = location.pathname === path || location.pathname.startsWith(path + "/");
return `block rounded-md px-3 py-2 text-base font-medium ${
active ? "bg-blue-50 text-blue-700" : "text-gray-700 hover:bg-gray-50"
}`;
};
return (
<>
<button
type="button"
aria-label={t("nav.openMenu")}
aria-expanded={open}
onClick={() => setOpen(true)}
className="inline-flex items-center justify-center rounded-md p-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.75}
stroke="currentColor"
className="h-6 w-6"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
{open && (
<div className="fixed inset-0 z-40 md:hidden">
{/* Backdrop */}
<button
type="button"
aria-label={t("nav.closeMenu")}
onClick={() => setOpen(false)}
className="absolute inset-0 bg-black/30"
/>
{/* Panel */}
<div
role="dialog"
aria-modal="true"
className="absolute right-0 top-0 flex h-full w-72 max-w-[85%] flex-col bg-white shadow-xl"
>
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<div className="flex items-center gap-3">
<Avatar displayName={user.displayName} username={user.username} size="md" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900">
{user.displayName ?? user.username}
</p>
<p className="truncate text-xs text-gray-500">@{user.username}</p>
</div>
</div>
<button
type="button"
aria-label={t("nav.closeMenu")}
onClick={() => setOpen(false)}
className="rounded-md p-2 text-gray-500 hover:bg-gray-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.75}
stroke="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-2 py-3">
<Link to="/feed" className={linkClass("/feed")}>
{t("social.feed.title")}
</Link>
<Link to="/explore" className={linkClass("/explore")}>
{t("nav.explore")}
</Link>
<Link to="/routes" className={linkClass("/routes")}>
{t("nav.routes")}
</Link>
<Link to="/activities" className={linkClass("/activities")}>
{t("nav.activities")}
</Link>
<Link to="/notifications" className={linkClass("/notifications")}>
{t("notifications.title")}
</Link>
<hr className="my-2 border-gray-200" />
<Link to={`/users/${user.username}`} className={linkClass(`/users/${user.username}`)}>
{t("nav.profile")}
</Link>
<Link to="/settings" className={linkClass("/settings")}>
{t("nav.settings")}
</Link>
<Form method="post" action="/auth/logout">
<button
type="submit"
className="block w-full rounded-md px-3 py-2 text-left text-base font-medium text-gray-700 hover:bg-gray-50"
>
{t("nav.logout")}
</button>
</Form>
</nav>
</div>
</div>
)}
</>
);
}

View file

@ -1,37 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { ProfileStats } from "./ProfileStats.tsx";
afterEach(cleanup);
describe("ProfileStats", () => {
it("renders nothing when there are no activities", () => {
const { container } = render(
<ProfileStats stats={{ count: 0, distance: 0, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.firstChild).toBeNull();
});
it("renders formatted totals", () => {
const { container, getByText } = render(
<ProfileStats
stats={{ count: 42, distance: 123_400, elevationGain: 5120, duration: 9000, last4Weeks: 3 }}
/>,
);
// Values come from the formatters (i18n-independent).
expect(getByText("42")).toBeTruthy();
expect(getByText("123 km")).toBeTruthy(); // >= 100 km → integer
expect(getByText("↑ 5120 m")).toBeTruthy();
expect(getByText("2h 30m")).toBeTruthy();
// last-4-weeks line present when > 0
expect(container.querySelector("p")).not.toBeNull();
});
it("omits the last-4-weeks line when zero", () => {
const { container } = render(
<ProfileStats stats={{ count: 5, distance: 1000, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.querySelector("p")).toBeNull();
});
});

View file

@ -1,42 +0,0 @@
import { useTranslation } from "react-i18next";
import { StatRow } from "./StatRow.tsx";
import { formatDistanceKm, formatElevationM, formatDuration } from "~/lib/stats";
// Structural shape (mirrors ActivityStats from activities.server) so this
// presentational component doesn't import from a `.server` module.
export interface ProfileStatsData {
count: number;
distance: number;
elevationGain: number;
duration: number;
last4Weeks: number;
}
/**
* Lifetime roll-up header on the profile: activity count · distance · ascent ·
* elapsed time, plus a "N in the last 4 weeks" line. Renders nothing when the
* (viewer-scoped) count is zero.
*/
export function ProfileStats({ stats, className }: { stats: ProfileStatsData; className?: string }) {
const { t } = useTranslation("journal");
if (stats.count === 0) return null;
return (
<div className={className}>
<StatRow
size="lg"
items={[
{ label: t("profileStats.activities"), value: String(stats.count) },
{ label: t("profileStats.distance"), value: formatDistanceKm(stats.distance) },
{ label: t("profileStats.ascent"), value: `${formatElevationM(stats.elevationGain)}` },
{ label: t("profileStats.time"), value: formatDuration(stats.duration) },
]}
/>
{stats.last4Weeks > 0 && (
<p className="mt-2 text-sm text-gray-500">
{t("profileStats.last4Weeks", { count: stats.last4Weeks })}
</p>
)}
</div>
);
}

View file

@ -1,63 +1,9 @@
import { useEffect, useRef } from "react";
import { MapContainer, TileLayer, GeoJSON, CircleMarker, useMap, useMapEvents } from "react-leaflet";
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
import L from "leaflet";
import type { GeoJsonObject } from "geojson";
import "leaflet/dist/leaflet.css";
/** Marker shown at the position the elevation chart is pointing at. */
function ActiveMarker({ point }: { point: { lat: number; lng: number } | null | undefined }) {
if (!point) return null;
return (
<CircleMarker
center={[point.lat, point.lng]}
radius={6}
pathOptions={{ color: "#fff", weight: 2, fillColor: "#2563eb", fillOpacity: 1 }}
/>
);
}
/** Reports the route sample nearest the cursor so the chart can highlight it. */
function HoverTracker({
series,
onHoverIndex,
}: {
series: Array<[number, number]>;
onHoverIndex: (index: number | null) => void;
}) {
useMapEvents({
mousemove(e) {
const { lat, lng } = e.latlng;
let best = -1;
let bestDelta = Infinity;
for (let i = 0; i < series.length; i++) {
const [slat, slng] = series[i]!;
const delta = (slat - lat) ** 2 + (slng - lng) ** 2;
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
onHoverIndex(best >= 0 ? best : null);
},
mouseout() {
onHoverIndex(null);
},
});
return null;
}
/** Pans the map when the chart is clicked (centerOn.v bumps per click). */
function Recenter({ centerOn }: { centerOn: { lat: number; lng: number; v: number } | null | undefined }) {
const map = useMap();
const lastV = useRef<number | null>(null);
useEffect(() => {
if (!centerOn || centerOn.v === lastV.current) return;
lastV.current = centerOn.v;
map.panTo([centerOn.lat, centerOn.lng], { animate: true });
}, [centerOn, map]);
return null;
}
function FitBounds({ data }: { data: GeoJsonObject }) {
const map = useMap();
const fitted = useRef(false);
@ -134,35 +80,16 @@ interface RouteMapProps {
dayBreaks?: number[];
/** 1-based day number to highlight, or null for no highlight */
highlightedDay?: number | null;
/** Elevation-profile sync: marker position, route samples to hover-match, callbacks. */
activePoint?: { lat: number; lng: number } | null;
hoverSeries?: Array<[number, number]>;
onHoverIndex?: (index: number | null) => void;
centerOn?: { lat: number; lng: number; v: number } | null;
}
export function RouteMapThumbnail({
geojson,
interactive,
className,
dayBreaks,
highlightedDay,
activePoint,
hoverSeries,
onHoverIndex,
centerOn,
}: RouteMapProps) {
export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) {
const data: GeoJsonObject = JSON.parse(geojson);
return (
<MapContainer
center={[50, 10]}
zoom={6}
// `isolate` gives the Leaflet container its own stacking context so its
// internal high z-indexes (panes ~200700, zoom controls ~1000) stay
// contained and can't paint over page overlays like the mobile nav
// drawer's backdrop.
className={`${className ?? "h-36 w-full rounded"} isolate`}
className={className ?? "h-36 w-full rounded"}
zoomControl={interactive ?? false}
attributionControl={interactive ?? false}
dragging={interactive ?? false}
@ -187,11 +114,6 @@ export function RouteMapThumbnail({
fullData={data}
/>
)}
<ActiveMarker point={activePoint} />
{hoverSeries && hoverSeries.length > 0 && onHoverIndex && (
<HoverTracker series={hoverSeries} onHoverIndex={onHoverIndex} />
)}
<Recenter centerOn={centerOn} />
</MapContainer>
);
}
@ -220,7 +142,7 @@ function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObj
type: "Feature",
geometry: { type: "LineString", coordinates: seg.coords },
properties: {},
} as GeoJsonObject;
} as unknown as GeoJsonObject;
return (
<GeoJSON
key={`${i}-${highlightedDay}`}

View file

@ -1,39 +0,0 @@
import { useTranslation } from "react-i18next";
import type { SportType } from "@trails-cool/db/schema/journal";
// Emoji glyphs as lightweight, dependency-free sport icons. The localized
// label carries the meaning; the glyph is decorative (aria-hidden).
const SPORT_EMOJI: Record<SportType, string> = {
hike: "🥾",
walk: "🚶",
run: "🏃",
ride: "🚴",
gravel: "🚲",
mtb: "🚵",
ski: "⛷️",
other: "📍",
};
/**
* Small pill (glyph + localized label) shown next to an activity title on the
* detail page, feed cards, and the profile list. Renders nothing when the
* sport type is unset.
*/
export function SportBadge({
sportType,
className,
}: {
sportType: SportType | null | undefined;
className?: string;
}) {
const { t } = useTranslation("journal");
if (!sportType) return null;
return (
<span
className={`inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700${className ? ` ${className}` : ""}`}
>
<span aria-hidden>{SPORT_EMOJI[sportType]}</span>
{t(`activities.sport.${sportType}`)}
</span>
);
}

View file

@ -1,29 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { StatRow } from "./StatRow.tsx";
afterEach(cleanup);
describe("StatRow", () => {
it("renders nothing for an empty item list", () => {
const { container } = render(<StatRow items={[]} />);
expect(container.firstChild).toBeNull();
});
it("renders each item's value and label, in order", () => {
const { container } = render(
<StatRow
items={[
{ label: "Distance", value: "30.0 km" },
{ label: "Time", value: "1h 0m" },
{ label: "Avg speed", value: "30.0 km/h" },
]}
/>,
);
const labels = [...container.querySelectorAll("dt")].map((el) => el.textContent);
const values = [...container.querySelectorAll("dd")].map((el) => el.textContent);
expect(labels).toEqual(["Distance", "Time", "Avg speed"]);
expect(values).toEqual(["30.0 km", "1h 0m", "30.0 km/h"]);
});
});

View file

@ -1,34 +0,0 @@
import type { StatItem } from "~/lib/stats";
/**
* The one shared headline-stat presentation. Surfaces pass an ordered list of
* {value, label} items (built via `activityStatItems`); the component never
* fetches or formats. `size="lg"` is the detail-page treatment; the default
* compact size is for feed cards and the profile list.
*/
export function StatRow({
items,
size = "sm",
className,
}: {
items: StatItem[];
size?: "sm" | "lg";
className?: string;
}) {
if (items.length === 0) return null;
const valueCls =
size === "lg"
? "text-2xl font-bold text-gray-900"
: "text-sm font-semibold text-gray-900";
const gap = size === "lg" ? "gap-6" : "gap-x-4 gap-y-1";
return (
<dl className={`flex flex-wrap ${gap}${className ? ` ${className}` : ""}`}>
{items.map((it) => (
<div key={it.label}>
<dd className={valueCls}>{it.value}</dd>
<dt className="text-xs text-gray-500">{it.label}</dt>
</div>
))}
</dl>
);
}

View file

@ -1,40 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { SurfaceBreakdown } from "./SurfaceBreakdown.tsx";
afterEach(cleanup);
describe("SurfaceBreakdown", () => {
it("renders nothing without a breakdown", () => {
const { container } = render(<SurfaceBreakdown breakdown={null} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when every bucket is zero", () => {
const { container } = render(<SurfaceBreakdown breakdown={{ surface: { asphalt: 0 }, highway: {} }} />);
expect(container.firstChild).toBeNull();
});
it("renders a legend item per non-zero category with its percentage", () => {
const { container, getByText } = render(
<SurfaceBreakdown breakdown={{ surface: { asphalt: 6000, gravel: 4000 }, highway: { residential: 10000 } }} />,
);
// 2 surface + 1 waytype = 3 legend entries
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(getByText(/60% · 6\.0 km/)).toBeTruthy();
expect(getByText(/40% · 4\.0 km/)).toBeTruthy();
expect(getByText(/100% · 10\.0 km/)).toBeTruthy();
});
it("sorts segments largest-first within a dimension", () => {
const { container } = render(
<SurfaceBreakdown breakdown={{ surface: { gravel: 3000, asphalt: 7000 }, highway: {} }} />,
);
// first bar's first segment is the larger (asphalt 70%)
const firstBar = container.querySelector("div.flex.h-3");
const widths = [...firstBar!.querySelectorAll("div")].map((d) => (d as HTMLElement).style.width);
expect(widths[0]).toBe("70%");
expect(widths[1]).toBe("30%");
});
});

View file

@ -1,99 +0,0 @@
import { useTranslation } from "react-i18next";
import {
SURFACE_COLORS,
DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS,
DEFAULT_HIGHWAY_COLOR,
} from "@trails-cool/map-core";
import { formatDistanceKm } from "~/lib/stats";
export interface SurfaceBreakdownData {
surface: Record<string, number>;
highway: Record<string, number>;
}
function Bar({
title,
data,
colorFor,
}: {
title: string;
data: Record<string, number>;
colorFor: (category: string) => string;
}) {
const { t } = useTranslation("journal");
const entries = Object.entries(data)
.filter(([, m]) => m > 0)
.sort((a, b) => b[1] - a[1]);
const total = entries.reduce((s, [, m]) => s + m, 0);
if (total <= 0) return null;
const label = (cat: string) =>
cat === "unknown"
? t("surface.other")
: t(`surface.cat.${cat}`, { defaultValue: cat.replace(/_/g, " ") });
return (
<div className="mt-3 first:mt-0">
<p className="mb-1 text-xs font-medium text-gray-500">{title}</p>
<div className="flex h-3 w-full overflow-hidden rounded">
{entries.map(([cat, m]) => (
<div
key={cat}
style={{ width: `${(m / total) * 100}%`, backgroundColor: colorFor(cat) }}
title={`${label(cat)} · ${formatDistanceKm(m)}`}
/>
))}
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
{entries.map(([cat, m]) => (
<li key={cat} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: colorFor(cat) }}
aria-hidden
/>
<span>{label(cat)}</span>
<span className="tabular-nums text-gray-400">
{Math.round((m / total) * 100)}% · {formatDistanceKm(m)}
</span>
</li>
))}
</ul>
</div>
);
}
/**
* Surface + waytype proportion bars (route-surface-breakdown). Renders nothing
* when there's no breakdown data. Colours come from the shared map-core
* palettes; unknown tags collapse into "other".
*/
export function SurfaceBreakdown({
breakdown,
className,
}: {
breakdown: SurfaceBreakdownData | null | undefined;
className?: string;
}) {
const { t } = useTranslation("journal");
if (!breakdown) return null;
const hasSurface = Object.values(breakdown.surface).some((m) => m > 0);
const hasHighway = Object.values(breakdown.highway).some((m) => m > 0);
if (!hasSurface && !hasHighway) return null;
return (
<div className={className}>
<Bar
title={t("surface.surface")}
data={breakdown.surface}
colorFor={(c) => SURFACE_COLORS[c] ?? DEFAULT_SURFACE_COLOR}
/>
<Bar
title={t("surface.waytype")}
data={breakdown.highway}
colorFor={(c) => HIGHWAY_COLORS[c] ?? DEFAULT_HIGHWAY_COLOR}
/>
</div>
);
}

View file

@ -1,41 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { WeeklyDistanceChart } from "./WeeklyDistanceChart.tsx";
afterEach(cleanup);
const weeks = (distances: number[]) =>
distances.map((distance, i) => ({ weekStart: `2026-04-${String(i + 1).padStart(2, "0")}`, distance }));
describe("WeeklyDistanceChart", () => {
it("renders nothing when every week is zero", () => {
const { container } = render(<WeeklyDistanceChart weeks={weeks([0, 0, 0])} />);
expect(container.firstChild).toBeNull();
});
it("renders the chart with a bar only for non-zero weeks", () => {
const { container } = render(<WeeklyDistanceChart weeks={weeks([5000, 0, 2500, 0])} />);
expect(container.querySelector("svg")).not.toBeNull();
// bars only for the two non-zero weeks; empty weeks keep their slot via the track rect
expect(container.querySelectorAll("[data-week-bar]")).toHaveLength(2);
});
it("tops the y-scale at the busiest week", () => {
const { getByText } = render(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
// peak gridline label = 10 km (>= 10 → integer)
expect(getByText("10 km")).toBeTruthy();
// half gridline
expect(getByText("5.0 km")).toBeTruthy();
});
it("shows a hover readout with the week's distance", () => {
// max 8 km → axis labels are 8.0 / 4.0 / 0 km; "3.0 km" is unique to the
// readout for week 0, so it only appears once that week is hovered.
const { container, queryByText } = render(<WeeklyDistanceChart weeks={weeks([3000, 8000])} />);
expect(queryByText("3.0 km")).toBeNull();
const hit = container.querySelectorAll("rect[fill='transparent']")[0]!;
fireEvent.mouseEnter(hit);
expect(queryByText("3.0 km")).not.toBeNull();
});
});

View file

@ -1,136 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDistanceKm } from "~/lib/stats";
export interface WeeklyDistanceBucket {
weekStart: string;
distance: number;
}
// SVG layout (user units; rendered responsive via viewBox).
const W = 480;
const H = 132;
const PAD = { top: 10, right: 6, bottom: 18, left: 36 };
const PLOT_W = W - PAD.left - PAD.right;
const PLOT_H = H - PAD.top - PAD.bottom;
const BASE_Y = PAD.top + PLOT_H;
function axisLabel(km: number): string {
if (km <= 0) return "0";
return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`;
}
/**
* Weekly-distance bar chart for the profile (last N weeks, oldest newest).
* Gridlines + a y-scale topped at the busiest week, faint per-week tracks so
* the 12-week axis is always visible (empty weeks read as gaps, not nothing),
* and a hover readout naming the week + its distance. Hidden when there is no
* distance in the window.
*/
export function WeeklyDistanceChart({
weeks,
className,
}: {
weeks: WeeklyDistanceBucket[];
className?: string;
}) {
const { t, i18n } = useTranslation("journal");
const [hover, setHover] = useState<number | null>(null);
const maxM = weeks.reduce((m, w) => Math.max(m, w.distance), 0);
if (maxM <= 0) return null;
const maxKm = maxM / 1000;
const n = weeks.length;
const colW = PLOT_W / n;
const barW = colW * 0.6;
const x = (i: number) => PAD.left + i * colW + (colW - barW) / 2;
const yFor = (m: number) => BASE_Y - (m / maxM) * PLOT_H;
const fmtWeek = (iso: string) =>
new Date(`${iso}T00:00:00`).toLocaleDateString(i18n.language, { month: "short", day: "numeric" });
const gridFracs = [0, 0.5, 1];
const active = hover != null ? weeks[hover] : null;
const first = weeks[0];
const last = weeks[n - 1];
const firstLabel = first ? fmtWeek(first.weekStart) : "";
const lastLabel = last ? fmtWeek(last.weekStart) : "";
return (
<div className={className}>
<div className="mb-1 flex items-baseline justify-between text-xs text-gray-500">
<span>{t("profileStats.weeklyDistance")}</span>
{active && (
<span className="tabular-nums text-gray-700">
{t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "}
<span className="font-semibold">{formatDistanceKm(active.distance)}</span>
</span>
)}
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-28 w-full"
role="img"
aria-label={t("profileStats.weeklyDistance")}
onMouseLeave={() => setHover(null)}
>
{/* gridlines + y-axis labels (0 · half · peak) */}
{gridFracs.map((f) => {
const yy = BASE_Y - f * PLOT_H;
return (
<g key={f}>
<line x1={PAD.left} y1={yy} x2={W - PAD.right} y2={yy} stroke="#e5e7eb" strokeWidth="1" />
<text x={PAD.left - 5} y={yy + 3} textAnchor="end" fontSize="9" fill="#9ca3af">
{axisLabel(maxKm * f)}
</text>
</g>
);
})}
{weeks.map((w, i) => {
const isActive = hover === i;
return (
<g key={w.weekStart}>
{/* faint week-slot track so empty weeks stay visible */}
<rect
x={x(i)}
y={PAD.top}
width={barW}
height={PLOT_H}
rx="1.5"
fill={isActive ? "#dbeafe" : "#f3f4f6"}
/>
{/* distance bar */}
{w.distance > 0 && (
<rect
data-week-bar
x={x(i)}
y={yFor(w.distance)}
width={barW}
height={BASE_Y - yFor(w.distance)}
rx="1.5"
fill={isActive ? "#1d4ed8" : "#3b82f6"}
/>
)}
{/* full-height hover hit area */}
<rect
x={PAD.left + i * colW}
y={PAD.top}
width={colW}
height={PLOT_H}
fill="transparent"
onMouseEnter={() => setHover(i)}
>
<title>{`${fmtWeek(w.weekStart)} · ${formatDistanceKm(w.distance)}`}</title>
</rect>
</g>
);
})}
</svg>
<div className="flex justify-between px-px text-[10px] text-gray-400">
<span>{firstLabel}</span>
<span>{lastLabel}</span>
</div>
</div>
);
}

View file

@ -1,38 +0,0 @@
import { useEffect } from "react";
import { useRevalidator } from "react-router";
/**
* Live-updates a route/activity detail page when its async surface backfill
* completes. Subscribes to `/api/events` and, on a `surface_breakdown` event
* matching this row, re-runs the loader (so the bars appear without a reload).
*
* Enable only while it's worth it i.e. the viewer is the owner (the backfill
* emits to the owner's user stream) and the breakdown isn't present yet. Once
* the breakdown lands, the loader revalidates, `enabled` flips false, and the
* connection is torn down.
*/
export function useSurfaceBackfillUpdates(
kind: "route" | "activity",
id: string,
enabled: boolean,
): void {
const revalidator = useRevalidator();
useEffect(() => {
if (!enabled) return;
if (typeof EventSource === "undefined") return;
const es = new EventSource("/api/events");
const onEvent = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as { kind?: string; id?: string };
if (parsed.kind === kind && parsed.id === id) revalidator.revalidate();
} catch {
// Malformed payload — ignore.
}
};
es.addEventListener("surface_breakdown", onEvent as EventListener);
return () => {
es.removeEventListener("surface_breakdown", onEvent as EventListener);
es.close();
};
}, [kind, id, enabled, revalidator]);
}

View file

@ -1,39 +0,0 @@
import { useEffect, useState } from "react";
/**
* Subscribes to /api/events for live `notifications.unread` updates.
* Returns the live unread count, seeded with the loader-provided
* baseline so the badge renders correctly before the SSE handshake
* completes. Native EventSource handles reconnects with the
* server-suggested `retry:` interval.
*/
export function useUnreadNotifications(initialCount: number, signedIn: boolean): number {
const [count, setCount] = useState(initialCount);
// Keep the in-component count in sync if the loader-provided baseline
// changes (e.g., on navigation).
useEffect(() => {
setCount(initialCount);
}, [initialCount]);
useEffect(() => {
if (!signedIn) return;
if (typeof EventSource === "undefined") return;
const es = new EventSource("/api/events");
const onUnread = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as { count: number };
if (typeof parsed.count === "number") setCount(parsed.count);
} catch {
// Malformed payload — ignore.
}
};
es.addEventListener("notifications.unread", onUnread as EventListener);
return () => {
es.removeEventListener("notifications.unread", onUnread as EventListener);
es.close();
};
}, [signedIn]);
return count;
}

View file

@ -1,26 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { ensureUserKeypair, listUsersWithoutKeypair } from "../lib/federation-keys.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* One-shot backfill: generate a federation keypair for every user who
* predates federation (spec: "Existing-user backfill at deploy"). The
* server enqueues this once at startup whenever FEDERATION_ENABLED is
* on (singleton-keyed, so repeat startups don't stack runs); each run
* only touches users whose public_key IS NULL, so re-runs are no-ops.
* New users get keys at registration and never appear in this workload.
*/
export const backfillUserKeypairsJob = defineJournalJob({
name: "backfill-user-keypairs",
retryLimit: 3,
expireInSeconds: 300,
async handler() {
const ids = await listUsersWithoutKeypair();
let generated = 0;
for (const id of ids) {
if (await ensureUserKeypair(id)) generated++;
}
logger.info({ candidates: ids.length, generated }, "backfill-user-keypairs");
return { candidates: ids.length, generated };
},
});

View file

@ -1,31 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { lt } from "drizzle-orm";
import { consumedJwtJti } from "@trails-cool/db/schema/journal";
import { getDb } from "../lib/db.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily cleanup for the JWT replay-protection table. Each row was
* inserted by `verifyRouteToken` to mark a token as consumed; once
* the token's `exp` claim has passed, the row is no longer useful
* (the JWT itself would fail signature verification before reaching
* the consume step). Bound the table to keep it tiny.
*
* See planner-audit #2 Phase B.
*/
export const consumedJtiSweepJob = defineJournalJob({
name: "consumed-jti-sweep",
cron: "45 3 * * *", // daily at 03:45 UTC (offset from notifications-purge)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const db = getDb();
const result = await db
.delete(consumedJwtJti)
.where(lt(consumedJwtJti.expiresAt, new Date()))
.returning({ jti: consumedJwtJti.jti });
const purged = result.length;
logger.info({ purged }, "consumed-jti-sweep");
return { purged };
},
});

View file

@ -1,134 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { and, eq } from "drizzle-orm";
import { activities, users } from "@trails-cool/db/schema/journal";
import { getDb } from "../lib/db.ts";
import { getOrigin } from "../lib/config.server.ts";
import { getFederation } from "../lib/federation.server.ts";
import {
activityToCreate,
activityToDelete,
type FederatableActivity,
} from "../lib/federation-objects.server.ts";
import {
getCachedRemoteActor,
upsertRemoteActor,
type DeliveryPayload,
} from "../lib/federation-delivery.server.ts";
import { logger } from "../lib/logger.server.ts";
import { federationDeliveryTotal } from "../lib/metrics.server.ts";
/**
* Outbound pacing (spec 5.5): never exceed 1 request/second per remote
* host. In-process map is sufficient pg-boss works this queue
* sequentially in the single journal process.
*/
const lastSendPerHost = new Map<string, number>();
const MIN_INTERVAL_MS = 1000;
async function paceHost(host: string): Promise<void> {
const last = lastSendPerHost.get(host) ?? 0;
const wait = last + MIN_INTERVAL_MS - Date.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
lastSendPerHost.set(host, Date.now());
}
/**
* Deliver one activity to one remote follower's inbox (spec 5.3/5.4).
* One job per (activity, recipient) so each delivery retries with
* exponential backoff independently (configured at enqueue time
* see enqueueActivityDeliveries). A thrown error marks the attempt
* failed and pg-boss retries; exhausting the budget is the permanent
* failure, logged by the final catch.
*/
export const deliverActivityJob = defineJournalJob({
name: "deliver-activity",
expireInSeconds: 60,
async handler(jobs) {
for (const job of jobs) {
const p = job.data;
try {
const outcome = await deliverOne(p);
federationDeliveryTotal.inc({ outcome });
} catch (err) {
federationDeliveryTotal.inc({ outcome: "failed" });
logger.warn(
{ err, action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
"deliver-activity attempt failed (pg-boss will retry until budget exhausted)",
);
throw err;
}
}
},
});
async function deliverOne(p: DeliveryPayload): Promise<"delivered" | "skipped"> {
const federation = getFederation();
const ctx = federation.createContext(new URL(getOrigin()), undefined);
// Build the activity to send. For `create`, re-read the row at
// delivery time: if it was deleted or un-publicized since enqueue,
// skip rather than leak.
let activity;
if (p.action === "create") {
const db = getDb();
const [row] = await db
.select()
.from(activities)
.where(and(eq(activities.id, p.activityId!), eq(activities.visibility, "public")))
.limit(1);
if (!row) {
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
return "skipped";
}
// Spec 9.3: flipping the profile to private stops federation — also
// for deliveries already enqueued when the flip happened.
const [owner] = await db
.select({ profileVisibility: users.profileVisibility })
.from(users)
.where(eq(users.username, p.ownerUsername))
.limit(1);
if (!owner || owner.profileVisibility !== "public") {
logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer public; skipping");
return "skipped";
}
activity = activityToCreate(row as FederatableActivity, p.ownerUsername);
} else {
activity = activityToDelete(p.objectIri, p.ownerUsername);
}
// Resolve the recipient's inbox: cached remote_actors row first,
// actor-document fetch as fallback (which also primes the cache).
const recipientIri = new URL(p.recipientActorIri);
let inboxUrl: URL;
const cached = await getCachedRemoteActor(p.recipientActorIri);
if (cached?.inboxUrl) {
inboxUrl = new URL(cached.inboxUrl);
} else {
await paceHost(recipientIri.host);
const actor = await ctx.lookupObject(recipientIri);
const fetchedInbox =
actor != null && "inboxId" in actor ? (actor.inboxId as URL | null) : null;
if (!fetchedInbox) {
throw new Error(`deliver-activity: cannot resolve inbox for ${p.recipientActorIri}`);
}
inboxUrl = fetchedInbox;
await upsertRemoteActor({
actorIri: p.recipientActorIri,
inboxUrl: inboxUrl.href,
domain: recipientIri.host,
});
}
await paceHost(inboxUrl.host);
await ctx.sendActivity(
// Identifier and username are the same thing in our actor model.
{ identifier: p.ownerUsername },
{ id: recipientIri, inboxId: inboxUrl },
activity,
);
logger.info(
{ action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
"deliver-activity: delivered",
);
return "delivered";
}

View file

@ -1,4 +1,4 @@
import { defineJournalJob } from "./payloads.ts";
import type { JobDefinition } from "@trails-cool/jobs";
import {
DEMO_BACKFILL_TARGET,
DEMO_DAILY_CAP,
@ -21,7 +21,7 @@ import { logger } from "../lib/logger.server.ts";
* 3. Otherwise apply the decide-to-walk gate (local hour + p=0.09) and
* daily cap; on pass, insert one route+activity via `generateOneWalk`.
*/
export const demoBotGenerateJob = defineJournalJob({
export const demoBotGenerateJob: JobDefinition = {
name: "demo-bot-generate",
cron: "0,30 * * * *",
retryLimit: 1,
@ -57,4 +57,4 @@ export const demoBotGenerateJob = defineJournalJob({
await refreshDemoBotGauges();
return { mode: "single", routeId: id };
},
});
};

View file

@ -1,4 +1,4 @@
import { defineJournalJob } from "./payloads.ts";
import type { JobDefinition } from "@trails-cool/jobs";
import {
demoRetentionDays,
isDemoBotEnabled,
@ -11,7 +11,7 @@ import { logger } from "../lib/logger.server.ts";
* Daily prune. Deletes synthetic rows older than
* `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users.
*/
export const demoBotPruneJob = defineJournalJob({
export const demoBotPruneJob: JobDefinition = {
name: "demo-bot-prune",
cron: "15 3 * * *",
retryLimit: 1,
@ -24,4 +24,4 @@ export const demoBotPruneJob = defineJournalJob({
await refreshDemoBotGauges();
return { days, ...counts };
},
});
};

View file

@ -1,22 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { sweepProcessedActivities } from "../lib/federation-replay.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily cleanup of federation_processed_activities rows older than 30
* days (spec: federation-operations "Inbound replay defense"). Replays of
* activities that old are already rejected by HTTP-signature date
* freshness, so the dedup record is no longer needed; this keeps the
* table bounded.
*/
export const federationDedupSweepJob = defineJournalJob({
name: "federation-dedup-sweep",
cron: "30 4 * * *", // daily at 04:30 UTC (offset from federation-kv-sweep at 04:15)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const purged = await sweepProcessedActivities();
logger.info({ purged }, "federation-dedup-sweep");
return { purged };
},
});

View file

@ -1,20 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { PostgresKvStore } from "../lib/federation-kv.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily cleanup of expired federation_kv rows (Fedify replay-protection
* nonces and caches carry TTLs; reads already filter expired rows, this
* keeps the table from growing unbounded).
*/
export const federationKvSweepJob = defineJournalJob({
name: "federation-kv-sweep",
cron: "15 4 * * *", // daily at 04:15 UTC (offset from the other sweeps)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const purged = await new PostgresKvStore().sweepExpired();
logger.info({ purged }, "federation-kv-sweep");
return { purged };
},
});

View file

@ -1,30 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { logger } from "../lib/logger.server.ts";
import { runGarminActivityImport } from "../lib/connected-services/providers/garmin/import.server.ts";
// Garmin webhook notifications enqueue here (spec: garmin-import,
// "Push-notification activity import"): the webhook answers 200
// immediately and this job does the slow part — authorized file
// download, FIT→GPX, activity creation. Backfill bursts deliver many
// notifications at once; the queue absorbs them and pg-boss retries
// transient download failures.
export const garminImportActivityJob = defineJournalJob({
name: "garmin-import-activity",
retryLimit: 3,
expireInSeconds: 300,
async handler(jobs) {
const batch = Array.isArray(jobs) ? jobs : [jobs];
for (const job of batch) {
const data = job.data;
try {
await runGarminActivityImport(data);
} catch (err) {
logger.warn(
{ err, externalId: data.externalId },
"garmin-import-activity failed (pg-boss will retry)",
);
throw err;
}
}
},
});

View file

@ -1,42 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { and, lt, inArray, count } from "drizzle-orm";
import { getDb } from "../lib/db.ts";
import { importBatches, type ImportBatchStatus } from "@trails-cool/db/schema/journal";
import { logger } from "../lib/logger.server.ts";
const STALE_MS = 10 * 60 * 1000;
const STALE_STATUSES: ImportBatchStatus[] = ["pending", "running"];
export const importBatchesSweepJob = defineJournalJob({
name: "import-batches-sweep",
cron: "* * * * *",
retryLimit: 0,
expireInSeconds: 55,
async handler() {
const db = getDb();
const cutoff = new Date(Date.now() - STALE_MS);
const staleFilter = and(
inArray(importBatches.status, STALE_STATUSES),
lt(importBatches.startedAt, cutoff),
);
// Skip the write when nothing is stale to avoid an unconditional UPDATE every minute.
const rows = await db
.select({ staleCount: count() })
.from(importBatches)
.where(staleFilter);
if ((rows[0]?.staleCount ?? 0) === 0) return;
const result = await db
.update(importBatches)
.set({
status: "failed" satisfies ImportBatchStatus,
errorMessage: "Import timed out — the server may have restarted mid-import. Click 'Run again' to retry.",
completedAt: new Date(),
})
.where(staleFilter)
.returning({ id: importBatches.id });
logger.info({ count: result.length }, "import-batches-sweep: marked stale batches as failed");
},
});

View file

@ -1,62 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../lib/logger.server.ts", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const withFreshCredentials = vi.fn();
vi.mock("../lib/connected-services/manager.ts", () => ({
withFreshCredentials: (...args: unknown[]) => withFreshCredentials(...args),
}));
const runKomootBulkImport = vi.fn();
const markBatchFailed = vi.fn();
vi.mock("../lib/komoot-bulk-import.server.ts", () => ({
runKomootBulkImport: (...args: unknown[]) => runKomootBulkImport(...args),
markBatchFailed: (...args: unknown[]) => markBatchFailed(...args),
}));
import { komootBulkImportJob } from "./komoot-bulk-import.ts";
type HandlerJobs = Parameters<typeof komootBulkImportJob.handler>[0];
function jobWith(data: unknown): HandlerJobs {
return [{ id: "j1", data }] as unknown as HandlerJobs;
}
const PAYLOAD = { batchId: "batch-1", userId: "user-1", serviceId: "svc-1" };
describe("komoot-bulk-import job", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves credentials through the manager — only the serviceId crosses the queue", async () => {
const creds = { mode: "public", komootUserId: "k1" };
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn(creds));
runKomootBulkImport.mockResolvedValue(undefined);
await komootBulkImportJob.handler(jobWith(PAYLOAD));
expect(withFreshCredentials).toHaveBeenCalledWith("svc-1", expect.any(Function));
expect(runKomootBulkImport).toHaveBeenCalledWith("batch-1", "user-1", creds);
expect(markBatchFailed).not.toHaveBeenCalled();
});
it("marks the batch failed and rethrows when credential resolution fails", async () => {
withFreshCredentials.mockRejectedValue(new Error("needs relink"));
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("needs relink");
expect(runKomootBulkImport).not.toHaveBeenCalled();
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "needs relink");
});
it("also marks failed when the import itself rejects (markBatchFailed is a no-op on terminal batches)", async () => {
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn({ mode: "public" }));
runKomootBulkImport.mockRejectedValue(new Error("komoot 500"));
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("komoot 500");
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "komoot 500");
});
});

View file

@ -1,36 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { logger } from "../lib/logger.server.ts";
import { withFreshCredentials } from "../lib/connected-services/manager.ts";
import {
markBatchFailed,
runKomootBulkImport,
type KomootCreds,
} from "../lib/komoot-bulk-import.server.ts";
export const komootBulkImportJob = defineJournalJob({
name: "komoot-bulk-import",
retryLimit: 1,
expireInSeconds: 1800,
async handler(jobs) {
const batch = Array.isArray(jobs) ? jobs : [jobs];
for (const job of batch) {
const { batchId, userId, serviceId } = job.data;
logger.info({ batchId, userId }, "komoot bulk import job started");
try {
// Credentials are resolved through the ConnectedServiceManager at
// execution time — the payload carries only the serviceId, so
// nothing credential-shaped sits in the job table and a relink
// between enqueue and execution is picked up here.
await withFreshCredentials(serviceId, (creds) =>
runKomootBulkImport(batchId, userId, creds as KomootCreds),
);
} catch (err) {
// runKomootBulkImport marks its own failures; this covers errors
// before it ran (service missing/not active/needs relink), where
// the batch would otherwise stay "pending" forever.
await markBatchFailed(batchId, err instanceof Error ? err.message : String(err));
throw err;
}
}
},
});

View file

@ -1,112 +0,0 @@
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { eq, sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { getDb } from "../lib/db.ts";
import { activities, follows, users } from "@trails-cool/db/schema/journal";
import { fanout } from "./notifications-fanout.ts";
import { listForUser } from "../lib/notifications.server.ts";
// Same opt-in flag as the rest of the notifications integration tests.
const runIntegration = process.env.NOTIFICATIONS_INTEGRATION === "1";
async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) {
const db = getDb();
const id = randomUUID();
await db.insert(users).values({
id,
email: `${opts.username}@example.test`,
username: opts.username,
domain: "test.local",
profileVisibility: opts.profileVisibility ?? "public",
});
return id;
}
async function makeFollow(followerId: string, followedId: string, opts: { accepted: boolean } = { accepted: true }) {
const db = getDb();
const followedUsername = (await db.select({ u: users.username }).from(users).where(eq(users.id, followedId)))[0]!.u;
await db.insert(follows).values({
id: randomUUID(),
followerId,
followedActorIri: `https://test.local/users/${followedUsername}`,
followedUserId: followedId,
acceptedAt: opts.accepted ? new Date() : null,
});
}
async function makeActivity(ownerId: string, visibility: "public" | "private" | "unlisted" = "public", name = "Walk") {
const db = getDb();
const id = randomUUID();
await db.insert(activities).values({
id,
ownerId,
name,
visibility,
});
return id;
}
async function wipe() {
const db = getDb();
await db.execute(sql`DELETE FROM journal.notifications WHERE recipient_user_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.activities WHERE owner_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`);
}
describe.skipIf(!runIntegration)("notifications-fanout integration", () => {
beforeAll(async () => {
const db = getDb();
await db.execute(sql`SELECT 1 FROM journal.notifications LIMIT 0`);
});
afterEach(wipe);
it("inserts exactly one row per accepted follower; pending followers are skipped", async () => {
const owner = await makeUser({ username: `nf_o_${Date.now()}` });
const a1 = await makeUser({ username: `nf_a1_${Date.now()}` });
const a2 = await makeUser({ username: `nf_a2_${Date.now()}` });
const p1 = await makeUser({ username: `nf_p1_${Date.now()}` });
const p2 = await makeUser({ username: `nf_p2_${Date.now()}` });
await makeFollow(a1, owner, { accepted: true });
await makeFollow(a2, owner, { accepted: true });
await makeFollow(p1, owner, { accepted: false });
await makeFollow(p2, owner, { accepted: false });
const activityId = await makeActivity(owner, "public", "Public Walk");
await fanout(activityId);
expect((await listForUser(a1)).rows.length).toBe(1);
expect((await listForUser(a2)).rows.length).toBe(1);
expect((await listForUser(p1)).rows.length).toBe(0);
expect((await listForUser(p2)).rows.length).toBe(0);
const a1Rows = (await listForUser(a1)).rows;
expect(a1Rows[0]?.type).toBe("activity_published");
expect((a1Rows[0]?.payload as { activityName?: string })?.activityName).toBe("Public Walk");
});
it("skips fan-out for non-public activities (defense in depth)", async () => {
const owner = await makeUser({ username: `nf_np_o_${Date.now()}` });
const f = await makeUser({ username: `nf_np_f_${Date.now()}` });
await makeFollow(f, owner, { accepted: true });
const privateAct = await makeActivity(owner, "private");
const unlistedAct = await makeActivity(owner, "unlisted");
await fanout(privateAct);
await fanout(unlistedAct);
expect((await listForUser(f)).rows.length).toBe(0);
});
it("is idempotent under retry — second fanout doesn't double-insert", async () => {
const owner = await makeUser({ username: `nf_id_o_${Date.now()}` });
const f = await makeUser({ username: `nf_id_f_${Date.now()}` });
await makeFollow(f, owner, { accepted: true });
const activityId = await makeActivity(owner, "public");
await fanout(activityId);
await fanout(activityId);
expect((await listForUser(f)).rows.length).toBe(1);
});
});

View file

@ -1,99 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { and, eq, isNotNull } from "drizzle-orm";
import { getDb } from "../lib/db.ts";
import { activities, follows, users } from "@trails-cool/db/schema/journal";
import { createNotification } from "../lib/notifications.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Fan out an `activity_published` notification to every accepted
* follower of the activity's owner. Idempotent at the DB level via the
* `(recipient_user_id, type, subject_id)` unique partial index a
* retry after partial failure won't double-insert.
*/
export const notificationsFanoutJob = defineJournalJob({
name: "notifications-fanout",
retryLimit: 3,
expireInSeconds: 300,
async handler(job) {
// pg-boss v12: `job` may be an array (batch) per its docs; we
// process whichever shape we get.
const batch = Array.isArray(job) ? job : [job];
for (const item of batch) {
await fanout(item.data.activityId);
}
},
});
export async function fanout(activityId: string): Promise<void> {
const db = getDb();
// Load the activity + owner info needed for the payload snapshot.
const [row] = await db
.select({
id: activities.id,
name: activities.name,
visibility: activities.visibility,
ownerId: activities.ownerId,
ownerUsername: users.username,
ownerDisplayName: users.displayName,
})
.from(activities)
.innerJoin(users, eq(activities.ownerId, users.id))
.where(eq(activities.id, activityId));
if (!row) {
logger.warn({ activityId }, "fanout: activity not found, skipping");
return;
}
// Remote-ingested rows have no local owner and never fan out locally
// (the users innerJoin already excludes them; this narrows the type).
if (row.ownerId === null) return;
// Defense in depth — the create-side guard already filters this, but
// recheck here so the job doesn't mistakenly fan out a row that was
// later flipped to private/unlisted before the job ran.
if (row.visibility !== "public") {
logger.info({ activityId, visibility: row.visibility }, "fanout: skipping non-public activity");
return;
}
// Find every accepted *local* follower of the owner. Remote
// followers (follower_id NULL, follower_actor_iri set) get the
// activity via federation push delivery, not via notifications.
const recipients = await db
.select({ followerId: follows.followerId })
.from(follows)
.where(
and(
eq(follows.followedUserId, row.ownerId),
isNotNull(follows.acceptedAt),
isNotNull(follows.followerId),
),
);
let inserted = 0;
for (const r of recipients) {
// Don't notify the owner about their own activity if they happen
// to follow themselves (shouldn't happen — followUser refuses
// self-follow — but defense in depth).
if (r.followerId === row.ownerId || r.followerId === null) continue;
const created = await createNotification({
type: "activity_published",
recipientUserId: r.followerId,
actorUserId: row.ownerId,
subjectId: row.id,
payload: {
activityId: row.id,
activityName: row.name,
ownerUsername: row.ownerUsername,
ownerDisplayName: row.ownerDisplayName,
},
});
if (created) inserted += 1;
}
logger.info(
{ activityId, recipients: recipients.length, inserted },
"notifications-fanout completed",
);
}

View file

@ -1,21 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { purgeReadOlderThan } from "../lib/notifications.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily retention pass. Drops notifications whose `read_at` is older
* than 90 days; unread rows are kept indefinitely so users never miss
* an event.
*/
export const notificationsPurgeJob = defineJournalJob({
name: "notifications-purge",
cron: "30 3 * * *", // daily at 03:30 UTC (offset from demo-bot-prune to spread load)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const days = 90;
const purged = await purgeReadOlderThan(days);
logger.info({ days, purged }, "notifications-purge");
return { days, purged };
},
});

View file

@ -1,47 +0,0 @@
import { describe, it, expect } from "vitest";
// Importing every job module pulls in their (transitively heavy)
// server deps; mock the leaf modules with side effects so this stays a
// unit test of the registry wiring.
import { vi } from "vitest";
vi.mock("../lib/db.ts", () => ({ getDb: vi.fn() }));
vi.mock("../lib/logger.server.ts", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
describe("job registry", () => {
it("every job's queue name is unique and a key of JobPayloads", async () => {
const modules = await Promise.all([
import("./backfill-user-keypairs.ts"),
import("./consumed-jti-sweep.ts"),
import("./deliver-activity.ts"),
import("./demo-bot-generate.ts"),
import("./demo-bot-prune.ts"),
import("./federation-kv-sweep.ts"),
import("./garmin-import-activity.ts"),
import("./import-batches-sweep.ts"),
import("./komoot-bulk-import.ts"),
import("./notifications-fanout.ts"),
import("./notifications-purge.ts"),
import("./poll-remote-actor.ts"),
import("./poll-remote-outboxes.ts"),
import("./send-welcome-email.ts"),
]);
const names = modules.flatMap((m) =>
Object.values(m as Record<string, unknown>)
.filter(
(v): v is { name: string; handler: unknown } =>
typeof v === "object" && v !== null && "handler" in v && "name" in v,
)
.map((def) => def.name),
);
expect(names).toHaveLength(14);
expect(new Set(names).size).toBe(names.length);
// pg-boss v11+ queue-name constraint, mirrored from packages/jobs
for (const name of names) {
expect(name).toMatch(/^[A-Za-z0-9_.-]+$/);
}
});
});

View file

@ -1,43 +0,0 @@
import { defineJob, type JobDefinition, type TypedJobDefinition } from "@trails-cool/jobs";
import type { DeliveryPayload } from "../lib/federation-delivery.server.ts";
import type { GarminImportData } from "../lib/connected-services/providers/garmin/import.server.ts";
/**
* Every journal job queue and its payload shape, in one place. The
* typed `enqueue` / `enqueueOptional` in boss.server.ts and the
* `defineJournalJob` helper below both key off this map, so an enqueue
* site and its handler cannot drift apart, and a queue-name typo is a
* compile error instead of an orphaned queue.
*
* `void` marks cron-only jobs that are never enqueued with data.
*/
export interface JobPayloads {
"backfill-user-keypairs": Record<string, never>;
"consumed-jti-sweep": void;
"deliver-activity": DeliveryPayload;
"demo-bot-generate": void;
"demo-bot-prune": void;
"federation-dedup-sweep": void;
"federation-kv-sweep": void;
"garmin-import-activity": GarminImportData;
"import-batches-sweep": void;
"komoot-bulk-import": { batchId: string; userId: string; serviceId: string };
"notifications-fanout": { activityId: string };
"notifications-purge": void;
"poll-remote-actor": { actorIri: string };
"poll-remote-outboxes": void;
"send-welcome-email": { email: string; username: string };
"surface-backfill": { kind: "route" | "activity"; id: string };
}
export type JobName = keyof JobPayloads;
/**
* defineJob, constrained to the journal's queue map: the name must be
* a known queue and the handler's payload type follows from it.
*/
export function defineJournalJob<K extends JobName>(
definition: TypedJobDefinition<JobPayloads[K]> & { name: K },
): JobDefinition {
return defineJob<JobPayloads[K]>(definition);
}

View file

@ -1,23 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { pollRemoteActor } from "../lib/federation-ingest.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Poll one remote trails actor's outbox (spec §7). Enqueued by the
* inbox Accept(Follow) listener (first poll, 7.5) and fanned out by
* the poll-remote-outboxes cron sweep (7.1).
*/
export const pollRemoteActorJob = defineJournalJob({
name: "poll-remote-actor",
retryLimit: 2,
expireInSeconds: 120,
async handler(jobs) {
for (const job of jobs) {
// Defensive: jobs enqueued before the typed seam may carry no data.
const actorIri = job.data?.actorIri;
if (!actorIri) continue;
const result = await pollRemoteActor(actorIri);
logger.info({ actorIri, result }, "poll-remote-actor");
}
},
});

View file

@ -1,25 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { listActorsDuePolling } from "../lib/federation-ingest.server.ts";
import { enqueueOptional } from "../lib/boss.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Cron sweep (spec 7.1): every 5 minutes, find remote trails actors
* that at least one local user follows (accepted) and that haven't
* been polled within the last hour, and fan out one poll-remote-actor
* job each. Per-host pacing lives in the poll itself.
*/
export const pollRemoteOutboxesJob = defineJournalJob({
name: "poll-remote-outboxes",
cron: "*/5 * * * *",
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const due = await listActorsDuePolling();
for (const actorIri of due) {
await enqueueOptional("poll-remote-actor", { actorIri }, { source: "poll-remote-outboxes" });
}
if (due.length > 0) logger.info({ due: due.length }, "poll-remote-outboxes sweep");
return { due: due.length };
},
});

View file

@ -1,27 +0,0 @@
import { defineJournalJob } from "./payloads.ts";
import { sendWelcome } from "../lib/email.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Queue-backed welcome email send. The old code did
* `sendWelcome(...).catch(log)` inline, which silently dropped failures.
* pg-boss retries on transient failure (3 attempts) and surfaces persistent
* failures via the dead-letter queue / logs.
*/
export const sendWelcomeEmailJob = defineJournalJob({
name: "send-welcome-email",
retryLimit: 3,
expireInSeconds: 120,
async handler(job) {
const batch = Array.isArray(job) ? job : [job];
for (const item of batch) {
const { email, username } = item.data;
try {
await sendWelcome(email, username);
} catch (err) {
logger.error({ err, email }, "send-welcome-email job failed");
throw err;
}
}
},
});

View file

@ -1,56 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execute = vi.fn();
vi.mock("../lib/db.ts", () => ({ getDb: () => ({ execute }) }));
const fetchWaysInBbox = vi.fn();
vi.mock("../lib/overpass-ways.server.ts", () => ({ fetchWaysInBbox: (...a: unknown[]) => fetchWaysInBbox(...a) }));
const emitTo = vi.fn();
vi.mock("../lib/events.server.ts", () => ({ emitTo: (...a: unknown[]) => emitTo(...a) }));
vi.mock("../lib/logger.server.ts", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }));
import { runSurfaceBackfill } from "./surface-backfill.ts";
const lineString = (coords: number[][]) => JSON.stringify({ type: "LineString", coordinates: coords });
beforeEach(() => {
execute.mockReset();
fetchWaysInBbox.mockReset();
emitTo.mockReset();
});
describe("runSurfaceBackfill", () => {
it("matches ways, stores the breakdown, and emits to the owner", async () => {
execute.mockResolvedValueOnce([
{ geojson: lineString([[0, 0], [0.001, 0], [0.002, 0]]), ownerId: "user-1", hasBreakdown: false },
]); // SELECT
execute.mockResolvedValueOnce(undefined); // UPDATE
fetchWaysInBbox.mockResolvedValue([
{ highway: "residential", surface: "asphalt", geometry: [{ lat: 0, lon: 0 }, { lat: 0, lon: 0.01 }] },
]);
await runSurfaceBackfill("activity", "act-1");
expect(fetchWaysInBbox).toHaveBeenCalledOnce();
expect(execute).toHaveBeenCalledTimes(2); // SELECT + UPDATE
expect(emitTo).toHaveBeenCalledWith("user-1", "surface_breakdown", { kind: "activity", id: "act-1" });
});
it("skips a row that already has a breakdown", async () => {
execute.mockResolvedValueOnce([{ geojson: lineString([[0, 0], [1, 0]]), ownerId: "u", hasBreakdown: true }]);
await runSurfaceBackfill("route", "r-1");
expect(fetchWaysInBbox).not.toHaveBeenCalled();
expect(execute).toHaveBeenCalledTimes(1); // only the SELECT
expect(emitTo).not.toHaveBeenCalled();
});
it("does not store or emit when Overpass returns no ways", async () => {
execute.mockResolvedValueOnce([{ geojson: lineString([[0, 0], [0.001, 0]]), ownerId: "u", hasBreakdown: false }]);
fetchWaysInBbox.mockResolvedValue([]);
await runSurfaceBackfill("activity", "a-2");
expect(execute).toHaveBeenCalledTimes(1); // SELECT only
expect(emitTo).not.toHaveBeenCalled();
});
});

View file

@ -1,101 +0,0 @@
import { sql } from "drizzle-orm";
import { defineJournalJob } from "./payloads.ts";
import { getDb } from "../lib/db.ts";
import { computeSurfaceBreakdown } from "@trails-cool/map-core";
import { fetchWaysInBbox, type Bbox } from "../lib/overpass-ways.server.ts";
import { matchSurfaces } from "../lib/surface-match.server.ts";
import { emitTo } from "../lib/events.server.ts";
import { logger } from "../lib/logger.server.ts";
// Cap coordinates fed to the matcher; the breakdown still weights by the actual
// segment length, so downsampling barely shifts proportions.
const MAX_COORDS = 250;
const BBOX_PAD_DEG = 0.0008; // ~80 m, so ways just off the line are considered
/**
* Derive a surface/waytype breakdown for a route/activity that has geometry but
* no breakdown yet (imports, uploads, pre-existing rows), by map-matching its
* geometry to OSM ways via Overpass. Idempotent (skips if already set),
* best-effort (Overpass failure throws pg-boss retries; an empty/oversized
* bbox is a no-op). On success it stores the breakdown and pushes an SSE event
* to the owner so an open detail page fills the bars in live.
*/
export const surfaceBackfillJob = defineJournalJob({
name: "surface-backfill",
retryLimit: 3,
expireInSeconds: 120,
async handler(job) {
const batch = Array.isArray(job) ? job : [job];
for (const item of batch) {
await runSurfaceBackfill(item.data.kind, item.data.id);
}
},
});
export async function runSurfaceBackfill(kind: "route" | "activity", id: string): Promise<void> {
const db = getDb();
const tableName = kind === "route" ? sql`journal.routes` : sql`journal.activities`;
const loaded = (await db.execute(sql`
SELECT ST_AsGeoJSON(geom) AS geojson,
owner_id AS "ownerId",
(surface_breakdown IS NOT NULL) AS "hasBreakdown"
FROM ${tableName} WHERE id = ${id} LIMIT 1
`)) as unknown as Array<{ geojson: string | null; ownerId: string | null; hasBreakdown: boolean }>;
const row = loaded[0];
if (!row) {
logger.warn({ kind, id }, "surface-backfill: row not found");
return;
}
if (row.hasBreakdown) return; // idempotent
if (!row.geojson) return; // no geometry to match
let coords: number[][];
try {
coords = (JSON.parse(row.geojson) as { coordinates?: number[][] }).coordinates ?? [];
} catch {
return;
}
if (coords.length < 2) return;
if (coords.length > MAX_COORDS) {
const stride = Math.ceil(coords.length / MAX_COORDS);
const out: number[][] = [];
for (let i = 0; i < coords.length; i += stride) out.push(coords[i]!);
const last = coords[coords.length - 1]!;
if (out[out.length - 1] !== last) out.push(last);
coords = out;
}
let south = coords[0]![1]!, north = south, west = coords[0]![0]!, east = west;
for (const c of coords) {
const lon = c[0]!, lat = c[1]!;
if (lat < south) south = lat;
if (lat > north) north = lat;
if (lon < west) west = lon;
if (lon > east) east = lon;
}
const bbox: Bbox = {
south: south - BBOX_PAD_DEG,
west: west - BBOX_PAD_DEG,
north: north + BBOX_PAD_DEG,
east: east + BBOX_PAD_DEG,
};
const ways = await fetchWaysInBbox(bbox);
if (ways.length === 0) {
logger.info({ kind, id }, "surface-backfill: no ways (bbox empty/oversized) — skipping");
return;
}
const { surfaces, highways } = matchSurfaces(coords, ways);
const breakdown = computeSurfaceBreakdown(coords, surfaces, highways);
if (Object.keys(breakdown.surface).length === 0 && Object.keys(breakdown.highway).length === 0) return;
await db.execute(sql`
UPDATE ${tableName} SET surface_breakdown = ${JSON.stringify(breakdown)}::jsonb WHERE id = ${id}
`);
if (row.ownerId) emitTo(row.ownerId, "surface_breakdown", { kind, id });
logger.info({ kind, id }, "surface-backfill: stored breakdown");
}

View file

@ -1,134 +1,76 @@
import { randomUUID } from "node:crypto";
import { eq, desc, and, isNotNull, inArray, sql } from "drizzle-orm";
import { unionAll } from "drizzle-orm/pg-core";
import { eq, desc, and, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import type { Visibility, SportType } from "@trails-cool/db/schema/journal";
import { processGpx, writeGeom } from "./gpx-save.server.ts";
import type { ProcessedGpx } from "./gpx-save.server.ts";
import { enqueueOptional } from "./boss.server.ts";
import type { OwnedRef } from "./ownership.server.ts";
import {
enqueueActivityDeliveries,
visibilityTransitionAction,
} from "./federation-delivery.server.ts";
import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { setGeomFromGpx } from "./routes.server.ts";
export interface ActivityInput {
name: string;
description?: string;
sportType?: SportType | null;
gpx?: string;
routeId?: string;
distance?: number | null;
duration?: number | null;
startedAt?: Date | null;
visibility?: Visibility;
synthetic?: boolean;
}
export async function updateActivityVisibility(
ownedActivity: OwnedRef,
id: string,
ownerId: string,
visibility: Visibility,
): Promise<boolean> {
const { id, ownerId } = ownedActivity;
const db = getDb();
// Read the previous visibility first: the federation action depends on
// the *transition*, not the new value alone (a gratuitous Delete
// permanently tombstones the object's URI on Mastodon — see
// visibilityTransitionAction).
const [existing] = await db
.select({ visibility: activities.visibility })
.from(activities)
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
.limit(1);
if (!existing) return false;
await db
const result = await db
.update(activities)
.set({ visibility })
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)));
// Notify followers when an activity becomes public. The unique
// (recipient, type, subject_id) partial index makes the fan-out
// idempotent, so toggling private→public→private→public won't spam
// followers (only the first transition per activity emits).
if (visibility === "public") {
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" });
}
// Federation: Create on (re-)publish, Delete(Tombstone) only when the
// activity actually was public before — remotes never saw anything
// else, and an unnecessary Delete poisons the URI forever.
const action = visibilityTransitionAction(existing.visibility, visibility);
if (action !== null) {
await enqueueActivityDeliveries(ownerId, id, action);
}
return true;
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
.returning({ id: activities.id });
return result.length > 0;
}
export async function createActivity(ownerId: string, input: ActivityInput) {
const db = getDb();
const id = randomUUID();
let processed: ProcessedGpx | null = null;
let distance: number | null = input.distance ?? null;
let elevationGain: number | null = null;
let elevationLoss: number | null = null;
let startedAt: Date | null = input.startedAt ?? null;
const duration: number | null = input.duration ?? null;
if (input.gpx) {
processed = await processGpx(input.gpx);
// GPX-derived distance wins unless it is zero; caller input is the fallback
distance = processed.stats.distance || distance;
elevationGain = processed.stats.elevationGain;
elevationLoss = processed.stats.elevationLoss;
startedAt = startedAt ?? processed.stats.startTime;
try {
const gpxData = await parseGpxAsync(input.gpx);
distance = gpxData.distance || distance;
elevationGain = gpxData.elevation.gain;
elevationLoss = gpxData.elevation.loss;
if (!startedAt && gpxData.tracks[0]?.[0]?.time) {
startedAt = new Date(gpxData.tracks[0][0].time);
}
} catch {
// Continue without stats if GPX parsing fails
}
}
await db.transaction(async (tx) => {
await tx.insert(activities).values({
id,
ownerId,
routeId: input.routeId ?? null,
name: input.name,
description: input.description ?? "",
sportType: input.sportType ?? null,
gpx: input.gpx,
distance,
duration,
elevationGain,
elevationLoss,
startedAt,
...(input.visibility ? { visibility: input.visibility } : {}),
...(input.synthetic ? { synthetic: true } : {}),
});
if (input.gpx && processed) {
await writeGeom(tx, id, "activities", processed.coords);
}
await db.insert(activities).values({
id,
ownerId,
routeId: input.routeId ?? null,
name: input.name,
description: input.description ?? "",
gpx: input.gpx,
distance,
duration,
elevationGain,
elevationLoss,
startedAt,
});
// Public activities at creation also fan out (matches the
// updateActivityVisibility path for the case where visibility is set
// up-front rather than flipped later).
if (input.visibility === "public") {
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" });
// Federation push delivery to accepted remote followers (spec 5.3).
await enqueueActivityDeliveries(ownerId, id, "create");
}
// Activities arrive without surface waytags (bare GPX); kick off the async
// Overpass backfill (route-surface-breakdown Path 2). Skipped for synthetic
// demo content. Best-effort — never blocks creation.
if (input.gpx && !input.synthetic) {
await enqueueOptional(
"surface-backfill",
{ kind: "activity", id },
{ source: "createActivity" },
{ singletonKey: `surface:activity:${id}` },
);
if (input.gpx) {
await setGeomFromGpx(id, "activities", input.gpx);
}
return id;
@ -143,20 +85,11 @@ export async function getActivity(id: string) {
return { ...activity, geojson, importSource };
}
export async function deleteActivity(ownedActivity: OwnedRef): Promise<boolean> {
const { id, ownerId } = ownedActivity;
export async function deleteActivity(id: string, ownerId: string): Promise<boolean> {
const db = getDb();
// The WHERE ownerId clause stays as defense in depth even though the
// OwnedRef brand already proves ownership.
const [activity] = await db.select({ id: activities.id, visibility: activities.visibility }).from(activities)
const [activity] = await db.select({ id: activities.id }).from(activities)
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)));
if (!activity) return false;
// Enqueue the federation retraction *before* the row disappears —
// the Delete payload carries everything it needs (object IRI +
// owner), so it survives the deletion (spec 5.6).
if (activity.visibility === "public") {
await enqueueActivityDeliveries(ownerId, id, "delete");
}
await db.delete(activities).where(eq(activities.id, id));
return true;
}
@ -170,109 +103,13 @@ async function getImportSource(activityId: string): Promise<{ provider: string;
return row ?? null;
}
export interface ActivityStats {
count: number;
/** metres */
distance: number;
/** metres */
elevationGain: number;
/** seconds, elapsed */
duration: number;
/** activities started in the last 28 days */
last4Weeks: number;
}
/**
* Aggregate roll-up for a profile. One indexed aggregate over stored columns
* (no GPX parsing) `owner_id` leads two existing indexes, so this is cheap
* even for power users (see profile-stats design §D1). `publicOnly` scopes the
* roll-up to what a visitor may see; the owner passes `false` for full totals.
*/
export async function getActivityStats(
ownerId: string,
opts: { publicOnly: boolean },
): Promise<ActivityStats> {
export async function listActivities(ownerId: string) {
const db = getDb();
const conditions = [eq(activities.ownerId, ownerId)];
if (opts.publicOnly) conditions.push(eq(activities.visibility, "public"));
const [row] = await db
.select({
count: sql<string>`count(*)`,
distance: sql<string>`coalesce(sum(${activities.distance}), 0)`,
elevationGain: sql<string>`coalesce(sum(${activities.elevationGain}), 0)`,
duration: sql<string>`coalesce(sum(${activities.duration}), 0)`,
last4Weeks: sql<string>`count(*) filter (where coalesce(${activities.startedAt}, ${activities.createdAt}) >= now() - interval '28 days')`,
})
.from(activities)
.where(and(...conditions));
// Postgres returns count/sum as strings via the driver; coerce to numbers.
return {
count: Number(row?.count ?? 0),
distance: Number(row?.distance ?? 0),
elevationGain: Number(row?.elevationGain ?? 0),
duration: Number(row?.duration ?? 0),
last4Weeks: Number(row?.last4Weeks ?? 0),
};
}
export interface WeeklyDistanceBucket {
/** ISO date (YYYY-MM-DD) of the week's Monday */
weekStart: string;
/** metres */
distance: number;
}
/**
* Distance per week for the last `weeks` weeks (oldest newest), gap-filled so
* every week is present (zero when no activity). The contiguous axis is built
* in SQL via `generate_series` + a LEFT JOIN that guarantees the week
* boundaries match Postgres `date_trunc('week', …)` exactly (no JS/Postgres
* boundary drift) and keeps it one cheap query over the owner_id index
* (profile-weekly-distance design §D1D2). `publicOnly` scopes it like
* `getActivityStats`.
*/
export async function getWeeklyDistance(
ownerId: string,
opts: { publicOnly: boolean; weeks?: number },
): Promise<WeeklyDistanceBucket[]> {
const weeks = opts.weeks ?? 12;
const db = getDb();
// Filter conditions live in the LEFT JOIN's ON clause so empty weeks survive.
const visibility = opts.publicOnly ? sql` AND a.visibility = 'public'` : sql``;
const result = await db.execute(sql`
WITH weeks AS (
SELECT generate_series(
date_trunc('week', now()) - (${weeks - 1} * interval '1 week'),
date_trunc('week', now()),
interval '1 week'
) AS wk
)
SELECT to_char(w.wk, 'YYYY-MM-DD') AS week_start,
coalesce(sum(a.distance), 0) AS distance
FROM weeks w
LEFT JOIN journal.activities a
ON date_trunc('week', coalesce(a.started_at, a.created_at)) = w.wk
AND a.owner_id = ${ownerId}${visibility}
GROUP BY w.wk
ORDER BY w.wk
`);
const rows = result as unknown as Array<{ week_start: string; distance: string | number }>;
return rows.map((r) => ({ weekStart: r.week_start, distance: Number(r.distance) }));
}
export async function listActivities(
ownerId: string,
sort: "startedAt" | "addedAt" = "startedAt",
) {
const db = getDb();
const order = sort === "addedAt" ? desc(activities.createdAt) : desc(activities.startedAt);
const rows = await db
.select()
.from(activities)
.where(eq(activities.ownerId, ownerId))
.orderBy(order);
.orderBy(desc(activities.createdAt));
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
@ -284,19 +121,13 @@ export async function listActivities(
* listings (the public profile page); never includes `unlisted` or
* `private` content.
*/
export async function listPublicActivitiesForOwner(
ownerId: string,
sort: "startedAt" | "addedAt" = "startedAt",
limit: number = 100,
) {
export async function listPublicActivitiesForOwner(ownerId: string) {
const db = getDb();
const order = sort === "addedAt" ? desc(activities.createdAt) : desc(activities.startedAt);
const rows = await db
.select()
.from(activities)
.where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public")))
.orderBy(order)
.limit(limit);
.orderBy(desc(activities.createdAt));
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
@ -304,40 +135,24 @@ export async function listPublicActivitiesForOwner(
}
/**
* Social feed (spec: social-federation §8): aggregated activities from
* actors that `followerId` follows with an *accepted* follow local
* users and remote trails actors alike. Reverse-chronological on
* COALESCE(remote_published_at, created_at).
*
* Audience rules:
* - local rows: `visibility = 'public'` only (unlisted/private never
* appear regardless of follow state)
* - remote rows: `audience = 'public'` or `followers-only` the
* latter gated structurally by joining the *viewer's own* accepted
* follow against the originating actor (spec: "Followers-only remote
* content reaches only the right viewer")
* Pending follows contribute nothing (accepted_at IS NOT NULL on both
* branches previously missing on the local branch).
* Social feed: aggregated public activities from users that `followerId`
* follows (accepted only). Reverse-chronological. Joins users for owner
* attribution. Unlisted/private activities never appear, regardless of
* follow state.
*/
export async function listSocialFeed(followerId: string, limit: number = 50) {
const db = getDb();
const local = db
const rows = await db
.select({
id: activities.id,
name: activities.name,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
sortTime: sql<Date>`${activities.createdAt}`.as("sort_time"),
ownerUsername: sql<string | null>`${users.username}`.as("owner_username"),
ownerDisplayName: sql<string | null>`${users.displayName}`.as("owner_display_name"),
ownerDomain: sql<string | null>`${users.domain}`.as("owner_domain"),
externalUrl: sql<string | null>`NULL`.as("external_url"),
remote: sql<boolean>`false`.as("remote"),
ownerUsername: users.username,
ownerDisplayName: users.displayName,
})
.from(activities)
.innerJoin(follows, eq(follows.followedUserId, activities.ownerId))
@ -345,47 +160,14 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
.where(
and(
eq(follows.followerId, followerId),
isNotNull(follows.acceptedAt),
eq(activities.visibility, "public"),
),
);
const remote = db
.select({
id: activities.id,
name: activities.name,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
sortTime: sql<Date>`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"),
ownerUsername: sql<string | null>`${remoteActors.username}`.as("owner_username"),
ownerDisplayName: sql<string | null>`${remoteActors.displayName}`.as("owner_display_name"),
ownerDomain: sql<string | null>`${remoteActors.domain}`.as("owner_domain"),
externalUrl: sql<string | null>`${activities.remoteOriginIri}`.as("external_url"),
remote: sql<boolean>`true`.as("remote"),
})
.from(activities)
.innerJoin(follows, eq(follows.followedActorIri, activities.remoteActorIri))
.leftJoin(remoteActors, eq(activities.remoteActorIri, remoteActors.actorIri))
.where(
and(
eq(follows.followerId, followerId),
isNotNull(follows.acceptedAt),
// public reaches every accepted follower; followers-only is
// already gated by joining the viewer's own accepted follow.
sql`${activities.audience} IN ('public', 'followers-only')`,
),
);
const rows = await unionAll(local, remote)
.orderBy(sql`sort_time DESC`)
)
.orderBy(desc(activities.createdAt))
.limit(limit);
const localIds = rows.filter((r) => !r.remote).map((r) => r.id);
const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map();
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
@ -402,7 +184,6 @@ export async function listRecentPublicActivities(limit: number = 20) {
.select({
id: activities.id,
name: activities.name,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
@ -422,43 +203,39 @@ export async function listRecentPublicActivities(limit: number = 20) {
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
export async function linkActivityToRoute(ownedActivity: OwnedRef, ownedRoute: OwnedRef) {
export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) {
const db = getDb();
await db
.update(activities)
.set({ routeId: ownedRoute.id })
.where(and(eq(activities.id, ownedActivity.id), eq(activities.ownerId, ownedActivity.ownerId)));
.set({ routeId })
.where(eq(activities.id, activityId));
}
export async function createRouteFromActivity(ownedActivity: OwnedRef): Promise<string | null> {
const { id: activityId, ownerId } = ownedActivity;
export async function createRouteFromActivity(activityId: string, ownerId: string): Promise<string | null> {
const db = getDb();
const [activity] = await db
.select()
.from(activities)
.where(and(eq(activities.id, activityId), eq(activities.ownerId, ownerId)));
const [activity] = await db.select().from(activities).where(eq(activities.id, activityId));
if (!activity?.gpx) return null;
const { coords } = await processGpx(activity.gpx);
const routeId = randomUUID();
await db.transaction(async (tx) => {
await tx.insert(routes).values({
id: routeId,
ownerId,
name: `Route from: ${activity.name}`,
description: `Created from activity "${activity.name}"`,
gpx: activity.gpx,
distance: activity.distance,
elevationGain: activity.elevationGain,
elevationLoss: activity.elevationLoss,
});
await writeGeom(tx, routeId, "routes", coords);
await tx.update(activities).set({ routeId }).where(eq(activities.id, activityId));
await db.insert(routes).values({
id: routeId,
ownerId,
name: `Route from: ${activity.name}`,
description: `Created from activity "${activity.name}"`,
gpx: activity.gpx,
distance: activity.distance,
elevationGain: activity.elevationGain,
elevationLoss: activity.elevationLoss,
});
await setGeomFromGpx(routeId, "routes", activity.gpx);
// Link the activity to the new route
await db
.update(activities)
.set({ routeId })
.where(eq(activities.id, activityId));
return routeId;
}
@ -480,19 +257,13 @@ async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise<Map<str
if (ids.length === 0) return map;
try {
const db = getDb();
// Use the query builder for the id list: a raw `ANY(${ids}::text[])`
// makes drizzle expand the array to `($1,$2,...)`, yielding the invalid
// `ANY((...)::text[])` — which throws and silently drops every preview.
const rows = await db
.select({
id: activities.id,
geojson: sql<string | null>`ST_AsGeoJSON(ST_Simplify(${activities.geom}, 0.001))`,
})
.from(activities)
.where(and(inArray(activities.id, ids), isNotNull(activities.geom)));
for (const row of rows) {
if (row.geojson) map.set(row.id, row.geojson);
}
await Promise.all(ids.map(async (id) => {
const result = await db.execute(
sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`,
);
const row = (result as unknown as Array<{ geojson: string }>)[0];
if (row?.geojson) map.set(id, row.geojson);
}));
} catch {
// Fallback: no geojson
}

View file

@ -1,8 +1,8 @@
import { getOrigin } from "./config.server.ts";
// Canonical ActivityPub actor IRI for a local user. Used as the key in
// `follows.followed_actor_iri` so the column shape is identical for local
// and (future) federated follows.
// and (future) federated follows. Reading from `process.env.ORIGIN` keeps
// us aligned with the rest of the auth/federation stack.
export function localActorIri(username: string): string {
return `${getOrigin()}/users/${username}`;
const origin = process.env.ORIGIN ?? "http://localhost:3000";
return `${origin}/users/${username}`;
}

View file

@ -1,70 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TERMS_VERSION } from "./legal";
const mockGetAuthenticatedUser = vi.fn();
vi.mock("./oauth.server.ts", () => ({
getAuthenticatedUser: mockGetAuthenticatedUser,
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe("requireApiUser", () => {
it("returns 401 when unauthenticated", async () => {
mockGetAuthenticatedUser.mockResolvedValue(null);
const { requireApiUser } = await import("./api-guard.server.ts");
try {
await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(401);
const body = await (err as Response).json();
expect(body.code).toBe("UNAUTHORIZED");
}
});
it("returns the user when termsVersion matches", async () => {
const user = { id: "u1", termsVersion: TERMS_VERSION };
mockGetAuthenticatedUser.mockResolvedValue(user);
const { requireApiUser } = await import("./api-guard.server.ts");
const result = await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect(result).toBe(user);
});
it("returns 403 TERMS_OUTDATED when termsVersion is stale", async () => {
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: "2020-01-01" });
const { requireApiUser } = await import("./api-guard.server.ts");
try {
await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(403);
const body = await (err as Response).json();
expect(body.code).toBe("TERMS_OUTDATED");
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
}
});
it("returns 403 TERMS_OUTDATED when termsVersion is null", async () => {
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: null });
const { requireApiUser } = await import("./api-guard.server.ts");
try {
await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(403);
const body = await (err as Response).json();
expect(body.code).toBe("TERMS_OUTDATED");
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
}
});
});

Some files were not shown because too many files have changed in this diff Show more