diff --git a/.claude/skills/cmux-browser/SKILL.md b/.agents/skills/cmux-browser/SKILL.md similarity index 100% rename from .claude/skills/cmux-browser/SKILL.md rename to .agents/skills/cmux-browser/SKILL.md diff --git a/.claude/skills/cmux-browser/agents/openai.yaml b/.agents/skills/cmux-browser/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-browser/agents/openai.yaml rename to .agents/skills/cmux-browser/agents/openai.yaml diff --git a/.claude/skills/cmux-browser/references/authentication.md b/.agents/skills/cmux-browser/references/authentication.md similarity index 100% rename from .claude/skills/cmux-browser/references/authentication.md rename to .agents/skills/cmux-browser/references/authentication.md diff --git a/.claude/skills/cmux-browser/references/commands.md b/.agents/skills/cmux-browser/references/commands.md similarity index 100% rename from .claude/skills/cmux-browser/references/commands.md rename to .agents/skills/cmux-browser/references/commands.md diff --git a/.claude/skills/cmux-browser/references/proxy-support.md b/.agents/skills/cmux-browser/references/proxy-support.md similarity index 100% rename from .claude/skills/cmux-browser/references/proxy-support.md rename to .agents/skills/cmux-browser/references/proxy-support.md diff --git a/.claude/skills/cmux-browser/references/session-management.md b/.agents/skills/cmux-browser/references/session-management.md similarity index 100% rename from .claude/skills/cmux-browser/references/session-management.md rename to .agents/skills/cmux-browser/references/session-management.md diff --git a/.claude/skills/cmux-browser/references/snapshot-refs.md b/.agents/skills/cmux-browser/references/snapshot-refs.md similarity index 100% rename from .claude/skills/cmux-browser/references/snapshot-refs.md rename to .agents/skills/cmux-browser/references/snapshot-refs.md diff --git a/.claude/skills/cmux-browser/references/video-recording.md b/.agents/skills/cmux-browser/references/video-recording.md similarity index 100% rename from .claude/skills/cmux-browser/references/video-recording.md rename to .agents/skills/cmux-browser/references/video-recording.md diff --git a/.claude/skills/cmux-browser/templates/authenticated-session.sh b/.agents/skills/cmux-browser/templates/authenticated-session.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/authenticated-session.sh rename to .agents/skills/cmux-browser/templates/authenticated-session.sh diff --git a/.claude/skills/cmux-browser/templates/capture-workflow.sh b/.agents/skills/cmux-browser/templates/capture-workflow.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/capture-workflow.sh rename to .agents/skills/cmux-browser/templates/capture-workflow.sh diff --git a/.claude/skills/cmux-browser/templates/form-automation.sh b/.agents/skills/cmux-browser/templates/form-automation.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/form-automation.sh rename to .agents/skills/cmux-browser/templates/form-automation.sh diff --git a/.claude/skills/cmux-debug-windows/SKILL.md b/.agents/skills/cmux-debug-windows/SKILL.md similarity index 100% rename from .claude/skills/cmux-debug-windows/SKILL.md rename to .agents/skills/cmux-debug-windows/SKILL.md diff --git a/.claude/skills/cmux-debug-windows/agents/openai.yaml b/.agents/skills/cmux-debug-windows/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-debug-windows/agents/openai.yaml rename to .agents/skills/cmux-debug-windows/agents/openai.yaml diff --git a/.claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/.agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh similarity index 100% rename from .claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh rename to .agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh diff --git a/.claude/skills/cmux-markdown/SKILL.md b/.agents/skills/cmux-markdown/SKILL.md similarity index 100% rename from .claude/skills/cmux-markdown/SKILL.md rename to .agents/skills/cmux-markdown/SKILL.md diff --git a/.claude/skills/cmux-markdown/agents/openai.yaml b/.agents/skills/cmux-markdown/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-markdown/agents/openai.yaml rename to .agents/skills/cmux-markdown/agents/openai.yaml diff --git a/.claude/skills/cmux-markdown/references/commands.md b/.agents/skills/cmux-markdown/references/commands.md similarity index 100% rename from .claude/skills/cmux-markdown/references/commands.md rename to .agents/skills/cmux-markdown/references/commands.md diff --git a/.claude/skills/cmux-markdown/references/live-reload.md b/.agents/skills/cmux-markdown/references/live-reload.md similarity index 100% rename from .claude/skills/cmux-markdown/references/live-reload.md rename to .agents/skills/cmux-markdown/references/live-reload.md diff --git a/.claude/skills/cmux/SKILL.md b/.agents/skills/cmux/SKILL.md similarity index 100% rename from .claude/skills/cmux/SKILL.md rename to .agents/skills/cmux/SKILL.md diff --git a/.claude/skills/cmux/agents/openai.yaml b/.agents/skills/cmux/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux/agents/openai.yaml rename to .agents/skills/cmux/agents/openai.yaml diff --git a/.claude/skills/cmux/references/handles-and-identify.md b/.agents/skills/cmux/references/handles-and-identify.md similarity index 100% rename from .claude/skills/cmux/references/handles-and-identify.md rename to .agents/skills/cmux/references/handles-and-identify.md diff --git a/.claude/skills/cmux/references/panes-surfaces.md b/.agents/skills/cmux/references/panes-surfaces.md similarity index 100% rename from .claude/skills/cmux/references/panes-surfaces.md rename to .agents/skills/cmux/references/panes-surfaces.md diff --git a/.claude/skills/cmux/references/trigger-flash-and-health.md b/.agents/skills/cmux/references/trigger-flash-and-health.md similarity index 100% rename from .claude/skills/cmux/references/trigger-flash-and-health.md rename to .agents/skills/cmux/references/trigger-flash-and-health.md diff --git a/.claude/skills/cmux/references/windows-workspaces.md b/.agents/skills/cmux/references/windows-workspaces.md similarity index 100% rename from .claude/skills/cmux/references/windows-workspaces.md rename to .agents/skills/cmux/references/windows-workspaces.md diff --git a/.claude/skills/crit-cli/SKILL.md b/.agents/skills/crit-cli/SKILL.md similarity index 100% rename from .claude/skills/crit-cli/SKILL.md rename to .agents/skills/crit-cli/SKILL.md diff --git a/.agents/skills/ia-review/SKILL.md b/.agents/skills/ia-review/SKILL.md new file mode 100644 index 0000000..8e323f9 --- /dev/null +++ b/.agents/skills/ia-review/SKILL.md @@ -0,0 +1,275 @@ +--- +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/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 4–6 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. diff --git a/.claude/skills/openspec-apply-change/SKILL.md b/.agents/skills/openspec-apply-change/SKILL.md similarity index 82% rename from .claude/skills/openspec-apply-change/SKILL.md rename to .agents/skills/openspec-apply-change/SKILL.md index d474dc1..1375861 100644 --- a/.claude/skills/openspec-apply-change/SKILL.md +++ b/.agents/skills/openspec-apply-change/SKILL.md @@ -1,16 +1,19 @@ --- 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.2.0" + generatedBy: "1.6.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 ` 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** @@ -30,6 +33,7 @@ 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 +43,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - `contextFiles`: artifact ID -> array of concrete 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 @@ -51,7 +55,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read the files listed in `contextFiles` from the apply instructions output. + Read every file path listed under `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 diff --git a/.claude/skills/openspec-archive-change/SKILL.md b/.agents/skills/openspec-archive-change/SKILL.md similarity index 75% rename from .claude/skills/openspec-archive-change/SKILL.md rename to .agents/skills/openspec-archive-change/SKILL.md index 9b1f851..c0c169d 100644 --- a/.claude/skills/openspec-archive-change/SKILL.md +++ b/.agents/skills/openspec-archive-change/SKILL.md @@ -1,16 +1,19 @@ --- 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.2.0" + generatedBy: "1.6.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 ` 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** @@ -30,6 +33,7 @@ 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 +56,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt. + Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` @@ -67,19 +71,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create the archive directory if it doesn't exist: + Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: ```bash - mkdir -p openspec/changes/archive + mkdir -p "/archive" ``` Generate target name using current date: `YYYY-MM-DD-` **Check if target already exists:** - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move the change directory to archive + - If no: Move `changeRoot` to the archive directory ```bash - mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- + mv "" "/archive/YYYY-MM-DD-" ``` 6. **Display summary** @@ -98,7 +102,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") All artifacts complete. All tasks complete. diff --git a/.claude/skills/openspec-explore/SKILL.md b/.agents/skills/openspec-explore/SKILL.md similarity index 84% rename from .claude/skills/openspec-explore/SKILL.md rename to .agents/skills/openspec-explore/SKILL.md index ffa10ca..771271a 100644 --- a/.claude/skills/openspec-explore/SKILL.md +++ b/.agents/skills/openspec-explore/SKILL.md @@ -1,12 +1,13 @@ --- 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.2.0" + generatedBy: "1.6.0" --- Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. @@ -15,6 +16,8 @@ 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 ` 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 @@ -56,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, │ @@ -102,11 +105,10 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Read existing artifacts for context** - - `openspec/changes//proposal.md` - - `openspec/changes//design.md` - - `openspec/changes//tasks.md` - - etc. +1. **Resolve and read existing artifacts for context** + - Run `openspec status --change "" --json`. + - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. + - Read existing files from `artifactPaths..existingOutputPaths`. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -114,14 +116,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//spec.md` | - | Requirement changed | `specs//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//spec.md` | + | Requirement changed | `specs//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?" @@ -227,7 +229,7 @@ User: A CLI tool that tracks local dev environments You: That changes everything. ┌─────────────────────────────────────────────────┐ - │ CLI TOOL DATA STORAGE │ + │ CLI TOOL DATA STORAGE │ └─────────────────────────────────────────────────┘ Key constraints: diff --git a/.claude/skills/openspec-propose/SKILL.md b/.agents/skills/openspec-propose/SKILL.md similarity index 81% rename from .claude/skills/openspec-propose/SKILL.md rename to .agents/skills/openspec-propose/SKILL.md index d27bc53..716d2d3 100644 --- a/.claude/skills/openspec-propose/SKILL.md +++ b/.agents/skills/openspec-propose/SKILL.md @@ -1,12 +1,13 @@ --- 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.2.0" + generatedBy: "1.6.0" --- Propose a new change - create the change and generate all artifacts in one step. @@ -20,6 +21,8 @@ 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 ` 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** @@ -37,7 +40,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. + This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -46,6 +49,7 @@ 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 +67,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 - - `outputPath`: Where to write the artifact + - `resolvedOutputPath`: Resolved path or pattern 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 + - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.agents/skills/spec-drift-review/SKILL.md b/.agents/skills/spec-drift-review/SKILL.md new file mode 100644 index 0000000..bf1cae9 --- /dev/null +++ b/.agents/skills/spec-drift-review/SKILL.md @@ -0,0 +1,216 @@ +--- +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//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/.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 1–3 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:** + - (touches: ) + + ## High-severity drift + + ### `` + - **Requirement: ** says ; code at `` does . + [link / one-line action] + - … + + ## Medium-severity drift + + ### `` + - + - … + + ## Low-severity drift (wording / cross-refs) + + - ``: + - … + + ## Code-without-spec + + - at `` — should this be in , or a new + spec? Recommendation: <…> + + ## Structural suggestions + + - Split: , + - Merge: + + - New spec: covering + - `CAPABILITIES.md` updates: + ``` + +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. diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md index bf23721..c6cb9b6 100644 --- a/.claude/commands/opsx/apply.md +++ b/.claude/commands/opsx/apply.md @@ -1,12 +1,15 @@ --- 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 ` 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** @@ -26,6 +29,7 @@ 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** @@ -35,7 +39,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - Context file paths (varies by schema) + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state @@ -47,7 +51,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read the files listed in `contextFiles` from the apply instructions output. + Read every file path listed under `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 diff --git a/.claude/commands/opsx/archive.md b/.claude/commands/opsx/archive.md index 5e91608..df8a2f2 100644 --- a/.claude/commands/opsx/archive.md +++ b/.claude/commands/opsx/archive.md @@ -1,12 +1,15 @@ --- 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 ` 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** @@ -26,6 +29,7 @@ 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`:** @@ -48,7 +52,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt. + Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` @@ -63,19 +67,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create the archive directory if it doesn't exist: + Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: ```bash - mkdir -p openspec/changes/archive + mkdir -p "/archive" ``` Generate target name using current date: `YYYY-MM-DD-` **Check if target already exists:** - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move the change directory to archive + - If no: Move `changeRoot` to the archive directory ```bash - mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- + mv "" "/archive/YYYY-MM-DD-" ``` 6. **Display summary** @@ -94,7 +98,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs All artifacts complete. All tasks complete. @@ -107,7 +111,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** No delta specs All artifacts complete. All tasks complete. @@ -120,7 +124,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** Sync skipped (user chose to skip) **Warnings:** @@ -137,7 +141,7 @@ Review the archive if this was not intentional. ## Archive Failed **Change:** -**Target:** openspec/changes/archive/YYYY-MM-DD-/ +**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ Target archive directory already exists. diff --git a/.claude/commands/opsx/explore.md b/.claude/commands/opsx/explore.md index 30d9c57..7558a63 100644 --- a/.claude/commands/opsx/explore.md +++ b/.claude/commands/opsx/explore.md @@ -1,6 +1,7 @@ --- 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] --- @@ -11,6 +12,8 @@ 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 ` 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" @@ -59,10 +62,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, │ @@ -107,11 +110,10 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Read existing artifacts for context** - - `openspec/changes//proposal.md` - - `openspec/changes//design.md` - - `openspec/changes//tasks.md` - - etc. +1. **Resolve and read existing artifacts for context** + - Run `openspec status --change "" --json`. + - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. + - Read existing files from `artifactPaths..existingOutputPaths`. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -119,14 +121,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//spec.md` | - | Requirement changed | `specs//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//spec.md` | + | Requirement changed | `specs//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?" diff --git a/.claude/commands/opsx/propose.md b/.claude/commands/opsx/propose.md index 05276f4..8d99588 100644 --- a/.claude/commands/opsx/propose.md +++ b/.claude/commands/opsx/propose.md @@ -1,6 +1,7 @@ --- 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] --- @@ -16,6 +17,8 @@ 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 ` 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** @@ -33,7 +36,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. + This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -42,6 +45,7 @@ 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** @@ -59,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 - - `outputPath`: Where to write the artifact + - `resolvedOutputPath`: Resolved path or pattern 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 + - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d2af30 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# 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 diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 0000000..4b73c10 --- /dev/null +++ b/.env.development.example @@ -0,0 +1,10 @@ +# 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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 21f282c..f2e30a4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,10 +20,22 @@ updates: update-types: ["version-update:semver-major"] - dependency-name: "@types/node" update-types: ["version-update:semver-major"] - # 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. + # 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. - 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: "/" diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index 5ba0f06..a5a967e 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -13,6 +13,15 @@ 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 @@ -25,7 +34,7 @@ jobs: matrix: app: [journal, planner] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: docker/login-action@v4 with: @@ -48,8 +57,12 @@ 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 @@ -59,7 +72,7 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Decrypt secrets run: | @@ -70,6 +83,16 @@ 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 @@ -77,7 +100,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile" + source: "infrastructure/docker-compose.yml,infrastructure/caddy" target: /opt/trails-cool strip_components: 1 @@ -98,6 +121,10 @@ 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 @@ -106,17 +133,69 @@ jobs: # Pull and deploy app containers docker compose --env-file app.env pull journal planner - docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force + # 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 # --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 - # Clean up - docker image prune -af + # 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 docker compose ps - # Annotate deploy in Grafana - GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2- 2>/dev/null) + # 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-) if [ -n "$GRAFANA_TOKEN" ]; then docker compose exec -T grafana curl -sf -X POST \ -H "Authorization: Bearer $GRAFANA_TOKEN" \ diff --git a/.github/workflows/cd-brouter.yml b/.github/workflows/cd-brouter.yml index 907df2d..5d28e86 100644 --- a/.github/workflows/cd-brouter.yml +++ b/.github/workflows/cd-brouter.yml @@ -20,7 +20,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: docker/login-action@v4 with: @@ -42,7 +42,7 @@ jobs: runs-on: ubuntu-latest environment: infra steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Decrypt shared secret id: decrypt @@ -87,6 +87,7 @@ 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 @@ -99,7 +100,7 @@ jobs: set -euo pipefail mkdir -p ~/brouter && cd ~/brouter echo "$TARBALL_B64" | base64 -d | tar -xzf - - chmod +x download-segments.sh + chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py # Segment seeding is a one-shot operator task (~10 GB, a few # minutes). CD must not rerun it on every deploy. diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index d317ae1..df41b93 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest environment: infra steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - 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/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" + 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" target: /opt/trails-cool strip_components: 1 @@ -64,6 +64,11 @@ 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) @@ -79,20 +84,77 @@ 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 (except Caddy — just reload its config). + # Restart infra services (config reloads handled below). # --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 diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml new file mode 100644 index 0000000..e69dec6 --- /dev/null +++ b/.github/workflows/cd-staging.yml @@ -0,0 +1,520 @@ +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 + : + # PR open/sync/reopen → :pr- + :pr-- + # 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 `` 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, ''))) + 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 `` + # 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, '')) + 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 </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: "" + + - 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. + + + + # ── 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 `` 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, '')))) + 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: "" + + - 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 }}) + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3240a80..068ebc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: contents: read pull-requests: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Gitleaks @@ -42,14 +42,27 @@ jobs: name: Dockerfile Package Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - 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@v6 + - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -62,7 +75,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -75,7 +88,7 @@ jobs: name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -88,7 +101,7 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -97,14 +110,14 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - e2e: - name: E2E Tests - needs: build + visual-tests: + name: Visual Tests runs-on: ubuntu-latest - env: - DATABASE_URL: postgres://trails:trails@localhost:5432/trails + permissions: + contents: read + pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -112,64 +125,78 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - - name: Cache PostGIS Docker image - id: postgis-cache - uses: actions/cache@v5 + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v6 with: - path: /tmp/postgis-image.tar - key: postgis-16-3.4 + path: ~/.cache/ms-playwright + key: playwright-${{ hashFiles('pnpm-lock.yaml') }} - - name: Load or pull PostGIS image + - 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: | - 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 + diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort) + if [ -z "$diffs" ]; then exit 0; 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 + 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+="\`\`\`" - - name: Push database schema - run: pnpm db:push + gh pr comment ${{ github.event.pull_request.number }} --body "$body" - - name: Build and cache BRouter - id: brouter-cache - uses: actions/cache@v5 + - name: Upload screenshots on failure + if: failure() + uses: actions/upload-artifact@v7 with: - path: /tmp/brouter - key: brouter-1.7.8 + name: visual-snapshots-diff + path: apps/planner/.vitest-attachments/ + include-hidden-files: true + retention-days: 7 - - 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 + e2e: + name: E2E Tests + needs: build + runs-on: ubuntu-latest + 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 - name: Cache BRouter segment id: segment-cache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: /tmp/brouter-segments - key: brouter-segment-E10_N50 + key: brouter-segment-E10_N50-v1.7.9 - name: Download Berlin segment if: steps.segment-cache.outputs.cache-hit != 'true' @@ -177,26 +204,54 @@ jobs: mkdir -p /tmp/brouter-segments wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5 - - name: Start BRouter + - name: Pre-seed BRouter segment volume run: | - 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 + 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 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@v5 + uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pnpm-lock.yaml') }} @@ -218,6 +273,12 @@ 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() }} @@ -255,3 +316,87 @@ 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 diff --git a/.github/workflows/dependabot-auto-fix.yml b/.github/workflows/dependabot-auto-fix.yml new file mode 100644 index 0000000..85e110f --- /dev/null +++ b/.github/workflows/dependabot-auto-fix.yml @@ -0,0 +1,94 @@ +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" diff --git a/.github/workflows/dependabot-dedupe.yml b/.github/workflows/dependabot-dedupe.yml deleted file mode 100644 index 09c90b1..0000000 --- a/.github/workflows/dependabot-dedupe.yml +++ /dev/null @@ -1,74 +0,0 @@ -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 diff --git a/.github/workflows/disk-maintenance.yml b/.github/workflows/disk-maintenance.yml new file mode 100644 index 0000000..d8f6a09 --- /dev/null +++ b/.github/workflows/disk-maintenance.yml @@ -0,0 +1,56 @@ +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." diff --git a/.github/workflows/staging-cleanup.yml b/.github/workflows/staging-cleanup.yml new file mode 100644 index 0000000..bed2fd4 --- /dev/null +++ b/.github/workflows/staging-cleanup.yml @@ -0,0 +1,112 @@ +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 < +# +# 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 diff --git a/.gitignore b/.gitignore index 58a8d39..450df80 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,10 @@ 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/ diff --git a/CLAUDE.md b/CLAUDE.md index b455318..2109593 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,9 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with Full architecture: `docs/architecture.md` Philosophy: `docs/philosophy.md` -OpenSpec change: `openspec/changes/phase-1-mvp/` +Roadmap: `docs/roadmap.md` +Ideas (pre-spec explorations): `docs/ideas/` +OpenSpec changes: `openspec/changes/` ## Principles @@ -25,7 +27,7 @@ OpenSpec change: `openspec/changes/phase-1-mvp/` - **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack) - **Maps**: Leaflet + OpenStreetMap tiles - **CRDT**: Yjs + y-websocket (Planner only) -- **Federation**: Fedify (Journal only, Phase 2) +- **Federation**: Fedify (Journal only) - **Database**: PostgreSQL + PostGIS - **Media storage**: S3-compatible (Garage) - **Routing engine**: BRouter (Java, runs as separate Docker container) @@ -39,11 +41,15 @@ apps/ planner/ — Planner app (React Router 7) journal/ — Journal app (React Router 7 + Fedify) packages/ - types/ — Shared TypeScript interfaces (Route, Activity, Waypoint) - ui/ — Shared React components (Tailwind) - map/ — Leaflet map wrappers and tile layer configs + 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 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 @@ -68,6 +74,40 @@ 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. @@ -83,8 +123,8 @@ pnpm db:studio # Open Drizzle Studio (DB browser) - **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) -- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions -- Map components go in `@trails-cool/map`, not in individual apps +- 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 - 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) @@ -143,13 +183,15 @@ Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a ## Deployment -Three separate CD workflows triggered by path: +Five CD workflows triggered by path or event: | 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 @@ -181,6 +223,49 @@ 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-.staging.trails.cool` | `trails_pr_` | 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 `` preview: journal `3200 + 2N`, planner `3201 + 2N` (planner unused for previews) + +**Compose project namespacing** keeps each preview isolated: +- Persistent staging: `-p trails-staging` +- PR ``: `-p trails-pr-` + +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-.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- 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: diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..e48200e --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,198 @@ +# 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`. 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:` +`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. diff --git a/FEDERATION.md b/FEDERATION.md new file mode 100644 index 0000000..aa4b88f --- /dev/null +++ b/FEDERATION.md @@ -0,0 +1,198 @@ +# 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": "trails.example/users/alice" }, + { "type": "PropertyValue", "name": "Instance", "value": "trails.example" } + ] +} +``` + +`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": "

Morning trail run — 12.4 km, 480 m up

", + "url": "https://trails.example/activities/01H…", + "published": "2026-07-13T07:12:00Z", + "to": ["https://www.w3.org/ns/activitystreams#Public"] +} +``` + +The Note IRI (`/activities/`) 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`.* diff --git a/README.md b/README.md index 36f7493..1bc4215 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,14 @@ 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 diff --git a/apps/journal/.env.example b/apps/journal/.env.example new file mode 100644 index 0000000..8a51ed0 --- /dev/null +++ b/apps/journal/.env.example @@ -0,0 +1,63 @@ +# 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 `/api/sync/callback/garmin`, the +# notification endpoint `/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= diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 9b1d751..306948b 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -1,4 +1,4 @@ -FROM node:25-slim AS base +FROM node:26-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,24 +9,29 @@ 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 @@ -35,6 +40,7 @@ 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 diff --git a/apps/journal/app/components/AccountDropdown.tsx b/apps/journal/app/components/AccountDropdown.tsx new file mode 100644 index 0000000..fd12a9e --- /dev/null +++ b/apps/journal/app/components/AccountDropdown.tsx @@ -0,0 +1,87 @@ +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(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 ( +
+ + + {open && ( +
+
+

+ {user.displayName ?? user.username} +

+

@{user.username}

+
+ setOpen(false)} + className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" + > + {t("nav.profile")} + + setOpen(false)} + className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" + > + {t("nav.settings")} + +
+ +
+
+ )} +
+ ); +} diff --git a/apps/journal/app/components/Avatar.tsx b/apps/journal/app/components/Avatar.tsx new file mode 100644 index 0000000..cc74702 --- /dev/null +++ b/apps/journal/app/components/Avatar.tsx @@ -0,0 +1,42 @@ +// 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, 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 ( + + ); +} diff --git a/apps/journal/app/components/ClientDate.tsx b/apps/journal/app/components/ClientDate.tsx index af210ae..300b376 100644 --- a/apps/journal/app/components/ClientDate.tsx +++ b/apps/journal/app/components/ClientDate.tsx @@ -3,10 +3,14 @@ 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 }: { iso: string }) { +export function ClientDate({ iso, withTime = false }: { iso: string; withTime?: boolean }) { const locale = useLocale(); - return ( - - ); + const d = new Date(iso); + const text = withTime + ? d.toLocaleString(locale, { dateStyle: "short", timeStyle: "short" }) + : d.toLocaleDateString(locale); + return ; } diff --git a/apps/journal/app/components/CollectionPage.tsx b/apps/journal/app/components/CollectionPage.tsx index 08c8bab..2632b44 100644 --- a/apps/journal/app/components/CollectionPage.tsx +++ b/apps/journal/app/components/CollectionPage.tsx @@ -4,6 +4,9 @@ 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 { @@ -42,9 +45,10 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) { ) : ( + + +
+

+ Federation (ActivityPub) +

+

+ Applies only when your profile visibility is public. + Private profiles do not federate at all — no actor object, no + WebFinger, no inbox. +

+
    +
  • + Your actor object (fetchable by any fediverse server) exposes: + username, display name, bio, profile link, and your public + signing key. Never your email, never private content. +
  • +
  • + Activities you mark public are pushed to the + servers of your accepted remote followers and listed in your + public outbox. Once delivered, copies live on those servers + under their policies — un-publishing sends a retraction, but + remote deletion cannot be guaranteed (and remote servers that + processed a retraction will not re-show a later re-publish). +
  • +
  • + Signing keys: one RSA keypair per user. The private key is + stored encrypted at rest and never leaves this server. +
  • +
  • + Remote actor cache: for accounts that interact with this + instance we store their public profile basics (handle, display + name, inbox/outbox URLs, public key) to render follower lists + and feeds without re-fetching. +
  • +
  • + Remote content: public (or followers-only) activities from + trails users you follow on other instances are cached here for + your feed — followers-only items are shown only to the + follower whose follow brought them in. +
  • +
  • + Inbox traffic (signed requests from other servers) appears in + the standard server logs (14-day retention, see section 4) and + is rate-limited per source instance.
diff --git a/apps/journal/app/routes/notifications.server.ts b/apps/journal/app/routes/notifications.server.ts new file mode 100644 index 0000000..ed47ef6 --- /dev/null +++ b/apps/journal/app/routes/notifications.server.ts @@ -0,0 +1,111 @@ +// Server-only loader for /notifications. See `home.server.ts`. + +import { redirect } from "react-router"; +import { inArray, eq, and } from "drizzle-orm"; +import { getDb } from "~/lib/db"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { listForUser } from "~/lib/notifications.server"; +import { linkFor } from "~/lib/notifications/link-for"; +import { readPayload } from "~/lib/notifications/payload"; +import { + countPendingFollowRequests, + listPendingFollowRequests, +} from "~/lib/follow.server"; +import { activities } from "@trails-cool/db/schema/journal"; + +type Tab = "activity" | "requests"; + +export interface NotificationRow { + id: string; + type: string; + readAt: string | null; + createdAt: string; + link: string; + payload: Record | null; +} + +export interface RequestRow { + id: string; + followerUsername: string; + followerDisplayName: string | null; + followerDomain: string; + createdAt: string; +} + +export async function loadNotifications(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const url = new URL(request.url); + const tab: Tab = url.searchParams.get("tab") === "requests" ? "requests" : "activity"; + + // Pending count drives the Requests tab dot regardless of which tab is + // currently active, so we always fetch it. It's a single COUNT(*) query. + const pendingCount = await countPendingFollowRequests(user.id); + + if (tab === "requests") { + const requests = await listPendingFollowRequests(user.id); + return { + tab: "requests" as const, + pendingCount, + requests: requests.map((r) => ({ + id: r.id, + followerUsername: r.followerUsername, + followerDisplayName: r.followerDisplayName, + followerDomain: r.followerDomain, + createdAt: r.createdAt.toISOString(), + })), + notifications: [] as NotificationRow[], + nextCursor: null as string | null, + }; + } + + const before = url.searchParams.get("before") ?? undefined; + const { rows, nextCursor } = await listForUser(user.id, { before }); + + // Renderer guard: drop activity_published rows whose subject is gone + // or no longer public (visibility flipped from public → private/unlisted). + // Fetch the still-public subject IDs in one query, then filter. + const activitySubjectIds = rows + .filter((r) => r.type === "activity_published" && r.subjectId) + .map((r) => r.subjectId as string); + let publicActivityIds = new Set(); + if (activitySubjectIds.length > 0) { + const db = getDb(); + const visible = await db + .select({ id: activities.id }) + .from(activities) + .where(and(inArray(activities.id, activitySubjectIds), eq(activities.visibility, "public"))); + publicActivityIds = new Set(visible.map((v) => v.id)); + } + + const visibleRows = rows.filter((r) => { + if (r.type !== "activity_published") return true; + if (!r.subjectId) return false; + return publicActivityIds.has(r.subjectId); + }); + + return { + tab: "activity" as const, + pendingCount, + requests: [] as RequestRow[], + notifications: visibleRows.map((r) => { + const link = linkFor({ + type: r.type, + subjectId: r.subjectId, + payload: r.payload, + payloadVersion: r.payloadVersion, + }); + const payload = readPayload(r.type, r.payloadVersion, r.payload); + return { + id: r.id, + type: r.type, + readAt: r.readAt?.toISOString() ?? null, + createdAt: r.createdAt.toISOString(), + link: link.web, + payload, + }; + }), + nextCursor, + }; +} diff --git a/apps/journal/app/routes/notifications.tsx b/apps/journal/app/routes/notifications.tsx new file mode 100644 index 0000000..13c567a --- /dev/null +++ b/apps/journal/app/routes/notifications.tsx @@ -0,0 +1,248 @@ +import { data, useFetcher } from "react-router"; +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/notifications"; +import { ClientDate } from "~/components/ClientDate"; +import { loadNotifications } from "./notifications.server"; + +export async function loader({ request }: Route.LoaderArgs) { + return data(await loadNotifications(request)); +} + +export function meta(_args: Route.MetaArgs) { + return [{ title: "Notifications — trails.cool" }]; +} + +interface NotificationRow { + id: string; + type: string; + readAt: string | null; + createdAt: string; + link: string; + payload: Record | null; +} + +interface RequestRow { + id: string; + followerUsername: string; + followerDisplayName: string | null; + followerDomain: string; + createdAt: string; +} + +function summary(t: (key: string, opts?: Record) => string, n: NotificationRow): string { + const p = n.payload as { followerUsername?: string; followerDisplayName?: string | null; + targetUsername?: string; targetDisplayName?: string | null; + activityName?: string; ownerUsername?: string; ownerDisplayName?: string | null } | null; + const someone = t("notifications.someone"); + switch (n.type) { + case "follow_request_received": { + const name = p?.followerDisplayName ?? p?.followerUsername ?? someone; + return t("notifications.summary.followRequestReceived", { name }); + } + case "follow_received": { + const name = p?.followerDisplayName ?? p?.followerUsername ?? someone; + return t("notifications.summary.followReceived", { name }); + } + case "follow_request_approved": { + const name = p?.targetDisplayName ?? p?.targetUsername ?? someone; + return t("notifications.summary.followRequestApproved", { name }); + } + case "activity_published": { + const owner = p?.ownerDisplayName ?? p?.ownerUsername ?? someone; + const activity = p?.activityName ?? ""; + return t("notifications.summary.activityPublished", { owner, activity }); + } + default: + return n.type; + } +} + +function NotificationItem({ row }: { row: NotificationRow }) { + const { t } = useTranslation("journal"); + const fetcher = useFetcher(); + const inFlight = fetcher.state !== "idle"; + + const onClick = (e: React.MouseEvent) => { + if (row.readAt) return; // already read; let the link navigate normally + // Mark read in the background; navigation proceeds via the anchor. + fetcher.submit(null, { + method: "post", + action: `/api/notifications/${row.id}/read`, + }); + void e; + }; + + return ( +
  • + +
    +
    + {!row.readAt && ( +
    + + + +
    +
    + {inFlight && marking read} +
  • + ); +} + +function RequestItem({ row }: { row: RequestRow }) { + const { t } = useTranslation("journal"); + const approve = useFetcher(); + const reject = useFetcher(); + const inFlight = approve.state !== "idle" || reject.state !== "idle"; + + return ( +
  • +
    + + {row.followerDisplayName ?? row.followerUsername} + +

    + @{row.followerUsername}@{row.followerDomain} ·{" "} + +

    +
    +
    + + + + + + +
    +
  • + ); +} + +function TabLink({ + to, + active, + label, + badge, +}: { + to: string; + active: boolean; + label: string; + badge: number; +}) { + return ( + + {label} + {badge > 0 && ( + + {badge} + + )} + + ); +} + +export default function Notifications({ loaderData }: Route.ComponentProps) { + const { tab, pendingCount, notifications, nextCursor, requests } = loaderData; + const { t } = useTranslation("journal"); + const markAll = useFetcher(); + + const hasUnread = notifications.some((n) => !n.readAt); + + return ( +
    +
    +

    {t("notifications.title")}

    + {tab === "activity" && hasUnread && ( + + + + )} +
    + +
    + + +
    + + {tab === "activity" ? ( + notifications.length === 0 ? ( +

    {t("notifications.empty")}

    + ) : ( + <> +
      + {notifications.map((n) => ( + + ))} +
    + {nextCursor && ( + + )} + + ) + ) : requests.length === 0 ? ( +

    {t("social.requests.empty")}

    + ) : ( +
      + {requests.map((r) => ( + + ))} +
    + )} +
    + ); +} diff --git a/apps/journal/app/routes/oauth.authorize.tsx b/apps/journal/app/routes/oauth.authorize.tsx index 91342d9..fa918f0 100644 --- a/apps/journal/app/routes/oauth.authorize.tsx +++ b/apps/journal/app/routes/oauth.authorize.tsx @@ -1,6 +1,6 @@ import type { Route } from "./+types/oauth.authorize"; import { redirect } from "react-router"; -import { getSessionUser } from "../lib/auth.server.ts"; +import { getSessionUser } from "../lib/auth/session.server.ts"; import { getOAuthClient, validateRedirectUri, diff --git a/apps/journal/app/routes/routes.$id.edit.server.ts b/apps/journal/app/routes/routes.$id.edit.server.ts new file mode 100644 index 0000000..379438c --- /dev/null +++ b/apps/journal/app/routes/routes.$id.edit.server.ts @@ -0,0 +1,49 @@ +// Server-only loader/action for /routes/:id/edit. See `home.server.ts`. + +import { redirect } from "react-router"; +import { requireSessionUser } from "~/lib/auth/session.server"; +import { updateRoute } from "~/lib/routes.server"; +import { requireOwnedRoute } from "~/lib/ownership.server"; +import type { Visibility } from "@trails-cool/db/schema/journal"; + +const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); + +export async function loadRouteEdit(request: Request, id: string | undefined) { + const user = await requireSessionUser(request); + + const route = await requireOwnedRoute(id ?? "", user.id, { notOwnerStatus: 403 }); + + return { + route: { + id: route.id, + name: route.name, + description: route.description, + visibility: route.visibility, + }, + }; +} + +export async function routeEditAction(request: Request, id: string | undefined) { + const user = await requireSessionUser(request); + const routeId = id ?? ""; + + const formData = await request.formData(); + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + const visibilityRaw = formData.get("visibility") as string | null; + + const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {}; + if (name) input.name = name; + if (description !== null) input.description = description; + if (gpxFile && gpxFile.size > 0) { + input.gpx = await gpxFile.text(); + } + if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) { + input.visibility = visibilityRaw as Visibility; + } + + const route = await requireOwnedRoute(routeId, user.id, { notOwnerStatus: 403 }); + await updateRoute(route, input); + return redirect(`/routes/${routeId}`); +} diff --git a/apps/journal/app/routes/routes.$id.edit.tsx b/apps/journal/app/routes/routes.$id.edit.tsx index e1fe2e9..7ab1bc0 100644 --- a/apps/journal/app/routes/routes.$id.edit.tsx +++ b/apps/journal/app/routes/routes.$id.edit.tsx @@ -1,52 +1,14 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id.edit"; -import { getSessionUser } from "~/lib/auth.server"; -import { getRoute, updateRoute } from "~/lib/routes.server"; -import type { Visibility } from "@trails-cool/db/schema/journal"; - -const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); +import { loadRouteEdit, routeEditAction } from "./routes.$id.edit.server"; export async function loader({ params, request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); - - const route = await getRoute(params.id); - if (!route) throw data({ error: "Route not found" }, { status: 404 }); - if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 }); - - return data({ - route: { - id: route.id, - name: route.name, - description: route.description, - visibility: route.visibility, - }, - }); + return data(await loadRouteEdit(request, params.id)); } export async function action({ params, request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); - - const formData = await request.formData(); - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const gpxFile = formData.get("gpx") as File | null; - const visibilityRaw = formData.get("visibility") as string | null; - - const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {}; - if (name) input.name = name; - if (description !== null) input.description = description; - if (gpxFile && gpxFile.size > 0) { - input.gpx = await gpxFile.text(); - } - if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) { - input.visibility = visibilityRaw as Visibility; - } - - await updateRoute(params.id, user.id, input); - return redirect(`/routes/${params.id}`); + return await routeEditAction(request, params.id); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/routes.$id.server.ts b/apps/journal/app/routes/routes.$id.server.ts new file mode 100644 index 0000000..27f0b3b --- /dev/null +++ b/apps/journal/app/routes/routes.$id.server.ts @@ -0,0 +1,191 @@ +// Server-only loader/action for /routes/:id. See `home.server.ts`. + +import { data, redirect } from "react-router"; +import { and, eq } from "drizzle-orm"; +import { canView } from "~/lib/auth.server"; +import { getSessionUser, requireSessionUser } from "~/lib/auth/session.server"; +import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; +import { requireOwnedRoute } from "~/lib/ownership.server"; +import { getDb } from "~/lib/db"; +import { syncPushes } from "@trails-cool/db/schema/journal"; +import { getService } from "~/lib/connected-services"; +import { computeDays, parseGpxAsync, elevationSeries } from "@trails-cool/gpx"; +import type { ElevationSample } from "@trails-cool/gpx"; +import { enqueueOptional } from "~/lib/boss.server"; + +export async function loadRouteDetail(request: Request, id: string | undefined) { + const routeId = id ?? ""; + const [routeWithVersions, routeWithGeojson] = await Promise.all([ + getRouteWithVersions(routeId), + getRoute(routeId), + ]); + if (!routeWithVersions) throw data({ error: "Route not found" }, { status: 404 }); + const route = routeWithVersions; + + const user = await getSessionUser(request); + const isOwner = user?.id === route.ownerId; + + // Visibility gate: public always renders, unlisted renders on direct link, + // private requires ownership. Return 404 (not 403) to avoid leaking existence. + if (!canView(route, user, { asDirectLink: true })) { + throw data({ error: "Route not found" }, { status: 404 }); + } + + // Lazy surface backfill for the owner's own routes that lack a breakdown + // (non-Planner routes — Planner saves provide it synchronously). Owner-gated; + // idempotent via singletonKey. The SSE event fills the bars in live. + if (isOwner && routeWithGeojson?.geojson && !route.surfaceBreakdown) { + await enqueueOptional( + "surface-backfill", + { kind: "route", id: route.id }, + { source: "route-detail" }, + { singletonKey: `surface:route:${route.id}` }, + ); + } + + // Parse GPX once for day stats and waypoint POI data + let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = []; + let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record }> = []; + let elevation: ElevationSample[] = []; + if (route.gpx) { + try { + const gpxData = await parseGpxAsync(route.gpx); + elevation = elevationSeries(gpxData.tracks); + waypoints = gpxData.waypoints.map((w) => ({ + lat: w.lat, + lon: w.lon, + name: w.name, + isDayBreak: w.isDayBreak, + note: w.note, + osmId: w.osmId, + poiTags: w.poiTags as Record | undefined, + })); + if (route.dayBreaks && route.dayBreaks.length > 0) { + dayStats = computeDays(gpxData.waypoints, gpxData.tracks); + } + } catch { + // Fall back to empty + } + } + + const currentVersion = route.versions[0]?.version ?? 1; + + // Wahoo push state — only meaningful for the owner. The single + // sync_pushes row per (user, route, wahoo) carries `lastPushedVersion`, + // which we compare against the current local version to render one of + // three states: matches, local newer, or last attempt failed. + let wahooPush: + | { + canPush: boolean; + needsReauth: boolean; + currentVersion: number; + latest: { + pushedAt: string | null; + remoteId: string | null; + lastPushedVersion: number | null; + error: string | null; + } | null; + } + | null = null; + if (isOwner && user && !!route.gpx) { + const connection = await getService(user.id, "wahoo"); + let latest: + | { + pushedAt: string | null; + remoteId: string | null; + lastPushedVersion: number | null; + error: string | null; + } + | null = null; + if (connection) { + const db = getDb(); + const [row] = await db + .select() + .from(syncPushes) + .where( + and( + eq(syncPushes.userId, user.id), + eq(syncPushes.routeId, route.id), + eq(syncPushes.provider, "wahoo"), + ), + ) + .limit(1); + latest = row + ? { + pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null, + remoteId: row.remoteId, + lastPushedVersion: row.lastPushedVersion, + error: row.error, + } + : null; + } + wahooPush = { + canPush: !!connection, + needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"), + currentVersion, + latest, + }; + } + + return { + route: { + id: route.id, + name: route.name, + description: route.description, + distance: route.distance, + elevationGain: route.elevationGain, + elevationLoss: route.elevationLoss, + routingProfile: route.routingProfile, + hasGpx: !!route.gpx, + dayBreaks: route.dayBreaks ?? [], + elevation, + surfaceBreakdown: route.surfaceBreakdown ?? null, + geojson: routeWithGeojson?.geojson ?? null, + visibility: route.visibility, + createdAt: route.createdAt.toISOString(), + updatedAt: route.updatedAt.toISOString(), + }, + dayStats, + waypoints, + versions: route.versions.map((v) => ({ + version: v.version, + changeDescription: v.changeDescription, + createdAt: v.createdAt.toISOString(), + })), + isOwner, + wahooPush, + }; +} + +export async function routeDetailAction(request: Request, id: string | undefined) { + const user = await requireSessionUser(request); + const routeId = id ?? ""; + + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "delete") { + const route = await requireOwnedRoute(routeId, user.id); + await deleteRoute(route); + return redirect("/routes"); + } + + if (intent === "update") { + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + + const input: Record = {}; + if (name) input.name = name; + if (description !== null) input.description = description; + if (gpxFile && gpxFile.size > 0) { + input.gpx = await gpxFile.text(); + } + + const route = await requireOwnedRoute(routeId, user.id); + await updateRoute(route, input as { name?: string; description?: string; gpx?: string }); + return redirect(`/routes/${routeId}`); + } + + return data({ error: "Unknown action" }, { status: 400 }); +} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 755b577..b45a02d 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -1,98 +1,22 @@ -import { useState, useCallback } from "react"; -import { data, redirect } from "react-router"; +import { useState, useCallback, useMemo } from "react"; +import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id"; -import { canView, getSessionUser } from "~/lib/auth.server"; -import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; - +import { StatRow } from "~/components/StatRow"; +import { ElevationProfile } from "~/components/ElevationProfile"; +import { SurfaceBreakdown } from "~/components/SurfaceBreakdown"; +import { useSurfaceBackfillUpdates } from "~/hooks/useSurfaceBackfill"; +import { activityStatItems } from "~/lib/stats"; +import { loadRouteDetail, routeDetailAction } from "./routes.$id.server"; export async function loader({ params, request }: Route.LoaderArgs) { - const [routeWithVersions, routeWithGeojson] = await Promise.all([ - getRouteWithVersions(params.id), - getRoute(params.id), - ]); - if (!routeWithVersions) throw data({ error: "Route not found" }, { status: 404 }); - const route = routeWithVersions; - - const user = await getSessionUser(request); - const isOwner = user?.id === route.ownerId; - - // Visibility gate: public always renders, unlisted renders on direct link, - // private requires ownership. Return 404 (not 403) to avoid leaking existence. - if (!canView(route, user, { asDirectLink: true })) { - throw data({ error: "Route not found" }, { status: 404 }); - } - - // Compute per-day stats if route has day breaks and GPX - let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = []; - if (route.dayBreaks && route.dayBreaks.length > 0 && route.gpx) { - try { - const { computeDays } = await import("@trails-cool/gpx"); - const { parseGpxAsync } = await import("@trails-cool/gpx"); - const gpxData = await parseGpxAsync(route.gpx); - dayStats = computeDays(gpxData.waypoints, gpxData.tracks); - } catch { - // Fall back to no day stats - } - } - - return data({ - route: { - id: route.id, - name: route.name, - description: route.description, - distance: route.distance, - elevationGain: route.elevationGain, - elevationLoss: route.elevationLoss, - routingProfile: route.routingProfile, - hasGpx: !!route.gpx, - dayBreaks: route.dayBreaks ?? [], - geojson: routeWithGeojson?.geojson ?? null, - visibility: route.visibility, - createdAt: route.createdAt.toISOString(), - updatedAt: route.updatedAt.toISOString(), - }, - dayStats, - versions: route.versions.map((v) => ({ - version: v.version, - changeDescription: v.changeDescription, - createdAt: v.createdAt.toISOString(), - })), - isOwner, - }); + return data(await loadRouteDetail(request, params.id)); } export async function action({ params, request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); - - const formData = await request.formData(); - const intent = formData.get("intent"); - - if (intent === "delete") { - await deleteRoute(params.id, user.id); - return redirect("/routes"); - } - - if (intent === "update") { - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const gpxFile = formData.get("gpx") as File | null; - - const input: Record = {}; - if (name) input.name = name; - if (description !== null) input.description = description; - if (gpxFile && gpxFile.size > 0) { - input.gpx = await gpxFile.text(); - } - - await updateRoute(params.id, user.id, input as { name?: string; description?: string; gpx?: string }); - return redirect(`/routes/${params.id}`); - } - - return data({ error: "Unknown action" }, { status: 400 }); + return routeDetailAction(request, params.id); } export function meta({ data: loaderData }: Route.MetaArgs) { @@ -122,11 +46,40 @@ export function meta({ data: loaderData }: Route.MetaArgs) { } export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { - const { route, dayStats, versions, isOwner } = loaderData; - const { t } = useTranslation("journal"); + const { route, dayStats, waypoints, versions, isOwner, wahooPush } = loaderData; + const { t, i18n } = useTranslation("journal"); + + // Live-update when the async surface backfill lands (owner-only). + useSurfaceBackfillUpdates("route", route.id, isOwner && !route.surfaceBreakdown); + const [editLoading, setEditLoading] = useState(false); const [highlightedDay, setHighlightedDay] = useState(null); + // Elevation profile ↔ map sync via a shared "active" sample index. + const elevation = route.elevation; + const [activeIndex, setActiveIndex] = useState(null); + const [centerOn, setCenterOn] = useState<{ lat: number; lng: number; v: number } | null>(null); + const hoverSeries = useMemo( + () => elevation.map((s) => [s.lat, s.lng] as [number, number]), + [elevation], + ); + const activePoint = + activeIndex != null && elevation[activeIndex] + ? { lat: elevation[activeIndex]!.lat, lng: elevation[activeIndex]!.lng } + : null; + const seekElevation = (i: number) => { + const s = elevation[i]; + if (s) setCenterOn((prev) => ({ lat: s.lat, lng: s.lng, v: (prev?.v ?? 0) + 1 })); + setActiveIndex(i); + }; + + const pushStatus = typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("push") + : null; + const pushErrorCode = typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("code") + : null; + // A route is "empty" when it has no geometry and no computed distance — // i.e. nobody has planned any waypoints yet. Surfaced as a dedicated // empty-state below so the page isn't a dead end. @@ -178,32 +131,88 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { {t("routes.exportGpx")} )} + {wahooPush?.canPush && (() => { + const latest = wahooPush.latest; + const matches = + latest?.pushedAt && + latest.lastPushedVersion === wahooPush.currentVersion; + const localNewer = + latest?.pushedAt && + latest.lastPushedVersion != null && + latest.lastPushedVersion < wahooPush.currentVersion; + if (matches) { + return ( + + {t("routes.sentToWahoo", { + date: new Date(latest!.pushedAt!).toLocaleDateString(i18n.language), + })} + + ); + } + return ( +
    + {localNewer && ( + + {t("routes.onWahooNewer", { n: latest!.lastPushedVersion })} + + )} + + {latest?.error && ( + + {t("routes.sendToWahooFailed", { error: latest.error })} + + )} +
    + ); + })()} )} -
    - {route.distance != null && ( -
    -

    - {(route.distance / 1000).toFixed(1)} km -

    -

    {t("routes.distance")}

    -
    + {pushStatus && ( +
    + {pushStatus === "success" && t("routes.sendToWahooBanner.success")} + {pushStatus === "needs_permission" && t("routes.sendToWahooBanner.needsPermission")} + {pushStatus === "no_connection" && t("routes.sendToWahooBanner.noConnection")} + {pushStatus === "no_geometry" && t("routes.sendToWahooBanner.noGeometry")} + {pushStatus === "error" && pushErrorCode === "validation" && + t("routes.sendToWahooBanner.validation")} + {pushStatus === "error" && pushErrorCode === "rate_limit" && + t("routes.sendToWahooBanner.rateLimit")} + {pushStatus === "error" && pushErrorCode === "token_expired" && + t("routes.sendToWahooBanner.tokenExpired")} + {pushStatus === "error" && (!pushErrorCode || pushErrorCode === "generic") && + t("routes.sendToWahooBanner.generic")} +
    + )} + + -

    ↑ {route.elevationGain} m

    -

    Ascent

    -
    - )} - {route.elevationLoss != null && ( -
    -

    ↓ {route.elevationLoss} m

    -

    Descent

    -
    - )} - + /> {dayStats.length > 1 && (
    @@ -249,12 +258,88 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
    )} - {route.geojson && ( -
    - 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} /> + {waypoints.some((w) => w.osmId || w.poiTags || w.note) && ( +
    +

    {t("routes.waypoints")}

    +
      + {waypoints.filter((w) => w.osmId || w.poiTags || w.note || w.name).map((w, i) => ( +
    • +

      + {w.name ?? `${w.lat.toFixed(5)}, ${w.lon.toFixed(5)}`} +

      + {w.note && ( +

      {w.note}

      + )} + {w.poiTags && ( +
      + {w.poiTags.phone && ( +
      +
      {t("routes.poi.phone")}
      +
      {w.poiTags.phone}
      +
      + )} + {w.poiTags.website && ( +
      +
      {t("routes.poi.website")}
      +
      {w.poiTags.website}
      +
      + )} + {w.poiTags.opening_hours && ( +
      +
      {t("routes.poi.openingHours")}
      +
      {w.poiTags.opening_hours}
      +
      + )} + {(w.poiTags["addr:street"] || w.poiTags["addr:city"]) && ( +
      +
      {t("routes.poi.address")}
      +
      + {[ + w.poiTags["addr:street"] && `${w.poiTags["addr:street"]}${w.poiTags["addr:housenumber"] ? " " + w.poiTags["addr:housenumber"] : ""}`, + w.poiTags["addr:postcode"] && w.poiTags["addr:city"] + ? `${w.poiTags["addr:postcode"]} ${w.poiTags["addr:city"]}` + : w.poiTags["addr:city"], + ].filter(Boolean).join(", ")} +
      +
      + )} +
      + )} +
    • + ))} +
    )} + {route.geojson && ( +
    + 0 ? route.dayBreaks : undefined} + highlightedDay={highlightedDay} + activePoint={activePoint} + hoverSeries={hoverSeries} + onHoverIndex={setActiveIndex} + centerOn={centerOn} + /> +
    + )} + + {elevation.length > 1 && ( + + )} + + + {isEmpty && (
    diff --git a/apps/journal/app/routes/routes._index.server.ts b/apps/journal/app/routes/routes._index.server.ts new file mode 100644 index 0000000..55b1432 --- /dev/null +++ b/apps/journal/app/routes/routes._index.server.ts @@ -0,0 +1,20 @@ +// Server-only loader for /routes index. See `home.server.ts`. + +import { requireSessionUser } from "~/lib/auth/session.server"; +import { listRoutes } from "~/lib/routes.server"; + +export async function loadRoutesIndex(request: Request) { + const user = await requireSessionUser(request); + + const userRoutes = await listRoutes(user.id); + return { + routes: userRoutes.map((r) => ({ + id: r.id, + name: r.name, + distance: r.distance, + elevationGain: r.elevationGain, + updatedAt: r.updatedAt.toISOString(), + geojson: r.geojson ?? null, + })), + }; +} diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx index 85511a6..dd67556 100644 --- a/apps/journal/app/routes/routes._index.tsx +++ b/apps/journal/app/routes/routes._index.tsx @@ -1,26 +1,12 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes._index"; -import { getSessionUser } from "~/lib/auth.server"; -import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; +import { loadRoutesIndex } from "./routes._index.server"; export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); - - const userRoutes = await listRoutes(user.id); - return data({ - routes: userRoutes.map((r) => ({ - id: r.id, - name: r.name, - distance: r.distance, - elevationGain: r.elevationGain, - updatedAt: r.updatedAt.toISOString(), - geojson: r.geojson ?? null, - })), - }); + return data(await loadRoutesIndex(request)); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/routes.new.server.ts b/apps/journal/app/routes/routes.new.server.ts new file mode 100644 index 0000000..cf6f9ac --- /dev/null +++ b/apps/journal/app/routes/routes.new.server.ts @@ -0,0 +1,29 @@ +// Server-only loader/action for /routes/new. See `home.server.ts`. + +import { data, redirect } from "react-router"; +import { requireSessionUser } from "~/lib/auth/session.server"; +import { createRoute } from "~/lib/routes.server"; + +export async function loadRoutesNew(request: Request) { + await requireSessionUser(request); + return {}; +} + +export async function routesNewAction(request: Request) { + const user = await requireSessionUser(request); + + const formData = await request.formData(); + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + + if (!name) return data({ error: "Name is required" }, { status: 400 }); + + let gpx: string | undefined; + if (gpxFile && gpxFile.size > 0) { + gpx = await gpxFile.text(); + } + + const routeId = await createRoute(user.id, { name, description, gpx }); + return redirect(`/routes/${routeId}`); +} diff --git a/apps/journal/app/routes/routes.new.tsx b/apps/journal/app/routes/routes.new.tsx index 7a939bd..03f2446 100644 --- a/apps/journal/app/routes/routes.new.tsx +++ b/apps/journal/app/routes/routes.new.tsx @@ -1,33 +1,14 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import type { Route } from "./+types/routes.new"; -import { getSessionUser } from "~/lib/auth.server"; -import { createRoute } from "~/lib/routes.server"; +import { loadRoutesNew, routesNewAction } from "./routes.new.server"; export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); - return data({}); + return data(await loadRoutesNew(request)); } export async function action({ request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); - - const formData = await request.formData(); - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const gpxFile = formData.get("gpx") as File | null; - - if (!name) return data({ error: "Name is required" }, { status: 400 }); - - let gpx: string | undefined; - if (gpxFile && gpxFile.size > 0) { - gpx = await gpxFile.text(); - } - - const routeId = await createRoute(user.id, { name, description, gpx }); - return redirect(`/routes/${routeId}`); + return await routesNewAction(request); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/settings._index.tsx b/apps/journal/app/routes/settings._index.tsx new file mode 100644 index 0000000..7afc536 --- /dev/null +++ b/apps/journal/app/routes/settings._index.tsx @@ -0,0 +1,8 @@ +import { redirect } from "react-router"; + +// /settings lands on the Profile section. Keeps deep-link friendliness +// (every section has a stable URL) without making the bare /settings +// URL feel empty. +export function loader() { + return redirect("/settings/profile"); +} diff --git a/apps/journal/app/routes/settings.account.server.ts b/apps/journal/app/routes/settings.account.server.ts new file mode 100644 index 0000000..af253b7 --- /dev/null +++ b/apps/journal/app/routes/settings.account.server.ts @@ -0,0 +1,15 @@ +// Server-only loader for /settings/account. See `home.server.ts`. + +import { redirect } from "react-router"; +import { getSessionUser } from "~/lib/auth/session.server"; + +export async function loadAccountSettings(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return { + user: { + username: user.username, + email: user.email, + }, + }; +} diff --git a/apps/journal/app/routes/settings.account.tsx b/apps/journal/app/routes/settings.account.tsx new file mode 100644 index 0000000..f48cade --- /dev/null +++ b/apps/journal/app/routes/settings.account.tsx @@ -0,0 +1,135 @@ +import { useState, useEffect } from "react"; +import { data, useFetcher } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/settings.account"; +import { loadAccountSettings } from "./settings.account.server"; + +export function meta() { + return [{ title: "Account — Settings — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + return data(await loadAccountSettings(request)); +} + +export default function AccountSettings({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + const { t } = useTranslation(["journal", "common"]); + const emailFetcher = useFetcher(); + + const [newEmail, setNewEmail] = useState(""); + const [showEmailForm, setShowEmailForm] = useState(false); + const [emailSent, setEmailSent] = useState(false); + + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [deleteUsername, setDeleteUsername] = useState(""); + + useEffect(() => { + if (emailFetcher.data && !emailFetcher.data.error) { + setEmailSent(true); + } + }, [emailFetcher.data]); + + return ( +
    +

    {t("settings.account.title")}

    + + {/* Email */} +
    + +
    +

    {user.email}

    + {!showEmailForm && !emailSent && ( + + )} +
    + + {showEmailForm && !emailSent && ( + + setNewEmail(e.target.value)} + placeholder={t("settings.account.newEmailPlaceholder")} + className="block flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + + + + )} + + {emailFetcher.data?.error && ( +

    {emailFetcher.data.error}

    + )} + + {emailSent && ( +

    {t("settings.account.verificationSent")}

    + )} +
    + + {/* Delete Account */} +
    +

    {t("settings.account.dangerZone")}

    +

    {t("settings.account.deleteDescription")}

    + + {!deleteConfirm ? ( + + ) : ( +
    +

    + {t("settings.account.deleteConfirmPrompt", { username: user.username })} +

    + setDeleteUsername(e.target.value)} + placeholder={user.username} + className="block w-full rounded-md border border-red-300 px-3 py-2 text-sm" + /> +
    +
    + +
    + +
    +
    + )} +
    +
    + ); +} diff --git a/apps/journal/app/routes/settings.connections.komoot.server.ts b/apps/journal/app/routes/settings.connections.komoot.server.ts new file mode 100644 index 0000000..ea9adb5 --- /dev/null +++ b/apps/journal/app/routes/settings.connections.komoot.server.ts @@ -0,0 +1,23 @@ +// Server-only loader for /settings/connections/komoot. See `home.server.ts`. + +import { redirect } from "react-router"; +import { getOrigin } from "~/lib/config.server"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { getService } from "~/lib/connected-services/manager"; + +export async function loadKomootConnection(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const service = await getService(user.id, "komoot"); + const origin = getOrigin(); + const trailsProfileUrl = `${origin}/users/${user.username}`; + + return { + connected: !!service, + mode: service ? (service.credentials as { mode?: string }).mode ?? null : null, + providerUserId: service?.providerUserId ?? null, + serviceId: service?.id ?? null, + trailsProfileUrl, + }; +} diff --git a/apps/journal/app/routes/settings.connections.komoot.tsx b/apps/journal/app/routes/settings.connections.komoot.tsx new file mode 100644 index 0000000..7c48941 --- /dev/null +++ b/apps/journal/app/routes/settings.connections.komoot.tsx @@ -0,0 +1,193 @@ +// Komoot connection management page. Supports two modes: +// Public — verify ownership via bio link (no password stored) +// Authenticated — email + password (password encrypted at rest) + +import { useState } from "react"; +import { data, useFetcher } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/settings.connections.komoot"; +import { loadKomootConnection } from "./settings.connections.komoot.server"; + +export function meta() { + return [{ title: "Connect Komoot — Settings — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + return data(await loadKomootConnection(request)); +} + +type VerifyResponse = { success?: boolean; error?: string }; +type ConnectResponse = { success?: boolean; error?: string }; + +export default function KomootConnectPage({ loaderData }: Route.ComponentProps) { + const { connected, mode, providerUserId, serviceId, trailsProfileUrl } = loaderData; + const { t } = useTranslation("journal"); + + const verifyFetcher = useFetcher(); + const connectFetcher = useFetcher(); + + const [komootProfileUrl, setKomootProfileUrl] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const isVerifying = verifyFetcher.state !== "idle"; + const isConnecting = connectFetcher.state !== "idle"; + + const verifyError = verifyFetcher.data?.error; + const connectError = connectFetcher.data?.error; + + function handleVerify(e: React.FormEvent) { + e.preventDefault(); + verifyFetcher.submit( + JSON.stringify({ komootProfileUrl }), + { method: "post", action: "/api/sync/komoot/verify", encType: "application/json" }, + ); + } + + function handleConnect(e: React.FormEvent) { + e.preventDefault(); + connectFetcher.submit( + JSON.stringify({ email, password }), + { method: "post", action: "/api/sync/komoot/connect", encType: "application/json" }, + ); + } + + return ( +
    +
    +

    {t("komoot.title")}

    + {connected && ( +
    + + {t("komoot.modeBadge", { mode: mode === "public" ? t("komoot.publicMode") : t("komoot.authenticatedMode") })} + + {providerUserId && ( + + {t("settings.services.connectedAs", { id: providerUserId })} + + )} + + {t("sync.import")} + + {serviceId && ( +
    + +
    + )} +
    + )} +
    + + {verifyFetcher.data?.success && ( +
    + {t("komoot.verifySuccess")} +
    + )} + {connectFetcher.data?.success && ( +
    + {t("komoot.connectSuccess")} +
    + )} + + {/* Public mode section */} +
    +

    {t("komoot.publicSection")}

    +

    + {t("komoot.publicInstructions")} +

    +
    + {trailsProfileUrl} +
    +
    +
    + + setKomootProfileUrl(e.target.value)} + placeholder={t("komoot.profileUrlPlaceholder")} + className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + required + /> +
    + +
    + {verifyError && ( +

    + {verifyError === "not_verified" + ? t("komoot.verificationError") + : verifyError === "invalid_url" + ? t("komoot.invalidUrl") + : t("komoot.verificationError")} +

    + )} + {isVerifying && ( +

    {t("komoot.verifyPending")}

    + )} +
    + + {/* Authenticated mode section */} +
    +

    {t("komoot.authenticatedSection")}

    +
    +
    + + setEmail(e.target.value)} + autoComplete="username" + className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + required + /> +
    +
    + + setPassword(e.target.value)} + autoComplete="current-password" + className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + required + /> +
    + +
    + {connectError && ( +

    + {connectError === "invalid_credentials" + ? t("komoot.authError") + : t("komoot.authError")} +

    + )} +
    +
    + ); +} diff --git a/apps/journal/app/routes/settings.connections.server.ts b/apps/journal/app/routes/settings.connections.server.ts new file mode 100644 index 0000000..c89c3a0 --- /dev/null +++ b/apps/journal/app/routes/settings.connections.server.ts @@ -0,0 +1,41 @@ +// Server-only loader for /settings/connections. Pulled out of the route +// file so the component module doesn't pull `getDb` + Drizzle schema +// into its module graph (only the loader does, via `import("...")` at +// load time). + +import { eq } from "drizzle-orm"; +import { requireSessionUser } from "~/lib/auth/session.server"; +import { getDb } from "~/lib/db"; +import { connectedServices } from "@trails-cool/db/schema/journal"; +import { getAllManifests } from "~/lib/connected-services"; + +export async function loadConnectionsSettings(request: Request) { + const user = await requireSessionUser(request); + + const db = getDb(); + const connections = await db + .select({ + provider: connectedServices.provider, + providerUserId: connectedServices.providerUserId, + }) + .from(connectedServices) + .where(eq(connectedServices.userId, user.id)); + + const providers = getAllManifests() + // Providers can hide themselves when the instance lacks their API + // credentials (Garmin: program keys are per-operator). + .filter((m) => m.configured?.() ?? true) + .map((m) => { + const conn = connections.find((c) => c.provider === m.id); + return { + id: m.id, + name: m.displayName, + connected: !!conn, + providerUserId: conn?.providerUserId, + connectUrl: m.connectUrl ?? null, + importUrl: m.importUrl ?? null, + }; + }); + + return { providers }; +} diff --git a/apps/journal/app/routes/settings.connections.tsx b/apps/journal/app/routes/settings.connections.tsx new file mode 100644 index 0000000..9b255e3 --- /dev/null +++ b/apps/journal/app/routes/settings.connections.tsx @@ -0,0 +1,82 @@ +import { data, useSearchParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/settings.connections"; +import { loadConnectionsSettings } from "./settings.connections.server"; + +const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const; +type KnownError = (typeof KNOWN_ERRORS)[number]; +function isKnownError(value: string | null): value is KnownError { + return value !== null && (KNOWN_ERRORS as readonly string[]).includes(value); +} + +export function meta() { + return [{ title: "Connected services — Settings — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + return data(await loadConnectionsSettings(request)); +} + +export default function ConnectionsSettings({ loaderData }: Route.ComponentProps) { + const { providers } = loaderData; + const { t } = useTranslation(["journal"]); + const [searchParams] = useSearchParams(); + const errorParam = searchParams.get("error"); + const errorKey: KnownError | null = isKnownError(errorParam) ? errorParam : errorParam ? "generic" : null; + + return ( +
    +

    {t("settings.services.title")}

    + {errorKey && ( +
    + {t(`settings.services.errors.${errorKey}`)} +
    + )} +
    + {providers.map((p) => ( +
    +
    +

    {p.name}

    + {p.connected && p.providerUserId && ( +

    + {t("settings.services.connectedAs", { id: p.providerUserId })} +

    + )} +
    + {p.connected ? ( +
    + + {t("sync.import")} + +
    + +
    +
    + ) : ( + + {t("settings.services.connect")} + + )} +
    + ))} +
    +
    + ); +} diff --git a/apps/journal/app/routes/settings.profile.server.ts b/apps/journal/app/routes/settings.profile.server.ts new file mode 100644 index 0000000..62865a2 --- /dev/null +++ b/apps/journal/app/routes/settings.profile.server.ts @@ -0,0 +1,17 @@ +// Server-only loader for /settings/profile. See `home.server.ts`. + +import { redirect } from "react-router"; +import { getSessionUser } from "~/lib/auth/session.server"; + +export async function loadProfileSettings(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return { + user: { + username: user.username, + displayName: user.displayName, + bio: user.bio, + profileVisibility: user.profileVisibility, + }, + }; +} diff --git a/apps/journal/app/routes/settings.profile.tsx b/apps/journal/app/routes/settings.profile.tsx new file mode 100644 index 0000000..3dda3d6 --- /dev/null +++ b/apps/journal/app/routes/settings.profile.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect } from "react"; +import { data, useFetcher } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/settings.profile"; +import { loadProfileSettings } from "./settings.profile.server"; + +export function meta() { + return [{ title: "Profile — Settings — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + return data(await loadProfileSettings(request)); +} + +export default function ProfileSettings({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + const { t } = useTranslation(["journal", "common"]); + const profileFetcher = useFetcher(); + + const [displayName, setDisplayName] = useState(user.displayName ?? ""); + const [bio, setBio] = useState(user.bio ?? ""); + const [profileVisibility, setProfileVisibility] = useState<"public" | "private">( + user.profileVisibility, + ); + const [profileSaved, setProfileSaved] = useState(false); + + useEffect(() => { + if (profileFetcher.data && !profileFetcher.data.error) { + setProfileSaved(true); + const timer = setTimeout(() => setProfileSaved(false), 3000); + return () => clearTimeout(timer); + } + }, [profileFetcher.data]); + + return ( +
    +

    {t("settings.profile.title")}

    + +
    + + setDisplayName(e.target.value)} + placeholder={user.username} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
    +
    + +