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/.claude/skills/ia-review/SKILL.md b/.agents/skills/ia-review/SKILL.md similarity index 100% rename from .claude/skills/ia-review/SKILL.md rename to .agents/skills/ia-review/SKILL.md 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/.claude/skills/spec-drift-review/SKILL.md b/.agents/skills/spec-drift-review/SKILL.md similarity index 100% rename from .claude/skills/spec-drift-review/SKILL.md rename to .agents/skills/spec-drift-review/SKILL.md 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 701416c..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 @@ -109,11 +136,34 @@ jobs: # 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 - docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force + # 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 + # 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 @@ -127,8 +177,13 @@ jobs: # deploy from failing if Caddy itself is unhealthy. docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true - # Clean up - docker image prune -af + # 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_SERVICE_TOKEN lives 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 index 8956ab9..e69dec6 100644 --- a/.github/workflows/cd-staging.yml +++ b/.github/workflows/cd-staging.yml @@ -17,7 +17,9 @@ on: - "infrastructure/docker-compose.staging.yml" - ".github/workflows/cd-staging.yml" pull_request: - types: [opened, synchronize, reopened, closed] + # `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/**" @@ -30,6 +32,12 @@ 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: @@ -38,7 +46,15 @@ jobs: # Skipped entirely on PR close (teardown doesn't need new images). build-images: name: Build & Push Docker Images - if: github.event_name != 'pull_request' || github.event.action != 'closed' + # 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: @@ -51,7 +67,7 @@ jobs: tag_primary: ${{ steps.tags.outputs.primary }} tag_sha: ${{ steps.tags.outputs.sha }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - id: tags name: Compute image tags @@ -89,6 +105,7 @@ jobs: 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 @@ -100,7 +117,7 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Decrypt secrets run: | @@ -118,6 +135,15 @@ jobs: 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 @@ -165,26 +191,48 @@ jobs: # 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 - 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 + # 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 - if: github.event_name == 'pull_request' && github.event.action != 'closed' + # 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@v6 + - uses: actions/checkout@v7 - id: ports name: Compute preview ports + project name @@ -204,7 +252,8 @@ jobs: 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 + # 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 }}" @@ -215,7 +264,18 @@ jobs: # 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 }}" - } >> infrastructure/staging.env + 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: | @@ -242,7 +302,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env,infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile" + source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env,infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile" target: /opt/trails-cool strip_components: 1 @@ -252,18 +312,25 @@ jobs: PR: ${{ steps.ports.outputs.pr }} PROJECT: ${{ steps.ports.outputs.project }} DB: ${{ steps.ports.outputs.database }} + JOURNAL_PORT: ${{ steps.ports.outputs.journal_port }} with: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - envs: PR,PROJECT,DB + envs: PR,PROJECT,DB,JOURNAL_PORT script: | set -euo pipefail cd /opt/trails-cool + ENV_FILE="staging-pr-${PR}.env" - GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-) + GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN "$ENV_FILE" | cut -d= -f2-) echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin + # Serialize all preview deploys with a server-side lock so concurrent + # CI jobs (e.g. a Dependabot batch) can't race on eviction or compose state. + exec 9>/tmp/trails-preview-deploy.lock + flock --timeout 300 9 || { echo "Timed out waiting for deploy lock after 300s"; exit 1; } + # Same network bootstrap as deploy-staging — see comment there. docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1) @@ -286,9 +353,12 @@ jobs: if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then echo "At cap; evicting oldest preview: $OLDEST" OLD_PR=${OLDEST#trails-pr-} - docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file staging.env down --remove-orphans || true + 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" + rm -f "sites/pr-$OLD_PR.caddyfile" "$OLD_ENV" fi fi fi @@ -303,20 +373,30 @@ jobs: "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 staging.env pull journal - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env up -d --remove-orphans journal + 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 - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env ps + # 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@v3 + uses: peter-evans/find-comment@v4 id: find-comment with: issue-number: ${{ github.event.number }} @@ -324,7 +404,7 @@ jobs: body-includes: "" - name: Upsert preview comment on PR - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-comment.outputs.comment-id }} issue-number: ${{ github.event.number }} @@ -344,7 +424,15 @@ jobs: # ── PR preview teardown ────────────────────────────────────────────── teardown-preview: name: Tear Down PR Preview - if: github.event_name == 'pull_request' && github.event.action == 'closed' + # 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: @@ -358,19 +446,19 @@ jobs: echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT" echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v6 + - 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.env + 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.env + } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env - name: Copy compose + env (teardown still needs the file) uses: appleboy/scp-action@v1 @@ -378,7 +466,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env" + source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env" target: /opt/trails-cool strip_components: 1 @@ -396,19 +484,20 @@ jobs: 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 staging.env down --remove-orphans || true + 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 and reload - rm -f "sites/pr-$PR.caddyfile" + # 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@v3 + uses: peter-evans/find-comment@v4 id: find-comment with: issue-number: ${{ github.event.number }} @@ -417,7 +506,7 @@ jobs: - name: Update preview comment on close if: steps.find-comment.outputs.comment-id - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-comment.outputs.comment-id }} edit-mode: replace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa9034e..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,14 @@ 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@v6 + - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -62,7 +62,7 @@ jobs: 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: @@ -75,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: @@ -88,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: @@ -101,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: @@ -110,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: @@ -125,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' @@ -190,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') }} @@ -231,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() }} @@ -268,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 index 595888f..bed2fd4 100644 --- a/.github/workflows/staging-cleanup.yml +++ b/.github/workflows/staging-cleanup.yml @@ -106,7 +106,7 @@ jobs: EOF docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file /tmp/cleanup.env down --remove-orphans || true docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true - rm -f "sites/pr-$PR.caddyfile" + rm -f "sites/pr-$PR.caddyfile" "staging-pr-${PR}.env" done rm -f /tmp/cleanup.env docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true diff --git a/.github/workflows/update-visual-snapshots.yml b/.github/workflows/update-visual-snapshots.yml new file mode 100644 index 0000000..bcbfea1 --- /dev/null +++ b/.github/workflows/update-visual-snapshots.yml @@ -0,0 +1,103 @@ +name: Update visual snapshots + +# How to use this workflow +# ======================== +# +# Visual snapshots live in `apps/planner/app/**/__screenshots__/` and are +# committed to the repo. They are generated by Vitest browser mode running +# the Planner's `*.browser.test.tsx` files against real Chromium via Playwright. +# +# When to update snapshots: +# - You intentionally changed the look of the elevation chart (new color mode, +# layout change, etc.) and the old snapshots are now wrong. +# - You added a new `*.browser.test.tsx` test and need the initial snapshots. +# +# Two ways to trigger this workflow: +# +# 1. Manual dispatch (workflow_dispatch): +# Go to Actions → "Update visual snapshots" → "Run workflow". +# Choose the branch you want updated. The workflow will commit the new +# snapshots back to that branch. +# +# 2. PR label (update-snapshots): +# Add the `update-snapshots` label to any PR. The workflow will update +# snapshots on the PR's head branch. Remove the label after to avoid +# re-triggering on every subsequent push. +# +# After the workflow commits updated snapshots, pull the branch locally: +# git pull origin +# +# 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 a1332cf..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 @@ -117,8 +123,8 @@ local config symmetric. - **Route registration**: Both apps use explicit `routes.ts` (not file-based routing). When adding a new route file, you **must** add it to `apps/*/app/routes.ts` or it won't be compiled into the build. - All user-facing strings must use i18n (`useTranslation()` hook, never hardcode strings) -- 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) @@ -249,6 +255,17 @@ The shared file `infrastructure/docker-compose.staging.yml` covers both — env **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 index e41990c..e48200e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,6 +9,24 @@ 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 @@ -124,3 +142,57 @@ webhooks to the right user via `provider_user_id`. Unknown `(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 index ef8718a..8a51ed0 100644 --- a/apps/journal/.env.example +++ b/apps/journal/.env.example @@ -49,6 +49,14 @@ # 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`. diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 585803c..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,13 +9,12 @@ 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/ @@ -23,11 +22,16 @@ 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 @@ -36,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/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. +
  • +
+
+

Sentry

-

- {t("activities.title")} ({activities.length}) -

+
+

+ {t("activities.title")} ({activities.length}) +

+ {activities.length > 0 && ( + + )} +
{activities.length === 0 ? (

{t("profile.noPublicActivities")}

) : ( @@ -270,12 +234,22 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
  • - {a.name} - {a.distance != null && ( - - {(a.distance / 1000).toFixed(1)} km - - )} + + {a.name} + + +
    {a.description && (

    {a.description}

    diff --git a/apps/journal/app/styles.css b/apps/journal/app/styles.css new file mode 100644 index 0000000..a6335a7 --- /dev/null +++ b/apps/journal/app/styles.css @@ -0,0 +1,10 @@ +@import "tailwindcss"; +@import "@trails-cool/ui/theme.css"; + +/* Scan the shared UI package so its token utility classes are generated. */ +@source "../../../packages/ui/src"; + +@keyframes slide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(400%); } +} diff --git a/apps/journal/package.json b/apps/journal/package.json index 26e2b77..659169b 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -12,38 +12,41 @@ "test": "vitest run" }, "dependencies": { + "@fedify/fedify": "2.3.1", + "@js-temporal/polyfill": "^0.5.1", + "@logtape/logtape": "^2.2.4", "@react-router/node": "catalog:", "@react-router/serve": "catalog:", "@sentry/node": "catalog:", "@sentry/react": "catalog:", "@simplewebauthn/browser": "^13.3.0", - "@simplewebauthn/server": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "@trails-cool/api": "workspace:*", "@trails-cool/db": "workspace:*", - "@trails-cool/jobs": "workspace:*", "@trails-cool/fit": "workspace:*", "@trails-cool/gpx": "workspace:*", "@trails-cool/i18n": "workspace:*", - "@trails-cool/map": "workspace:*", + "@trails-cool/jobs": "workspace:*", + "@trails-cool/map-core": "workspace:*", + "@trails-cool/ui": "workspace:*", "@trails-cool/sentry-config": "workspace:*", "@trails-cool/types": "workspace:*", - "@trails-cool/ui": "workspace:*", "drizzle-orm": "catalog:", - "isbot": "^5.1.39", + "isbot": "^5.2.0", "jose": "^6.2.3", - "nodemailer": "^8.0.7", + "nodemailer": "^9.0.3", "pino": "^10.3.1", "prom-client": "^15.1.3", "react": "catalog:", "react-dom": "catalog:", "react-router": "catalog:", - "zod": "^4.4.2" + "zod": "^4.4.3" }, "devDependencies": { "@react-router/dev": "catalog:", "@simplewebauthn/types": "^12.0.0", "@tailwindcss/vite": "catalog:", - "@types/nodemailer": "^8.0.0", + "@types/nodemailer": "^8.0.1", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-basic-ssl": "^2.3.0", diff --git a/apps/journal/react-router.config.ts b/apps/journal/react-router.config.ts index e45e273..5142398 100644 --- a/apps/journal/react-router.config.ts +++ b/apps/journal/react-router.config.ts @@ -2,4 +2,11 @@ import type { Config } from "@react-router/dev/config"; export default { ssr: true, + future: { + // Route middleware — used for ActivityPub content negotiation on + // /users/:username (see app/lib/federation.server.ts). No loader or + // action reads the `context` arg, so the flag's type change to + // RouterContextProvider is a no-op for existing code. + v8_middleware: true, + }, } satisfies Config; diff --git a/apps/journal/serve-static.test.ts b/apps/journal/serve-static.test.ts new file mode 100644 index 0000000..f35debd --- /dev/null +++ b/apps/journal/serve-static.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from "vitest"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { serveStatic } from "./serve-static.ts"; + +function mockReq(url: string, method = "GET"): IncomingMessage { + return { url, method, headers: { host: "trails.cool" } } as unknown as IncomingMessage; +} + +function mockRes(): ServerResponse { + return { setHeader: vi.fn() } as unknown as ServerResponse; +} + +describe("serveStatic", () => { + // Regression: a malformed path makes `new URL` throw ERR_INVALID_URL. + // Because serveStatic runs synchronously inside the HTTP server + // callback, an unhandled throw is process-fatal — a single `GET //` + // crash-looped the journal in prod. It must fall through (return false) + // so the request reaches React Router and 404s instead. + it.each(["//", "///", "/\\", "//foo\\bar"])( + "returns false without throwing for malformed path %j", + (path) => { + const req = mockReq(path); + expect(() => serveStatic(req, mockRes())).not.toThrow(); + expect(serveStatic(req, mockRes())).toBe(false); + }, + ); + + it("falls through for non-GET/HEAD methods", () => { + expect(serveStatic(mockReq("/assets/app.js", "POST"), mockRes())).toBe(false); + }); + + it("falls through for a well-formed path with no matching file", () => { + expect(serveStatic(mockReq("/does/not/exist.js"), mockRes())).toBe(false); + }); +}); diff --git a/apps/journal/serve-static.ts b/apps/journal/serve-static.ts new file mode 100644 index 0000000..3b317ef --- /dev/null +++ b/apps/journal/serve-static.ts @@ -0,0 +1,55 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { createReadStream, statSync } from "node:fs"; +import { join, extname, resolve } from "node:path"; + +const CLIENT_DIR = resolve(import.meta.dirname, "build", "client"); + +const MIME: Record = { + ".js": "application/javascript", + ".css": "text/css", + ".html": "text/html", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +/** + * Serve a static asset from the client build. Returns true if the request + * was handled, false if it should fall through to the app request handler. + * + * Runs synchronously inside the HTTP server callback, so it must never + * throw: a thrown error here is an uncaught exception that crashes the + * whole process. Malformed request paths (e.g. `//`, `///`, `/\`) make + * `new URL` throw `ERR_INVALID_URL`; we catch that and fall through so + * React Router 404s the request instead of taking the server down. + */ +export function serveStatic(req: IncomingMessage, res: ServerResponse): boolean { + if (req.method !== "GET" && req.method !== "HEAD") return false; + + let url: URL; + try { + url = new URL(req.url ?? "/", `http://${req.headers.host}`); + } catch { + return false; + } + const filePath = resolve(join(CLIENT_DIR, url.pathname)); + + if (!filePath.startsWith(CLIENT_DIR)) return false; + + try { + if (!statSync(filePath).isFile()) return false; + } catch { + return false; + } + + res.setHeader("Content-Type", MIME[extname(filePath)] ?? "application/octet-stream"); + if (url.pathname.startsWith("/assets/")) { + res.setHeader("Cache-Control", "public, immutable, max-age=31536000"); + } + createReadStream(filePath).pipe(res); + return true; +} diff --git a/apps/journal/server.ts b/apps/journal/server.ts index 6367191..3e0d8f4 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -2,56 +2,29 @@ import * as Sentry from "@sentry/node"; import { nodeSentryConfig, drop404s } from "@trails-cool/sentry-config"; import { createRequestListener } from "@react-router/node"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { createReadStream, statSync } from "node:fs"; -import { join, extname, resolve } from "node:path"; -import { logger } from "./app/lib/logger.server.ts"; -import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts"; +import { serveStatic } from "./serve-static.ts"; +import { logger, requestContext } from "./app/lib/logger.server.ts"; +import { randomUUID } from "node:crypto"; +import { httpRequestDuration, normalizeRoute, registry } from "./app/lib/metrics.server.ts"; import { createBoss, startWorker } from "@trails-cool/jobs"; -import postgres from "postgres"; +import { getDatabaseUrl } from "@trails-cool/db"; +import postgres, { type Sql } from "postgres"; -Sentry.init({ - dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", - ...nodeSentryConfig("journal server"), - beforeSend: drop404s, -}); +// Sentry DSN is supplied via env. No hardcoded fallback — self-hosted +// instances default to no Sentry. Flagship sets SENTRY_DSN via +// docker-compose env (see infrastructure/docker-compose.yml). +// SENTRY_DISABLED=true is an explicit opt-out even when a DSN is set +// (useful for local prod-mode debugging). +const sentryDsn = process.env.SENTRY_DSN; +if (process.env.SENTRY_DISABLED !== "true" && sentryDsn) { + Sentry.init({ + dsn: sentryDsn, + ...nodeSentryConfig("journal server"), + beforeSend: drop404s, + }); +} const port = Number(process.env.PORT ?? 3000); -const CLIENT_DIR = resolve(import.meta.dirname, "build", "client"); - -const MIME: Record = { - ".js": "application/javascript", - ".css": "text/css", - ".html": "text/html", - ".json": "application/json", - ".png": "image/png", - ".jpg": "image/jpeg", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", -}; - -function serveStatic(req: IncomingMessage, res: ServerResponse): boolean { - if (req.method !== "GET" && req.method !== "HEAD") return false; - - const url = new URL(req.url ?? "/", `http://${req.headers.host}`); - const filePath = resolve(join(CLIENT_DIR, url.pathname)); - - if (!filePath.startsWith(CLIENT_DIR)) return false; - - try { - if (!statSync(filePath).isFile()) return false; - } catch { - return false; - } - - res.setHeader("Content-Type", MIME[extname(filePath)] ?? "application/octet-stream"); - if (url.pathname.startsWith("/assets/")) { - res.setHeader("Cache-Control", "public, immutable, max-age=31536000"); - } - createReadStream(filePath).pipe(res); - return true; -} const listener = createRequestListener({ build: () => import("./build/server/index.js" as string) as never, @@ -65,17 +38,27 @@ async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promis const version = process.env.SENTRY_RELEASE ?? "dev"; +// Module-level singleton postgres client dedicated to /api/health. The +// previous handler opened a fresh client + connection on every call, +// which OOM'd the process under monitoring load (probes hit /api/health +// every few seconds). `max: 2` is plenty for liveness checks; the main +// app DB pool is separate (via @trails-cool/db's createDb). +let healthClient: Sql | null = null; +function getHealthClient(): Sql { + if (!healthClient) { + healthClient = postgres(getDatabaseUrl(), { max: 2, idle_timeout: 30 }); + } + return healthClient; +} + async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise { - const client = postgres(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails", { max: 1 }); try { - await client`SELECT 1`; + await getHealthClient()`SELECT 1`; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", version, db: "connected" })); } catch { res.writeHead(503, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" })); - } finally { - await client.end(); } } @@ -83,22 +66,32 @@ const server = createServer((req, res) => { const url = req.url ?? "/"; const start = Date.now(); - if (!url.startsWith("/assets/") && url !== "/api/health" && url !== "/api/metrics") { - res.on("finish", () => { - const duration = Date.now() - start; - logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request"); - httpRequestDuration.observe( - { method: req.method ?? "GET", route: url.split("?")[0]!, status: String(res.statusCode) }, - duration / 1000, - ); - }); - } + // Honor an inbound X-Request-Id header (e.g. from Caddy or a probe) + // so request IDs propagate across the proxy hop. Mint a fresh one if + // absent. Echo on the response so clients can correlate. + const inbound = req.headers["x-request-id"]; + const requestId = + (Array.isArray(inbound) ? inbound[0] : inbound) || randomUUID(); + res.setHeader("X-Request-Id", requestId); - if (url === "/api/health") { handleHealth(req, res); return; } - if (url === "/api/metrics") { handleMetrics(req, res); return; } - if (!serveStatic(req, res)) { - listener(req, res); - } + requestContext.run({ requestId }, () => { + if (!url.startsWith("/assets/") && url !== "/api/health" && url !== "/api/metrics") { + res.on("finish", () => { + const duration = Date.now() - start; + logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request"); + httpRequestDuration.observe( + { method: req.method ?? "GET", route: normalizeRoute(url), status: String(res.statusCode) }, + duration / 1000, + ); + }); + } + + if (url === "/api/health") { handleHealth(req, res); return; } + if (url === "/api/metrics") { handleMetrics(req, res); return; } + if (!serveStatic(req, res)) { + listener(req, res); + } + }); }); server.listen(port, async () => { @@ -143,13 +136,43 @@ server.listen(port, async () => { // notifications wired up would silently drop fan-out enqueues. const { notificationsFanoutJob } = await import("./app/jobs/notifications-fanout.ts"); const { notificationsPurgeJob } = await import("./app/jobs/notifications-purge.ts"); - jobs.push(notificationsFanoutJob, notificationsPurgeJob); + const { komootBulkImportJob } = await import("./app/jobs/komoot-bulk-import.ts"); + const { importBatchesSweepJob } = await import("./app/jobs/import-batches-sweep.ts"); + const { sendWelcomeEmailJob } = await import("./app/jobs/send-welcome-email.ts"); + const { consumedJtiSweepJob } = await import("./app/jobs/consumed-jti-sweep.ts"); + const { garminImportActivityJob } = await import("./app/jobs/garmin-import-activity.ts"); + const { surfaceBackfillJob } = await import("./app/jobs/surface-backfill.ts"); + jobs.push(notificationsFanoutJob, notificationsPurgeJob, komootBulkImportJob, importBatchesSweepJob, sendWelcomeEmailJob, consumedJtiSweepJob, garminImportActivityJob, surfaceBackfillJob); + // Federation jobs — registered only when federation is on. + if (process.env.FEDERATION_ENABLED === "true") { + const { backfillUserKeypairsJob } = await import("./app/jobs/backfill-user-keypairs.ts"); + const { federationKvSweepJob } = await import("./app/jobs/federation-kv-sweep.ts"); + const { federationDedupSweepJob } = await import("./app/jobs/federation-dedup-sweep.ts"); + const { deliverActivityJob } = await import("./app/jobs/deliver-activity.ts"); + const { pollRemoteActorJob } = await import("./app/jobs/poll-remote-actor.ts"); + const { pollRemoteOutboxesJob } = await import("./app/jobs/poll-remote-outboxes.ts"); + jobs.push(backfillUserKeypairsJob, federationKvSweepJob, federationDedupSweepJob, deliverActivityJob, pollRemoteActorJob, pollRemoteOutboxesJob); + } - const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"); + const boss = createBoss(getDatabaseUrl()); await startWorker(boss, jobs); // Register the started boss so feature code can enqueue jobs against // the same instance via getBoss() / enqueueOptional(). - const { setBoss } = await import("./app/lib/boss.server.ts"); + const { setBoss, enqueue } = await import("./app/lib/boss.server.ts"); setBoss(boss); logger.info("Background job worker started"); + + // One-shot federation keypair backfill per startup (spec: existing + // users get keys before any federation traffic). Each run only + // touches users whose public_key IS NULL, so repeats are no-ops. + if (process.env.FEDERATION_ENABLED === "true") { + // Create the durable queue backing Fedify's message queue before any + // federation traffic can enqueue/consume on it. pg-boss requires queues + // to exist explicitly (v10+); createQueue is idempotent. + const { FEDERATION_QUEUE_NAME } = await import("./app/lib/federation-queue.server.ts"); + await boss.createQueue(FEDERATION_QUEUE_NAME); + + await enqueue("backfill-user-keypairs", {}); + logger.info("federation keypair backfill enqueued"); + } }); diff --git a/apps/journal/vitest.config.ts b/apps/journal/vitest.config.ts index 558bad0..ecd0a99 100644 --- a/apps/journal/vitest.config.ts +++ b/apps/journal/vitest.config.ts @@ -3,6 +3,13 @@ import shared from "../../vitest.shared.ts"; import { resolve } from "node:path"; export default mergeConfig(shared, defineConfig({ + test: { + // mergeConfig concatenates arrays, so this adds to the shared + // include globs rather than replacing them. Picks up co-located + // tests for root-level server infra (server.ts, serve-static.ts) + // that live outside app/ and src/. + include: ["*.test.{ts,tsx}"], + }, resolve: { alias: { "~": resolve(import.meta.dirname, "app"), diff --git a/apps/mobile/__mocks__/expo-crypto.ts b/apps/mobile/__mocks__/expo-crypto.ts new file mode 100644 index 0000000..2c724f2 --- /dev/null +++ b/apps/mobile/__mocks__/expo-crypto.ts @@ -0,0 +1,5 @@ +export const CryptoDigestAlgorithm = { SHA256: "SHA-256" } as const; +export const CryptoEncoding = { BASE64: "base64" } as const; + +export const getRandomBytes = jest.fn((size: number) => new Uint8Array(size)); +export const digestStringAsync = jest.fn(async () => "mock-digest"); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 7e0dc7b..714e5e2 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,7 +1,5 @@ import { ExpoConfig, ConfigContext } from "expo/config"; -// `newArchEnabled` isn't in the Expo SDK 55 typings yet but is an -// accepted runtime flag. MapLibre RN v11 requires the new architecture. type ExpoConfigWithNewArch = ExpoConfig & { newArchEnabled?: boolean }; export default ({ config }: ConfigContext): ExpoConfigWithNewArch => ({ @@ -14,11 +12,6 @@ export default ({ config }: ConfigContext): ExpoConfigWithNewArch => ({ orientation: "portrait", icon: "./assets/icon.png", userInterfaceStyle: "light", - splash: { - image: "./assets/splash-icon.png", - resizeMode: "contain", - backgroundColor: "#ffffff", - }, ios: { supportsTablet: true, bundleIdentifier: "cool.trails.app", @@ -41,6 +34,12 @@ export default ({ config }: ConfigContext): ExpoConfigWithNewArch => ({ }, plugins: [ "expo-router", + "expo-localization", + ["expo-splash-screen", { + image: "./assets/splash-icon.png", + resizeMode: "contain", + backgroundColor: "#ffffff", + }], "expo-secure-store", "expo-web-browser", "@maplibre/maplibre-react-native", diff --git a/apps/mobile/app/__tests__/smoke.test.tsx b/apps/mobile/app/__tests__/smoke.test.tsx index 907e02a..f7922d9 100644 --- a/apps/mobile/app/__tests__/smoke.test.tsx +++ b/apps/mobile/app/__tests__/smoke.test.tsx @@ -3,8 +3,10 @@ import { render, screen } from "@testing-library/react-native"; import { Text } from "react-native"; describe("Jest + React Native Testing Library", () => { - it("renders a component", () => { - render(Hello); + it("renders a component", async () => { + // await is a no-op on RNTL v13 but required on v14, where render() + // returns a Promise + await render(Hello); expect(screen.getByTestId("hello")).toBeTruthy(); expect(screen.getByText("Hello")).toBeTruthy(); }); diff --git a/apps/mobile/lib/editor/RouteMap.tsx b/apps/mobile/lib/editor/RouteMap.tsx index afcd600..d627860 100644 --- a/apps/mobile/lib/editor/RouteMap.tsx +++ b/apps/mobile/lib/editor/RouteMap.tsx @@ -77,10 +77,11 @@ function RouteMapInner({ const cameraRef = useRef(null); const handleLongPress = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event: any) => { - // v11 event shape: payload lives on `event.nativeEvent.lngLat` - // (the old `event.geometry.coordinates` is gone). + // maplibre-react-native v11+ codegen `NativePressEvent` isn't + // re-exported as a type, so we declare the slice we use locally. + // Payload moved from `event.geometry.coordinates` (v10) to + // `event.nativeEvent.lngLat` in v11. + (event: { nativeEvent?: { lngLat?: readonly [number, number] } }) => { const lngLat = event?.nativeEvent?.lngLat; if (Array.isArray(lngLat) && lngLat.length >= 2) { onLongPress(lngLat[1] as number, lngLat[0] as number); diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index fb69142..db3f822 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -6,8 +6,8 @@ const monorepoRoot = path.resolve(projectRoot, "../.."); const config = getDefaultConfig(projectRoot); -// Watch all files in the monorepo -config.watchFolders = [monorepoRoot]; +// Watch all files in the monorepo (merge with Expo defaults) +config.watchFolders = [...(config.watchFolders ?? []), monorepoRoot]; // Resolve modules from both the project and monorepo root config.resolver.nodeModulesPaths = [ diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e5ef23d..984a4c3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -6,7 +6,8 @@ "expo": { "install": { "exclude": [ - "react" + "react", + "@sentry/react-native" ] } }, @@ -29,51 +30,51 @@ ] }, "dependencies": { - "@expo/metro-runtime": "^55.0.10", - "@gorhom/bottom-sheet": "^5.2.13", - "@maplibre/maplibre-react-native": "^11.0.2", - "@sentry/cli": "^3.4.1", - "@sentry/react-native": "~8.10.0", + "@expo/metro-runtime": "^56.0.15", + "@gorhom/bottom-sheet": "^5.2.14", + "@maplibre/maplibre-react-native": "^11.3.6", + "@sentry/cli": "^3.6.0", + "@sentry/react-native": "~8.13.0", "@trails-cool/api": "workspace:*", "@trails-cool/gpx": "workspace:*", "@trails-cool/i18n": "workspace:*", "@trails-cool/map-core": "workspace:*", "@trails-cool/sentry-config": "workspace:*", "@trails-cool/types": "workspace:*", - "expo": "~55.0.19", - "expo-constants": "~55.0.15", - "expo-crypto": "~55.0.14", - "expo-dev-client": "~55.0.30", - "expo-dev-menu": "^55.0.26", - "expo-device": "~55.0.15", - "expo-file-system": "~55.0.17", - "expo-linking": "~55.0.14", - "expo-localization": "~55.0.13", - "expo-location": "~55.1.8", - "expo-navigation-bar": "~55.0.12", - "expo-notifications": "~55.0.22", - "expo-router": "~55.0.13", - "expo-secure-store": "~55.0.13", - "expo-splash-screen": "~55.0.19", - "expo-sqlite": "~55.0.15", - "expo-status-bar": "~55.0.5", - "expo-system-ui": "^55.0.16", - "expo-web-browser": "~55.0.14", + "expo": "~56.0.12", + "expo-constants": "~56.0.18", + "expo-crypto": "~56.0.4", + "expo-dev-client": "~56.0.20", + "expo-device": "~56.0.4", + "expo-file-system": "~56.0.8", + "expo-linking": "~56.0.14", + "expo-localization": "~56.0.6", + "expo-location": "~56.0.18", + "expo-navigation-bar": "~56.0.3", + "expo-notifications": "~56.0.18", + "expo-router": "~56.2.11", + "expo-secure-store": "~56.0.4", + "expo-splash-screen": "~56.0.10", + "expo-sqlite": "~56.0.5", + "expo-status-bar": "~56.0.4", + "expo-system-ui": "^56.0.5", + "expo-web-browser": "~56.0.5", "react": "catalog:", - "react-native": "0.83.4", - "react-native-gesture-handler": "^2.31.1", - "react-native-reanimated": "^4.3.0", + "react-native": "0.85.3", + "react-native-gesture-handler": "^2.31.2", + "react-native-reanimated": "^4.3.1", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "~4.24.0", - "use-latest-callback": "^0.3.3", - "zod": "^4.4.2" + "react-native-screens": "~4.25.2", + "react-native-worklets": "0.8.3", + "use-latest-callback": "^0.3.4", + "zod": "^4.4.3" }, "devDependencies": { - "@testing-library/react-native": "^13.3.3", + "@testing-library/react-native": "^14.0.1", "@types/jest": "^29.5.14", - "@types/react": "~19.2.14", - "jest-expo": "^55.0.15", - "react-test-renderer": "^19.2.5", - "typescript": "~5.9.2" + "@types/react": "~19.2.17", + "jest-expo": "^56.0.4", + "react-test-renderer": "^19.2.7", + "typescript": "~6.0.3" } } diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index dc95243..1dda7a8 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "strict": true, "allowImportingTsExtensions": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"] + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "types": ["jest"] } } diff --git a/apps/planner/.gitignore b/apps/planner/.gitignore new file mode 100644 index 0000000..2625868 --- /dev/null +++ b/apps/planner/.gitignore @@ -0,0 +1 @@ +.vitest-attachments/ diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index e4fd73e..e0e567b 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/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,13 +9,12 @@ FROM base AS deps COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/planner/package.json apps/planner/ 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/ diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index f0379d7..d67b0d7 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -11,7 +11,12 @@ import { elevationColor, routeGradeColor, maxspeedColor, } from "@trails-cool/map-core"; -export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway" | "maxspeed" | "smoothness" | "tracktype" | "cycleway" | "bikeroute"; +import type { ColorMode } from "~/lib/route-data"; +import { buildColorRuns } from "~/lib/color-runs"; + +// ColorMode lives in the routeData schema module; re-exported here for +// existing importers. +export type { ColorMode }; interface ColoredRouteProps { coordinates: [number, number, number][]; // [lon, lat, ele] @@ -27,174 +32,59 @@ interface ColoredRouteProps { export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }: ColoredRouteProps) { const segments = useMemo(() => { - if (colorMode === "plain" || coordinates.length < 2) { - return null; - } + const n = coordinates.length; + if (colorMode === "plain" || n < 2) return null; + + // Build a per-segment color function (segment i spans coord i → i+1). + // Return null when the required data channel isn't available yet. + let colorAt: (i: number) => string; if (colorMode === "elevation") { - const elevations = coordinates.map((c) => c[2]); - const minEle = Math.min(...elevations); - const maxEle = Math.max(...elevations); - const range = maxEle - minEle || 1; - - const result: { positions: L.LatLngExpression[]; color: string }[] = []; - for (let i = 0; i < coordinates.length - 1; i++) { - const t = (elevations[i]! - minEle) / range; - result.push({ - positions: [ - [coordinates[i]![1], coordinates[i]![0]], - [coordinates[i + 1]![1], coordinates[i + 1]![0]], - ], - color: elevationColor(t), - }); - } - return result; - } - - if (colorMode === "grade") { - const result: { positions: L.LatLngExpression[]; color: string }[] = []; - for (let i = 0; i < coordinates.length - 1; i++) { + const eles = coordinates.map((c) => c[2]); + const minEle = Math.min(...eles); + const range = Math.max(...eles) - minEle || 1; + // Quantize the gradient into buckets so equal-color runs can merge — + // otherwise every segment is a distinct color (one polyline each). + const BUCKETS = 24; + colorAt = (i) => { + const t = (eles[i]! - minEle) / range; + return elevationColor(Math.round(t * BUCKETS) / BUCKETS); + }; + } else if (colorMode === "grade") { + colorAt = (i) => { const c0 = coordinates[i]!; const c1 = coordinates[i + 1]!; - // Approximate distance in meters using lat/lon diff const dLat = (c1[1] - c0[1]) * 111320; const dLon = (c1[0] - c0[0]) * 111320 * Math.cos((c0[1] * Math.PI) / 180); const dist = Math.sqrt(dLat * dLat + dLon * dLon); const grade = dist > 0 ? ((c1[2] - c0[2]) / dist) * 100 : 0; - result.push({ - positions: [ - [c0[1], c0[0]], - [c1[1], c1[0]], - ], - color: routeGradeColor(grade), - }); - } - return result; + return routeGradeColor(grade); + }; + } else if (colorMode === "highway") { + if (!highways || highways.length < n) return null; + colorAt = (i) => HIGHWAY_COLORS[highways[i] ?? "unknown"] ?? DEFAULT_HIGHWAY_COLOR; + } else if (colorMode === "maxspeed") { + if (!maxspeeds || maxspeeds.length < n) return null; + colorAt = (i) => maxspeedColor(maxspeeds[i] ?? "unknown"); + } else if (colorMode === "smoothness") { + if (!smoothnesses || smoothnesses.length < n) return null; + colorAt = (i) => SMOOTHNESS_COLORS[smoothnesses[i] ?? "unknown"] ?? DEFAULT_SMOOTHNESS_COLOR; + } else if (colorMode === "tracktype") { + if (!tracktypes || tracktypes.length < n) return null; + colorAt = (i) => TRACKTYPE_COLORS[tracktypes[i] ?? "unknown"] ?? DEFAULT_TRACKTYPE_COLOR; + } else if (colorMode === "cycleway") { + if (!cycleways || cycleways.length < n) return null; + colorAt = (i) => CYCLEWAY_COLORS[cycleways[i] ?? "unknown"] ?? DEFAULT_CYCLEWAY_COLOR; + } else if (colorMode === "bikeroute") { + if (!bikeroutes || bikeroutes.length < n) return null; + colorAt = (i) => BIKEROUTE_COLORS[bikeroutes[i] ?? "none"] ?? DEFAULT_BIKEROUTE_COLOR; + } else { + // surface + if (!surfaces || surfaces.length < n) return null; + colorAt = (i) => SURFACE_COLORS[surfaces[i] ?? "unknown"] ?? DEFAULT_SURFACE_COLOR; } - // highway mode - if (colorMode === "highway") { - if (!highways || highways.length < coordinates.length) return null; - - const result: { positions: L.LatLngExpression[]; color: string }[] = []; - for (let i = 0; i < coordinates.length - 1; i++) { - const highway = highways[i] ?? "unknown"; - result.push({ - positions: [ - [coordinates[i]![1], coordinates[i]![0]], - [coordinates[i + 1]![1], coordinates[i + 1]![0]], - ], - color: HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_COLOR, - }); - } - return result; - } - - // maxspeed mode - if (colorMode === "maxspeed") { - if (!maxspeeds || maxspeeds.length < coordinates.length) return null; - - const result: { positions: L.LatLngExpression[]; color: string }[] = []; - for (let i = 0; i < coordinates.length - 1; i++) { - const speed = maxspeeds[i] ?? "unknown"; - result.push({ - positions: [ - [coordinates[i]![1], coordinates[i]![0]], - [coordinates[i + 1]![1], coordinates[i + 1]![0]], - ], - color: maxspeedColor(speed), - }); - } - return result; - } - - // smoothness mode - if (colorMode === "smoothness") { - if (!smoothnesses || smoothnesses.length < coordinates.length) return null; - - const result: { positions: L.LatLngExpression[]; color: string }[] = []; - for (let i = 0; i < coordinates.length - 1; i++) { - const smoothness = smoothnesses[i] ?? "unknown"; - result.push({ - positions: [ - [coordinates[i]![1], coordinates[i]![0]], - [coordinates[i + 1]![1], coordinates[i + 1]![0]], - ], - color: SMOOTHNESS_COLORS[smoothness] ?? DEFAULT_SMOOTHNESS_COLOR, - }); - } - return result; - } - - // tracktype mode - if (colorMode === "tracktype") { - if (!tracktypes || tracktypes.length < coordinates.length) return null; - - const result: { positions: L.LatLngExpression[]; color: string }[] = []; - for (let i = 0; i < coordinates.length - 1; i++) { - const tracktype = tracktypes[i] ?? "unknown"; - result.push({ - positions: [ - [coordinates[i]![1], coordinates[i]![0]], - [coordinates[i + 1]![1], coordinates[i + 1]![0]], - ], - color: TRACKTYPE_COLORS[tracktype] ?? DEFAULT_TRACKTYPE_COLOR, - }); - } - return result; - } - - // cycleway mode - if (colorMode === "cycleway") { - if (!cycleways || cycleways.length < coordinates.length) return null; - - const result: { positions: L.LatLngExpression[]; color: string }[] = []; - for (let i = 0; i < coordinates.length - 1; i++) { - const cycleway = cycleways[i] ?? "unknown"; - result.push({ - positions: [ - [coordinates[i]![1], coordinates[i]![0]], - [coordinates[i + 1]![1], coordinates[i + 1]![0]], - ], - color: CYCLEWAY_COLORS[cycleway] ?? DEFAULT_CYCLEWAY_COLOR, - }); - } - return result; - } - - // bikeroute mode - if (colorMode === "bikeroute") { - if (!bikeroutes || bikeroutes.length < coordinates.length) return null; - - const result: { positions: L.LatLngExpression[]; color: string }[] = []; - for (let i = 0; i < coordinates.length - 1; i++) { - const bikeroute = bikeroutes[i] ?? "none"; - result.push({ - positions: [ - [coordinates[i]![1], coordinates[i]![0]], - [coordinates[i + 1]![1], coordinates[i + 1]![0]], - ], - color: BIKEROUTE_COLORS[bikeroute] ?? DEFAULT_BIKEROUTE_COLOR, - }); - } - return result; - } - - // surface mode - if (!surfaces || surfaces.length < coordinates.length) return null; - - const result: { positions: L.LatLngExpression[]; color: string }[] = []; - for (let i = 0; i < coordinates.length - 1; i++) { - const surface = surfaces[i] ?? "unknown"; - result.push({ - positions: [ - [coordinates[i]![1], coordinates[i]![0]], - [coordinates[i + 1]![1], coordinates[i + 1]![0]], - ], - color: SURFACE_COLORS[surface] ?? DEFAULT_SURFACE_COLOR, - }); - } - return result; + return buildColorRuns(coordinates, colorAt); }, [coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes]); const plainPositions = useMemo( @@ -206,7 +96,7 @@ export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxsp return ( ); diff --git a/apps/planner/app/components/DayBreakdown.tsx b/apps/planner/app/components/DayBreakdown.tsx index 58cfa9c..4500f76 100644 --- a/apps/planner/app/components/DayBreakdown.tsx +++ b/apps/planner/app/components/DayBreakdown.tsx @@ -19,27 +19,27 @@ export function DayBreakdown({ days, children }: DayBreakdownProps) {
    {isExpanded && ( <> -
    +
    ↑ {day.ascent} m ↓ {day.descent} m
    diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 5d5e006..0f6e13a 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -1,76 +1,22 @@ import { useEffect, useState, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { IconButton } from "@trails-cool/ui"; import type { DayStage } from "@trails-cool/gpx"; import type { YjsState } from "~/lib/use-yjs"; import { - elevationColor, maxspeedColor, - SURFACE_COLORS, DEFAULT_SURFACE_COLOR, - HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR, - SMOOTHNESS_COLORS, DEFAULT_SMOOTHNESS_COLOR, - TRACKTYPE_COLORS, DEFAULT_TRACKTYPE_COLOR, - CYCLEWAY_COLORS, DEFAULT_CYCLEWAY_COLOR, - BIKEROUTE_COLORS, DEFAULT_BIKEROUTE_COLOR, + SURFACE_COLORS, + DEFAULT_SURFACE_COLOR, + HIGHWAY_COLORS, + DEFAULT_HIGHWAY_COLOR, + SMOOTHNESS_COLORS, + DEFAULT_SMOOTHNESS_COLOR, + CYCLEWAY_COLORS, + DEFAULT_CYCLEWAY_COLOR, } from "@trails-cool/map-core"; -import type { ColorMode } from "~/components/ColoredRoute"; - -function gradeColor(grade: number): string { - const absGrade = Math.abs(grade); - if (absGrade < 3) return "#22c55e"; // green: flat/gentle - if (absGrade < 6) return "#eab308"; // yellow: moderate - if (absGrade < 10) return "#f97316"; // orange: steep - if (absGrade < 15) return "#ef4444"; // red: very steep - return "#991b1b"; // dark red: extreme -} - -interface ElevationPoint { - distance: number; - elevation: number; - lat: number; - lon: number; -} - -function extractElevation(geojsonStr: string): ElevationPoint[] { - try { - const geojson = JSON.parse(geojsonStr); - const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? []; - if (coords.length === 0) return []; - - const points: ElevationPoint[] = []; - let totalDist = 0; - - for (let i = 0; i < coords.length; i++) { - if (i > 0) { - const prev = coords[i - 1]!; - const curr = coords[i]!; - totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!); - } - if (coords[i]![2] !== undefined) { - points.push({ - distance: totalDist, - elevation: coords[i]![2]!, - lat: coords[i]![1]!, - lon: coords[i]![0]!, - }); - } - } - return points; - } catch { - return []; - } -} - -function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number { - const R = 6371000; - const toRad = (d: number) => (d * Math.PI) / 180; - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - -const PADDING = { top: 10, right: 10, bottom: 25, left: 40 }; +import { drawElevationChart, PADDING } from "~/lib/elevation-chart-draw"; +import { useElevationData } from "~/lib/use-elevation-data"; +import { Select } from "@trails-cool/ui"; +import { setColorMode, type ColorMode } from "~/lib/route-data"; interface ElevationChartProps { yjs: YjsState; @@ -79,28 +25,79 @@ interface ElevationChartProps { onClickPosition?: (position: [number, number]) => void; onDragSelect?: (bounds: [[number, number], [number, number]]) => void; days?: DayStage[]; + /** Authoritative route stats (same source as the sidebar) for the summary. */ + routeStats?: { distance?: number; elevationGain?: number; elevationLoss?: number }; } -export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days }: ElevationChartProps) { +const COLLAPSE_KEY = "planner:elevationCollapsed"; + +const ChevronDown = () => ( + + + +); +const ChevronUp = () => ( + + + +); + +/** Tiny inline elevation sparkline for the collapsed summary bar. */ +function Sparkline({ points }: { points: Array<{ distance: number; elevation: number }> }) { + if (points.length < 2) return null; + const W = 72; + const H = 18; + const eles = points.map((p) => p.elevation); + const min = Math.min(...eles); + const range = Math.max(...eles) - min || 1; + const maxD = points[points.length - 1]!.distance || 1; + const d = points + .map((p) => `${((p.distance / maxD) * W).toFixed(1)},${(H - ((p.elevation - min) / range) * H).toFixed(1)}`) + .join(" "); + return ( + + + + ); +} + +export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days, routeStats }: ElevationChartProps) { const { t } = useTranslation("planner"); - const [points, setPoints] = useState([]); + const { points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes } = useElevationData(yjs.routeData); + const [collapsed, setCollapsed] = useState( + () => typeof window !== "undefined" && localStorage.getItem(COLLAPSE_KEY) === "1", + ); + const toggleCollapsed = useCallback(() => { + setCollapsed((c) => { + const next = !c; + try { + localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0"); + } catch { + /* ignore */ + } + return next; + }); + }, []); + + // Collapsed-bar summary. Uses the authoritative routeStats (same source as + // the sidebar) so the figures match; falls back to the chart's own distance + // only when stats aren't available yet. + const distanceMeters = routeStats?.distance ?? (points.length ? points[points.length - 1]!.distance : 0); + const distanceKm = (distanceMeters / 1000).toFixed(1); + const ascent = routeStats?.elevationGain; + const descent = routeStats?.elevationLoss; + const [hoverIdx, setHoverIdx] = useState(null); - const [colorMode, setColorMode] = useState("plain"); - const [surfaces, setSurfaces] = useState([]); - const [highways, setHighways] = useState([]); - const [maxspeeds, setMaxspeeds] = useState([]); - const [smoothnesses, setSmoothnesses] = useState([]); - const [tracktypes, setTracktypes] = useState([]); - const [cycleways, setCycleways] = useState([]); - const [bikeroutes, setBikeroutes] = useState([]); const canvasRef = useRef(null); - const pointsRef = useRef([]); - pointsRef.current = points; const isExternalHover = useRef(false); const dragStartX = useRef(null); const dragStartClientX = useRef(null); const isDragging = useRef(false); const dragCurrentX = useRef(null); + const dragCleanup = useRef<(() => void) | null>(null); + + // Detach any dangling window drag listeners on unmount. + useEffect(() => () => dragCleanup.current?.(), []); // External highlight from map hover: find closest point by distance useEffect(() => { @@ -124,68 +121,9 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio } isExternalHover.current = true; setHoverIdx(closest); - // Do NOT call onHover here to avoid feedback loop }, [highlightDistance, points]); - useEffect(() => { - const update = () => { - const geojson = yjs.routeData.get("geojson") as string | undefined; - if (geojson) { - setPoints(extractElevation(geojson)); - } else { - setPoints([]); - } - const mode = yjs.routeData.get("colorMode") as ColorMode | undefined; - setColorMode(mode ?? "plain"); - const surfacesJson = yjs.routeData.get("surfaces") as string | undefined; - if (surfacesJson) { - try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); } - } else { - setSurfaces([]); - } - const highwaysJson = yjs.routeData.get("highways") as string | undefined; - if (highwaysJson) { - try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); } - } else { - setHighways([]); - } - const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined; - if (maxspeedsJson) { - try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); } - } else { - setMaxspeeds([]); - } - const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined; - if (smoothnessesJson) { - try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); } - } else { - setSmoothnesses([]); - } - const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined; - if (tracktypesJson) { - try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); } - } else { - setTracktypes([]); - } - const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined; - if (cyclewaysJson) { - try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); } - } else { - setCycleways([]); - } - const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined; - if (bikeroutesJson) { - try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); } - } else { - setBikeroutes([]); - } - }; - yjs.routeData.observe(update); - update(); - return () => yjs.routeData.unobserve(update); - }, [yjs.routeData]); - - const drawChart = useCallback( + const draw = useCallback( (highlightIdx: number | null) => { const canvas = canvasRef.current; if (!canvas || points.length < 2) return; @@ -199,406 +137,44 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); - const w = rect.width; - const h = rect.height; - const chartW = w - PADDING.left - PADDING.right; - const chartH = h - PADDING.top - PADDING.bottom; - - const maxDist = points[points.length - 1]!.distance; - const elevations = points.map((p) => p.elevation); - const minEle = Math.min(...elevations); - const maxEle = Math.max(...elevations); - const eleRange = maxEle - minEle || 1; - - const toX = (d: number) => PADDING.left + (d / maxDist) * chartW; - const toY = (e: number) => PADDING.top + chartH - ((e - minEle) / eleRange) * chartH; - - ctx.clearRect(0, 0, w, h); - - if (colorMode === "grade") { - // Grade-colored segments: color by steepness - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]!; - const p1 = points[i + 1]!; - const dDist = p1.distance - p0.distance; - const grade = dDist > 0 ? ((p1.elevation - p0.elevation) / dDist) * 100 : 0; - const color = gradeColor(grade); - - // Fill segment - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), PADDING.top + chartH); - ctx.lineTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.lineTo(toX(p1.distance), PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = color.replace(")", ", 0.25)").replace("rgb", "rgba").replace("#", ""); - // hex to rgba fill - ctx.fillStyle = color + "40"; - ctx.fill(); - - // Line segment - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - } else if (colorMode === "elevation") { - // Elevation-colored fill and line segments - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]!; - const p1 = points[i + 1]!; - const t = (p0.elevation - minEle) / eleRange; - const color = elevationColor(t); - - // Fill segment - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), PADDING.top + chartH); - ctx.lineTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.lineTo(toX(p1.distance), PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = color.replace("rgb", "rgba").replace(")", ", 0.2)"); - ctx.fill(); - - // Line segment - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - } else if (colorMode === "surface" && surfaces.length >= points.length) { - // Surface-colored segments - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]!; - const p1 = points[i + 1]!; - const surface = surfaces[i] ?? "unknown"; - const color = SURFACE_COLORS[surface] ?? DEFAULT_SURFACE_COLOR; - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), PADDING.top + chartH); - ctx.lineTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.lineTo(toX(p1.distance), PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = color + "40"; - ctx.fill(); - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - } else if (colorMode === "highway" && highways.length >= points.length) { - // Highway-colored segments - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]!; - const p1 = points[i + 1]!; - const highway = highways[i] ?? "unknown"; - const color = HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_COLOR; - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), PADDING.top + chartH); - ctx.lineTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.lineTo(toX(p1.distance), PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = color + "40"; - ctx.fill(); - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - } else if (colorMode === "maxspeed" && maxspeeds.length >= points.length) { - // Maxspeed-colored segments - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]!; - const p1 = points[i + 1]!; - const speed = maxspeeds[i] ?? "unknown"; - const color = maxspeedColor(speed); - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), PADDING.top + chartH); - ctx.lineTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.lineTo(toX(p1.distance), PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = color + "40"; - ctx.fill(); - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - } else if (colorMode === "smoothness" && smoothnesses.length >= points.length) { - // Smoothness-colored segments - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]!; - const p1 = points[i + 1]!; - const smoothness = smoothnesses[i] ?? "unknown"; - const color = SMOOTHNESS_COLORS[smoothness] ?? DEFAULT_SMOOTHNESS_COLOR; - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), PADDING.top + chartH); - ctx.lineTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.lineTo(toX(p1.distance), PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = color + "40"; - ctx.fill(); - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - } else if (colorMode === "tracktype" && tracktypes.length >= points.length) { - // Track type-colored segments - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]!; - const p1 = points[i + 1]!; - const tracktype = tracktypes[i] ?? "unknown"; - const color = TRACKTYPE_COLORS[tracktype] ?? DEFAULT_TRACKTYPE_COLOR; - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), PADDING.top + chartH); - ctx.lineTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.lineTo(toX(p1.distance), PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = color + "40"; - ctx.fill(); - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - } else if (colorMode === "cycleway" && cycleways.length >= points.length) { - // Cycleway-colored segments - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]!; - const p1 = points[i + 1]!; - const cycleway = cycleways[i] ?? "unknown"; - const color = CYCLEWAY_COLORS[cycleway] ?? DEFAULT_CYCLEWAY_COLOR; - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), PADDING.top + chartH); - ctx.lineTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.lineTo(toX(p1.distance), PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = color + "40"; - ctx.fill(); - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - } else if (colorMode === "bikeroute" && bikeroutes.length >= points.length) { - // Bike route-colored segments - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]!; - const p1 = points[i + 1]!; - const bikeroute = bikeroutes[i] ?? "none"; - const color = BIKEROUTE_COLORS[bikeroute] ?? DEFAULT_BIKEROUTE_COLOR; - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), PADDING.top + chartH); - ctx.lineTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.lineTo(toX(p1.distance), PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = color + "40"; - ctx.fill(); - - ctx.beginPath(); - ctx.moveTo(toX(p0.distance), toY(p0.elevation)); - ctx.lineTo(toX(p1.distance), toY(p1.elevation)); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - } else { - // Plain fill and line - ctx.beginPath(); - ctx.moveTo(PADDING.left, PADDING.top + chartH); - for (const p of points) { - ctx.lineTo(toX(p.distance), toY(p.elevation)); - } - ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = "rgba(37, 99, 235, 0.15)"; - ctx.fill(); - - ctx.beginPath(); - for (let i = 0; i < points.length; i++) { - const p = points[i]!; - if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation)); - else ctx.lineTo(toX(p.distance), toY(p.elevation)); - } - ctx.strokeStyle = "#2563eb"; - ctx.lineWidth = 1.5; - ctx.stroke(); - } - - // Axis labels - ctx.fillStyle = "#6b7280"; - ctx.font = "10px sans-serif"; - ctx.textAlign = "right"; - ctx.fillText(`${Math.round(maxEle)}m`, PADDING.left - 4, PADDING.top + 10); - ctx.fillText(`${Math.round(minEle)}m`, PADDING.left - 4, PADDING.top + chartH); - ctx.textAlign = "center"; - ctx.fillText("0 km", PADDING.left, h - 4); - ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4); - - // Day dividers - if (days && days.length > 1) { - for (let d = 0; d < days.length - 1; d++) { - // Find the point closest to the day boundary distance - const boundaryDist = days.slice(0, d + 1).reduce((sum, s) => sum + s.distance, 0); - const bx = toX(boundaryDist); - - // Dashed vertical line - ctx.beginPath(); - ctx.setLineDash([4, 3]); - ctx.moveTo(bx, PADDING.top); - ctx.lineTo(bx, PADDING.top + chartH); - ctx.strokeStyle = "#9A9484"; - ctx.lineWidth = 1; - ctx.stroke(); - ctx.setLineDash([]); - - // Day label at top - ctx.fillStyle = "#9A9484"; - ctx.font = "9px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText(`${(boundaryDist / 1000).toFixed(0)} km`, bx, h - 4); - } - } - - // Hover crosshair + info - if (highlightIdx !== null && highlightIdx >= 0 && highlightIdx < points.length) { - const p = points[highlightIdx]!; - const hx = toX(p.distance); - const hy = toY(p.elevation); - - // Vertical line - ctx.beginPath(); - ctx.moveTo(hx, PADDING.top); - ctx.lineTo(hx, PADDING.top + chartH); - ctx.strokeStyle = "rgba(239, 68, 68, 0.5)"; - ctx.lineWidth = 1; - ctx.stroke(); - - // Dot - ctx.beginPath(); - ctx.arc(hx, hy, 4, 0, Math.PI * 2); - ctx.fillStyle = "#ef4444"; - ctx.fill(); - ctx.strokeStyle = "white"; - ctx.lineWidth = 2; - ctx.stroke(); - - // Info label - ctx.fillStyle = "#1f2937"; - ctx.font = "bold 10px sans-serif"; - ctx.textAlign = "left"; - let label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`; - if (colorMode === "grade" && highlightIdx > 0) { - const prev = points[highlightIdx - 1]!; - const dDist = p.distance - prev.distance; - const grade = dDist > 0 ? ((p.elevation - prev.elevation) / dDist) * 100 : 0; - label += ` · ${grade > 0 ? "+" : ""}${grade.toFixed(1)}%`; - } - if (colorMode === "surface" && surfaces[highlightIdx]) { - label += ` · ${surfaces[highlightIdx]}`; - } - if (colorMode === "highway" && highways[highlightIdx]) { - label += ` · ${highways[highlightIdx]}`; - } - if (colorMode === "maxspeed" && maxspeeds[highlightIdx]) { - const s = maxspeeds[highlightIdx]!; - label += ` · ${s === "unknown" ? s : `${s} km/h`}`; - } - if (colorMode === "smoothness" && smoothnesses[highlightIdx]) { - label += ` · ${smoothnesses[highlightIdx]}`; - } - if (colorMode === "tracktype" && tracktypes[highlightIdx]) { - label += ` · ${tracktypes[highlightIdx]}`; - } - if (colorMode === "cycleway" && cycleways[highlightIdx]) { - label += ` · ${cycleways[highlightIdx]}`; - } - if (colorMode === "bikeroute" && bikeroutes[highlightIdx]) { - const names: Record = { icn: "International", ncn: "National", rcn: "Regional", lcn: "Local", none: "None" }; - label += ` · ${names[bikeroutes[highlightIdx]!] ?? bikeroutes[highlightIdx]}`; - } - const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8; - ctx.textAlign = hx + 8 > w - 80 ? "right" : "left"; - ctx.fillText(label, labelX, PADDING.top + 10); - } - - // Drag selection overlay - if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null) { - const x1 = Math.max(PADDING.left, Math.min(dragStartX.current, PADDING.left + chartW)); - const x2 = Math.max(PADDING.left, Math.min(dragCurrentX.current, PADDING.left + chartW)); - const selLeft = Math.min(x1, x2); - const selRight = Math.max(x1, x2); - ctx.fillStyle = "rgba(59, 130, 246, 0.15)"; - ctx.fillRect(selLeft, PADDING.top, selRight - selLeft, chartH); - ctx.strokeStyle = "rgba(59, 130, 246, 0.5)"; - ctx.lineWidth = 1; - ctx.strokeRect(selLeft, PADDING.top, selRight - selLeft, chartH); - } + drawElevationChart(ctx, rect.width, rect.height, { + points, + colorMode, + surfaces, + highways, + maxspeeds, + smoothnesses, + tracktypes, + cycleways, + bikeroutes, + hoverIdx: highlightIdx, + isDragging: isDragging.current, + dragStartX: dragStartX.current, + dragCurrentX: dragCurrentX.current, + days, + }); }, - [points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days, t], + [points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days], ); useEffect(() => { - drawChart(hoverIdx); - }, [points, hoverIdx, colorMode, drawChart]); + draw(hoverIdx); + }, [points, hoverIdx, colorMode, draw]); const handleMouseMove = useCallback( (e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas || points.length < 2) return; + // Drag is tracked via window listeners (see handleMouseDown); ignore + // canvas moves while a press/drag gesture is active. + if (dragStartClientX.current != null) return; + const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const chartW = rect.width - PADDING.left - PADDING.right; const ratio = (mouseX - PADDING.left) / chartW; - // Handle drag selection - if (dragStartClientX.current != null) { - const dx = Math.abs(e.clientX - dragStartClientX.current); - if (dx > 5) { - isDragging.current = true; - dragCurrentX.current = mouseX; - drawChart(hoverIdx); - return; - } - } - if (ratio < 0 || ratio > 1) { setHoverIdx(null); onHover?.(null); @@ -608,7 +184,6 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio const maxDist = points[points.length - 1]!.distance; const targetDist = ratio * maxDist; - // Find closest point let closest = 0; let minDiff = Infinity; for (let i = 0; i < points.length; i++) { @@ -624,16 +199,15 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio const p = points[closest]!; onHover?.([p.lat, p.lon]); }, - [points, onHover, hoverIdx, drawChart], + [points, onHover], ); const handleMouseLeave = useCallback(() => { + // Keep an in-progress drag alive when the pointer leaves the canvas — + // the window listeners keep tracking it. + if (dragStartClientX.current != null) return; setHoverIdx(null); onHover?.(null); - dragStartX.current = null; - dragStartClientX.current = null; - isDragging.current = false; - dragCurrentX.current = null; }, [onHover]); const handleMouseDown = useCallback( @@ -641,86 +215,84 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); + const chartW = rect.width - PADDING.left - PADDING.right; + dragStartX.current = e.clientX - rect.left; dragStartClientX.current = e.clientX; isDragging.current = false; dragCurrentX.current = null; - }, - [], - ); + setHoverIdx(null); + onHover?.(null); - const handleMouseUp = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas || points.length < 2) { + const clampX = (clientX: number) => + Math.max(PADDING.left, Math.min(clientX - rect.left, PADDING.left + chartW)); + + // Track the drag on the window so it survives the pointer leaving the + // canvas and still completes if the button is released outside it. + const onWindowMove = (ev: MouseEvent) => { + if (dragStartClientX.current == null) return; + if (Math.abs(ev.clientX - dragStartClientX.current) > 5) { + isDragging.current = true; + dragCurrentX.current = clampX(ev.clientX); + draw(null); + } + }; + + const detach = () => { + window.removeEventListener("mousemove", onWindowMove); + window.removeEventListener("mouseup", onWindowUp); + dragCleanup.current = null; + }; + + function onWindowUp(ev: MouseEvent) { + detach(); + if (points.length >= 2) { + const maxDist = points[points.length - 1]!.distance; + if (isDragging.current && dragStartX.current != null) { + const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW)); + const endRatio = Math.max(0, Math.min(1, (clampX(ev.clientX) - PADDING.left) / chartW)); + const startDist = Math.min(startRatio, endRatio) * maxDist; + const endDist = Math.max(startRatio, endRatio) * maxDist; + let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity; + let found = false; + for (const p of points) { + if (p.distance >= startDist && p.distance <= endDist) { + minLat = Math.min(minLat, p.lat); + maxLat = Math.max(maxLat, p.lat); + minLon = Math.min(minLon, p.lon); + maxLon = Math.max(maxLon, p.lon); + found = true; + } + } + if (found && onDragSelect) onDragSelect([[minLat, minLon], [maxLat, maxLon]]); + } else if (onClickPosition) { + const ratio = (ev.clientX - rect.left - PADDING.left) / chartW; + if (ratio >= 0 && ratio <= 1) { + const targetDist = ratio * maxDist; + let closest = 0; + let minDiff = Infinity; + for (let i = 0; i < points.length; i++) { + const diff = Math.abs(points[i]!.distance - targetDist); + if (diff < minDiff) { minDiff = diff; closest = i; } + } + onClickPosition([points[closest]!.lat, points[closest]!.lon]); + } + } + } dragStartX.current = null; dragStartClientX.current = null; isDragging.current = false; dragCurrentX.current = null; - return; + draw(null); } - const rect = canvas.getBoundingClientRect(); - const chartW = rect.width - PADDING.left - PADDING.right; - const maxDist = points[points.length - 1]!.distance; - - if (isDragging.current && dragStartX.current != null) { - // Drag-select: compute bounding box of selected range - const endX = e.clientX - rect.left; - const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW)); - const endRatio = Math.max(0, Math.min(1, (endX - PADDING.left) / chartW)); - const startDist = Math.min(startRatio, endRatio) * maxDist; - const endDist = Math.max(startRatio, endRatio) * maxDist; - - // Find all points in the selected distance range - let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity; - let found = false; - for (const p of points) { - if (p.distance >= startDist && p.distance <= endDist) { - minLat = Math.min(minLat, p.lat); - maxLat = Math.max(maxLat, p.lat); - minLon = Math.min(minLon, p.lon); - maxLon = Math.max(maxLon, p.lon); - found = true; - } - } - - if (found && onDragSelect) { - onDragSelect([[minLat, minLon], [maxLat, maxLon]]); - } - } else if (dragStartClientX.current != null) { - // Click (not drag): pan map to clicked point - const dx = Math.abs(e.clientX - dragStartClientX.current); - if (dx <= 5 && onClickPosition) { - const mouseX = e.clientX - rect.left; - const ratio = (mouseX - PADDING.left) / chartW; - if (ratio >= 0 && ratio <= 1) { - const targetDist = ratio * maxDist; - let closest = 0; - let minDiff = Infinity; - for (let i = 0; i < points.length; i++) { - const diff = Math.abs(points[i]!.distance - targetDist); - if (diff < minDiff) { - minDiff = diff; - closest = i; - } - } - const p = points[closest]!; - onClickPosition([p.lat, p.lon]); - } - } - } - - dragStartX.current = null; - dragStartClientX.current = null; - isDragging.current = false; - dragCurrentX.current = null; - drawChart(hoverIdx); + dragCleanup.current = detach; + window.addEventListener("mousemove", onWindowMove); + window.addEventListener("mouseup", onWindowUp); }, - [points, onClickPosition, onDragSelect, hoverIdx, drawChart], + [points, onHover, onDragSelect, onClickPosition, draw], ); - // Touch handlers for mobile const handleTouchStart = useCallback( (e: React.TouchEvent) => { e.preventDefault(); @@ -729,13 +301,11 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio const rect = canvas.getBoundingClientRect(); if (e.touches.length === 1) { - // Single touch: start scrub + potential drag const x = e.touches[0]!.clientX - rect.left; dragStartX.current = x; dragStartClientX.current = e.touches[0]!.clientX; isDragging.current = false; dragCurrentX.current = null; - // Immediately show highlight at touch position const chartW = rect.width - PADDING.left - PADDING.right; const ratio = (x - PADDING.left) / chartW; if (ratio >= 0 && ratio <= 1 && points.length >= 2) { @@ -753,16 +323,15 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio onHover?.([p.lat, p.lon]); } } else if (e.touches.length === 2) { - // Two fingers: range select const x1 = e.touches[0]!.clientX - rect.left; const x2 = e.touches[1]!.clientX - rect.left; dragStartX.current = Math.min(x1, x2); dragCurrentX.current = Math.max(x1, x2); isDragging.current = true; - drawChart(hoverIdx); + draw(hoverIdx); } }, - [points, onHover, hoverIdx, drawChart], + [points, onHover, hoverIdx, draw], ); const handleTouchMove = useCallback( @@ -773,7 +342,6 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio const rect = canvas.getBoundingClientRect(); if (e.touches.length === 1 && !isDragging.current) { - // Single touch scrub: update highlight const x = e.touches[0]!.clientX - rect.left; const chartW = rect.width - PADDING.left - PADDING.right; const ratio = (x - PADDING.left) / chartW; @@ -792,22 +360,20 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio onHover?.([p.lat, p.lon]); } } else if (e.touches.length === 2) { - // Two finger range select: update selection const x1 = e.touches[0]!.clientX - rect.left; const x2 = e.touches[1]!.clientX - rect.left; dragStartX.current = Math.min(x1, x2); dragCurrentX.current = Math.max(x1, x2); isDragging.current = true; - drawChart(hoverIdx); + draw(hoverIdx); } }, - [points, onHover, hoverIdx, drawChart], + [points, onHover, hoverIdx, draw], ); const handleTouchEnd = useCallback( (e: React.TouchEvent) => { if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null && points.length >= 2) { - // Two finger range complete: zoom map const canvas = canvasRef.current; if (canvas) { const rect = canvas.getBoundingClientRect(); @@ -834,10 +400,8 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio } } } else if (e.changedTouches.length === 1 && onClickPosition && dragStartClientX.current != null) { - // Single tap (no significant movement): pan map const dx = Math.abs(e.changedTouches[0]!.clientX - dragStartClientX.current); if (dx <= 10) { - // Treat as tap → pan const canvas = canvasRef.current; if (canvas && points.length >= 2) { const rect = canvas.getBoundingClientRect(); @@ -859,26 +423,51 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio } } - // Clear highlight on touch end setHoverIdx(null); onHover?.(null); dragStartX.current = null; dragStartClientX.current = null; isDragging.current = false; dragCurrentX.current = null; - drawChart(null); + draw(null); }, - [points, onHover, onClickPosition, onDragSelect, drawChart], + [points, onHover, onClickPosition, onDragSelect, draw], ); const setMode = useCallback((mode: string) => { - yjs.routeData.set("colorMode", mode); + setColorMode(yjs.routeData, mode as ColorMode); }, [yjs.routeData]); if (points.length < 2) return null; + if (collapsed) { + return ( +
    + + + {distanceKm} km + {ascent !== undefined && ( + <> + ·↑ {ascent} m + + )} + {descent !== undefined && ( + <> + ·↓ {descent} m + + )} + +
    + + + +
    +
    + ); + } + return ( -
    +
    {(() => { const osmLinks: Record = { @@ -902,14 +491,14 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio const title = titles[colorMode] ?? t("elevation.profile"); const link = osmLinks[colorMode]; return link ? ( - + {title} ) : ( -

    {title}

    +

    {title}

    ); })()} -
    +
    {colorMode === "grade" && (<> {"<3%"} {"<6%"} @@ -980,10 +569,12 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio None )}
    - + + + +
    0) { - return [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))]; - } - } catch { /* invalid geojson */ } - return []; -} - -function getWaypoints(yjs: YjsState) { - return yjs.waypoints.toArray().map((yMap: Y.Map) => ({ - lat: yMap.get("lat") as number, - lon: yMap.get("lon") as number, - name: yMap.get("name") as string | undefined, - isDayBreak: yMap.get("overnight") === true ? true : undefined, - })); -} - -function getNoGoAreas(yjs: YjsState): NoGoArea[] { - return yjs.noGoAreas.toArray().map((yMap: Y.Map) => ({ - points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], - })).filter((a) => a.points.length >= 3); -} +import { + buildDayGpxFiles, + buildPlanGpx, + buildRouteGpx, + hasDayBreaks, +} from "~/lib/gpx-export"; function download(gpx: string, filename: string) { const blob = new Blob([gpx], { type: "application/gpx+xml" }); @@ -59,108 +34,63 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { }, [open]); const handleExportRoute = useCallback(() => { - const tracks = getTracks(yjs); - const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks }); - download(gpx, "route.gpx"); + download(buildRouteGpx(yjs), "route.gpx"); setOpen(false); }, [yjs]); const handleExportPlan = useCallback(() => { - const tracks = getTracks(yjs); - const waypoints = getWaypoints(yjs); - const noGoAreas = getNoGoAreas(yjs); - const notes = yjs.notes.toString() || undefined; - const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas }); - download(gpx, "route-plan.gpx"); + download(buildPlanGpx(yjs), "route-plan.gpx"); setOpen(false); }, [yjs]); const handleExportDays = useCallback(() => { - const tracks = getTracks(yjs); - const waypoints = getWaypoints(yjs); - const allPoints = tracks.flat(); - if (allPoints.length === 0 || waypoints.length === 0) return; - - const days = computeDays(waypoints, tracks); - if (days.length <= 1) { - // Single day — just export the full route - const gpx = generateGpx({ name: "trails.cool route", tracks }); - download(gpx, "route.gpx"); - setOpen(false); - return; - } - - // Find closest track index for each waypoint - const wpTrackIndices = waypoints.map((wp) => { - let bestIdx = 0; - let bestDist = Infinity; - for (let i = 0; i < allPoints.length; i++) { - const dx = allPoints[i]!.lat - wp.lat; - const dy = allPoints[i]!.lon - wp.lon; - const d = dx * dx + dy * dy; - if (d < bestDist) { bestDist = d; bestIdx = i; } - } - return bestIdx; - }); - - for (const day of days) { - const startIdx = wpTrackIndices[day.startWaypointIndex]!; - const endIdx = wpTrackIndices[day.endWaypointIndex]!; - const dayPoints = allPoints.slice(startIdx, endIdx + 1); - const dayName = day.startName && day.endName - ? `Day ${day.dayNumber}: ${day.startName} - ${day.endName}` - : `Day ${day.dayNumber}`; - const gpx = generateGpx({ name: dayName, tracks: [dayPoints] }); - const filename = `day-${day.dayNumber}${day.startName ? `-${day.startName.toLowerCase().replace(/\s+/g, "-")}` : ""}.gpx`; - download(gpx, filename); + for (const file of buildDayGpxFiles(yjs)) { + download(file.gpx, file.filename); } setOpen(false); }, [yjs]); - const hasMultipleDays = (() => { - const waypoints = getWaypoints(yjs); - return waypoints.some((w) => w.isDayBreak); - })(); + const hasMultipleDays = hasDayBreaks(yjs); + + const item = + "block w-full px-3 py-1.5 text-left transition-colors hover:bg-bg-subtle"; + const split = + "h-7 border border-border bg-bg-raised text-sm font-medium text-text-hi transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"; return (
    {open && ( -
    - - {hasMultipleDays && ( - )}
    diff --git a/apps/planner/app/components/MapHelpers.tsx b/apps/planner/app/components/MapHelpers.tsx new file mode 100644 index 0000000..4443cc6 --- /dev/null +++ b/apps/planner/app/components/MapHelpers.tsx @@ -0,0 +1,271 @@ +import { useEffect, useState, useRef } from "react"; +import { useMap, useMapEvents, Marker } from "react-leaflet"; +import L from "leaflet"; +import type { YjsState } from "~/lib/use-yjs"; +import { overlayLayers } from "@trails-cool/map-core"; +import { usePois } from "~/lib/use-pois"; +import { Z_CURSOR } from "@trails-cool/map-core"; +import { getBaseLayer, getOverlays, setBaseLayer, setOverlays } from "~/lib/route-data"; + +// Exposes the Leaflet map instance on window.__leafletMap for E2E testing and external integrations. +export function MapExposer() { + const map = useMap(); + useEffect(() => { + if (typeof window !== "undefined") { + window.__leafletMap = map; + } + }, [map]); + return null; +} + +// Fits the map to the route bounds once on first load. +export function RouteFitter({ coordinates }: { coordinates: [number, number, number][] | null }) { + const map = useMap(); + const hasFitted = useRef(false); + + useEffect(() => { + if (hasFitted.current || !coordinates || coordinates.length < 2) return; + + const bounds = L.latLngBounds( + coordinates.map((c) => [c[1]!, c[0]!] as [number, number]), + ); + if (!bounds.isValid()) return; + + const raf = requestAnimationFrame(() => { + map.invalidateSize(); + map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 }); + hasFitted.current = true; + }); + return () => cancelAnimationFrame(raf); + }, [coordinates, map]); + + return null; +} + +// Routes map click events to a callback, with a suppressRef for one-shot suppression (e.g. after route insert). +export function MapClickHandler({ + onAdd, + suppressRef, +}: { + onAdd: (lat: number, lng: number) => void; + suppressRef: React.RefObject; +}) { + useMapEvents({ + click(e) { + if (suppressRef.current) { + suppressRef.current = false; + return; + } + onAdd(e.latlng.lat, e.latlng.lng); + }, + }); + return null; +} + +// Broadcasts the local user's cursor position via Yjs awareness and renders remote cursors. +export function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) { + const map = useMap(); + const [cursors, setCursors] = useState< + Map + >(new Map()); + + useEffect(() => { + const localId = awareness.clientID; + + const handleMouseMove = (e: L.LeafletMouseEvent) => { + awareness.setLocalStateField("mapCursor", { lat: e.latlng.lat, lng: e.latlng.lng }); + }; + const handleMouseOut = () => { + awareness.setLocalStateField("mapCursor", null); + }; + + map.on("mousemove", handleMouseMove); + map.on("mouseout", handleMouseOut); + + const updateCursors = () => { + const states = awareness.getStates(); + const newCursors = new Map(); + states.forEach((state, clientId) => { + if (clientId !== localId && state.mapCursor && state.user) { + newCursors.set(clientId, { + lat: state.mapCursor.lat, + lng: state.mapCursor.lng, + color: state.user.color, + name: state.user.name, + }); + } + }); + setCursors(newCursors); + }; + + awareness.on("change", updateCursors); + + return () => { + map.off("mousemove", handleMouseMove); + map.off("mouseout", handleMouseOut); + awareness.off("change", updateCursors); + }; + }, [map, awareness]); + + return ( + <> + {Array.from(cursors.entries()).map(([clientId, cursor]) => ( + + + + + ${cursor.name} +
    `, + iconSize: [0, 0], + })} + /> + ))} + + ); +} + +// Leaflet control button for toggling no-go area drawing mode. +export function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) L.DomEvent.disableClickPropagation(ref.current); + }, []); + + return ( + + ); +} + +// Keeps Yjs routeData and the Leaflet LayersControl in sync (both directions). +export function OverlaySync({ + yjs, + onOverlayChange, + onBaseLayerChange, +}: { + yjs: YjsState; + onOverlayChange: (ids: string[]) => void; + onBaseLayerChange: (name: string) => void; +}) { + const map = useMap(); + const suppressRef = useRef(false); + + useEffect(() => { + const handleAdd = (e: L.LayersControlEvent) => { + if (suppressRef.current) return; + const layer = overlayLayers.find((l) => l.name === e.name); + if (!layer) return; + const current = getOverlays(yjs.routeData) ?? []; + if (!current.includes(layer.id)) { + const updated = [...current, layer.id]; + setOverlays(yjs.routeData, updated); + onOverlayChange(updated); + } + }; + + const handleRemove = (e: L.LayersControlEvent) => { + if (suppressRef.current) return; + const layer = overlayLayers.find((l) => l.name === e.name); + if (!layer) return; + const updated = (getOverlays(yjs.routeData) ?? []).filter((id) => id !== layer.id); + setOverlays(yjs.routeData, updated); + onOverlayChange(updated); + }; + + const handleBaseChange = (e: L.LayersControlEvent) => { + if (suppressRef.current) return; + setBaseLayer(yjs.routeData, e.name); + }; + + map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn); + map.on("overlayremove", handleRemove as L.LeafletEventHandlerFn); + map.on("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn); + return () => { + map.off("overlayadd", handleAdd as L.LeafletEventHandlerFn); + map.off("overlayremove", handleRemove as L.LeafletEventHandlerFn); + map.off("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn); + }; + }, [map, yjs, onOverlayChange]); + + useEffect(() => { + const handleChange = () => { + const overlays = getOverlays(yjs.routeData); + if (overlays) onOverlayChange(overlays); + const base = getBaseLayer(yjs.routeData); + if (base) onBaseLayerChange(base); + }; + yjs.routeData.observe(handleChange); + handleChange(); + return () => yjs.routeData.unobserve(handleChange); + }, [yjs, onOverlayChange, onBaseLayerChange]); + + return null; +} + +// Triggers POI refreshes on map move and category changes. +export function PoiRefresher({ poiState }: { poiState: ReturnType }) { + const map = useMap(); + const refreshRef = useRef(poiState.refresh); + refreshRef.current = poiState.refresh; + + useEffect(() => { + const refresh = () => { + const bounds = map.getBounds(); + const zoom = map.getZoom(); + refreshRef.current( + { south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() }, + zoom, + ); + }; + map.on("moveend", refresh); + return () => { map.off("moveend", refresh); }; + }, [map]); + + const prevCategories = useRef(poiState.enabledCategories); + useEffect(() => { + if (prevCategories.current === poiState.enabledCategories) return; + prevCategories.current = poiState.enabledCategories; + const bounds = map.getBounds(); + const zoom = map.getZoom(); + poiState.refresh( + { south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() }, + zoom, + ); + }, [map, poiState.enabledCategories, poiState.refresh]); + + return null; +} diff --git a/apps/planner/app/components/NearbyPoiMarkers.tsx b/apps/planner/app/components/NearbyPoiMarkers.tsx new file mode 100644 index 0000000..0dc8167 --- /dev/null +++ b/apps/planner/app/components/NearbyPoiMarkers.tsx @@ -0,0 +1,49 @@ +import L from "leaflet"; +import { Marker, Tooltip } from "react-leaflet"; +import type { Poi } from "~/lib/pois"; +import type { PoiCategory } from "@trails-cool/map-core"; + +interface NearbyPoiMarkersProps { + pois: Poi[]; + categories: PoiCategory[]; + onSnap: (poi: Poi) => void; +} + +function nearbyPoiIcon(color: string, icon: string): L.DivIcon { + return L.divIcon({ + className: "", + html: `
    ${icon}
    `, + iconSize: [0, 0], + }); +} + +export function NearbyPoiMarkers({ pois, categories, onSnap }: NearbyPoiMarkersProps) { + return ( + <> + {pois.map((poi) => { + const cat = categories.find((c) => c.id === poi.category); + if (!cat) return null; + return ( + onSnap(poi) }} + zIndexOffset={900} + > + + {poi.name ?? cat.icon} {cat.icon !== poi.name ? cat.icon : ""} + + + ); + })} + + ); +} diff --git a/apps/planner/app/components/NoGoAreaLayer.tsx b/apps/planner/app/components/NoGoAreaLayer.tsx index b6508ee..ea66aa8 100644 --- a/apps/planner/app/components/NoGoAreaLayer.tsx +++ b/apps/planner/app/components/NoGoAreaLayer.tsx @@ -12,10 +12,11 @@ interface NoGoAreaLayerProps { onToggle: () => void; } +// --color-danger (the no-go hue at full strength) const NO_GO_STYLE: L.PathOptions = { - color: "#ef4444", - fillColor: "#ef4444", - fillOpacity: 0.2, + color: "#a03c3c", + fillColor: "#a03c3c", + fillOpacity: 0.15, weight: 2, }; @@ -45,7 +46,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay const polygon = L.polygon(latLngs, NO_GO_STYLE); polygon.on("contextmenu", (e) => { - L.DomEvent.preventDefault(e as unknown as Event); + L.DomEvent.preventDefault(e.originalEvent); suppressObserverRef.current = true; doc.transact(() => noGoAreas.delete(i, 1), "local"); suppressObserverRef.current = false; diff --git a/apps/planner/app/components/NotesPanel.tsx b/apps/planner/app/components/NotesPanel.tsx index 179d9ac..9954733 100644 --- a/apps/planner/app/components/NotesPanel.tsx +++ b/apps/planner/app/components/NotesPanel.tsx @@ -94,8 +94,8 @@ export function NotesPanel({ yjs }: NotesPanelProps) { return (
    -
    -

    +
    +

    {t("sidebar.notes")}

    diff --git a/apps/planner/app/components/ParticipantAvatars.tsx b/apps/planner/app/components/ParticipantAvatars.tsx new file mode 100644 index 0000000..d821d05 --- /dev/null +++ b/apps/planner/app/components/ParticipantAvatars.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Avatar, Badge } from "@trails-cool/ui"; +import type { Participant } from "~/lib/use-participants"; + +interface ParticipantAvatarsProps { + participants: Participant[]; + /** Called when the local participant commits a new display name. */ + onRenameLocal?: (name: string) => void; +} + +/** + * Presentational participant strip: a colored Avatar per participant, the + * local user's name inline-editable, and a Host badge. Driven entirely by the + * plain `participants` array so it renders in the /dev/ui gallery without a + * live session. + */ +export function ParticipantAvatars({ + participants, + onRenameLocal, +}: ParticipantAvatarsProps) { + const { t } = useTranslation("planner"); + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editing]); + + const startEditing = (name: string) => { + setValue(name); + setEditing(true); + }; + + const commit = () => { + onRenameLocal?.(value); + setEditing(false); + }; + + return ( +
    + {participants.map((p) => ( +
    + + {p.isLocal && editing ? ( + setValue(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + else if (e.key === "Escape") setEditing(false); + }} + maxLength={20} + className="w-24 rounded border border-border bg-bg-raised px-1.5 py-0.5 text-base text-text-hi focus:border-accent focus:outline-none sm:text-xs" + /> + ) : ( + + )} + {p.isHost && {t("participants.host")}} +
    + ))} +
    + ); +} diff --git a/apps/planner/app/components/ParticipantList.tsx b/apps/planner/app/components/ParticipantList.tsx deleted file mode 100644 index 154c3d9..0000000 --- a/apps/planner/app/components/ParticipantList.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useEffect, useState, useRef, useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import type { YjsState } from "~/lib/use-yjs"; -import { electHost } from "~/lib/host-election"; - -interface Participant { - clientId: number; - name: string; - color: string; - isHost: boolean; - isLocal: boolean; -} - -interface ParticipantListProps { - yjs: YjsState; -} - -export function ParticipantList({ yjs }: ParticipantListProps) { - const { t } = useTranslation("planner"); - const [participants, setParticipants] = useState([]); - const [editing, setEditing] = useState(false); - const [editValue, setEditValue] = useState(""); - const inputRef = useRef(null); - - const updateParticipants = useCallback(() => { - const states = yjs.awareness.getStates() as Map>; - const localId = yjs.awareness.clientID; - - const list: Participant[] = []; - states.forEach((state, clientId) => { - const user = state.user as { color: string; name: string } | undefined; - if (!user) return; - - const { isHost } = electHost(states, clientId); - - list.push({ - clientId, - name: user.name, - color: user.color, - isHost, - isLocal: clientId === localId, - }); - }); - - // Sort: local user first, then host, then alphabetical - list.sort((a, b) => { - if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1; - if (a.isHost !== b.isHost) return a.isHost ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - setParticipants(list); - }, [yjs.awareness]); - - useEffect(() => { - yjs.awareness.on("change", updateParticipants); - updateParticipants(); - - return () => { - yjs.awareness.off("change", updateParticipants); - }; - }, [yjs.awareness, updateParticipants]); - - const startEditing = () => { - const localParticipant = participants.find((p) => p.isLocal); - if (localParticipant) { - setEditValue(localParticipant.name); - setEditing(true); - } - }; - - useEffect(() => { - if (editing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [editing]); - - const commitEdit = () => { - const trimmed = editValue.trim(); - if (trimmed && trimmed.length <= 20) { - yjs.setUserName(trimmed); - } - setEditing(false); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - commitEdit(); - } else if (e.key === "Escape") { - setEditing(false); - } - }; - - return ( -
    - {participants.map((p) => ( -
    - - {p.isLocal && editing ? ( - setEditValue(e.target.value)} - onBlur={commitEdit} - onKeyDown={handleKeyDown} - maxLength={20} - className="w-20 rounded border border-gray-300 px-1 py-0 text-base sm:text-xs text-gray-700 focus:border-blue-500 focus:outline-none" - /> - ) : ( - - )} - {p.isHost && ( - - {t("participants.host")} - - )} -
    - ))} -
    - ); -} diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index bb64215..bef0afe 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -1,56 +1,56 @@ -import { useEffect, useState, useCallback, useRef } from "react"; -import { MapContainer, TileLayer, LayersControl, Marker, Polyline, useMapEvents, useMap } from "react-leaflet"; +import { useState, useCallback, useRef, memo, type RefObject } from "react"; +import { MapContainer, TileLayer, LayersControl, Marker, Polyline, Tooltip } from "react-leaflet"; import L from "leaflet"; -import * as Y from "yjs"; import { useTranslation } from "react-i18next"; import type { DayStage } from "@trails-cool/gpx"; import type { YjsState } from "~/lib/use-yjs"; -import { baseLayers, overlayLayers } from "@trails-cool/map"; -import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; -import { isOvernight } from "~/lib/overnight"; +import { baseLayers, overlayLayers } from "@trails-cool/map-core"; import { setOvernight } from "~/lib/overnight"; import { usePois } from "~/lib/use-pois"; import { useProfileDefaults } from "~/lib/use-profile-defaults"; import { useYjsPoiSync } from "~/lib/use-yjs-poi-sync"; -import { snapToPoi } from "~/lib/poi-snap"; -import { Z_CURSOR, Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core"; +import { Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core"; import { NoGoAreaLayer } from "./NoGoAreaLayer"; -import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute"; +import { ColoredRoute } from "./ColoredRoute"; import { RouteInteraction } from "./RouteInteraction"; import { PoiPanel, PoiMarkers } from "./PoiPanel"; import { WaypointContextMenu } from "./WaypointContextMenu"; +import { + MapExposer, + RouteFitter, + MapClickHandler, + CursorTracker, + NoGoAreaButton, + OverlaySync, + PoiRefresher, +} from "./MapHelpers"; +import { useWaypointManager } from "~/lib/use-waypoint-manager"; +import { useGpxDrop } from "~/lib/use-gpx-drop"; +import { useNearbyPois } from "~/lib/use-nearby-pois"; +import { NearbyPoiMarkers } from "./NearbyPoiMarkers"; +import { poiCategories } from "@trails-cool/map-core"; +import type { Poi } from "~/lib/pois"; import "leaflet/dist/leaflet.css"; -/** Distance from a point to a line segment in degrees (approximate) */ -function pointToSegmentDist( - pLat: number, pLon: number, - aLat: number, aLon: number, - bLat: number, bLon: number, -): number { - const dx = bLon - aLon; - const dy = bLat - aLat; - const lenSq = dx * dx + dy * dy; - if (lenSq === 0) return Math.sqrt((pLon - aLon) ** 2 + (pLat - aLat) ** 2); - const t = Math.max(0, Math.min(1, ((pLon - aLon) * dx + (pLat - aLat) * dy) / lenSq)); - const projLon = aLon + t * dx; - const projLat = aLat + t * dy; - return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2); -} - -function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon { - const bg = overnight ? "#8B6D3A" : "#2563eb"; +function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean, hasNote?: boolean): L.DivIcon { + // token colors: --color-accent / --color-stop (overnight). Note dot: --color-eg-mid. + const bg = overnight ? "#8b6d3a" : "#4a6b40"; const scale = highlighted ? "scale(1.17)" : "scale(1)"; + const noteIndicator = hasNote + ? `` + : ""; return L.divIcon({ className: "", - html: `
    ${overnight ? "☾" : index + 1}
    `, + html: `
    +
    ${overnight ? "☾" : index + 1}
    + ${noteIndicator} +
    `, iconSize: [0, 0], }); } @@ -60,7 +60,7 @@ function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon { className: "", html: `
    >): WaypointData[] { - return waypoints.toArray().map((yMap) => ({ - lat: yMap.get("lat") as number, - lon: yMap.get("lon") as number, - name: yMap.get("name") as string | undefined, - overnight: isOvernight(yMap), - })); -} +/** + * A single waypoint marker. Memoized so unrelated PlannerMap re-renders (e.g. + * route-hover chart sync) don't re-apply `position`/`icon` mid-drag — which + * otherwise yanks the pin back to its saved spot and the drag never commits. + */ +const WaypointMarker = memo(function WaypointMarker({ + index, lat, lon, overnight, highlighted, note, + onMove, onContextMenu, suspendRef, draggingRef, undoManager, +}: WaypointMarkerProps) { + return ( + { e.originalEvent.stopPropagation(); }, + mouseover: () => { suspendRef.current = true; }, + mouseout: () => { if (!draggingRef.current) suspendRef.current = false; }, + dragstart: () => { + draggingRef.current = true; + undoManager.stopCapturing(); + suspendRef.current = true; + }, + dragend: (e) => { + draggingRef.current = false; + suspendRef.current = false; + const { lat: dLat, lng } = (e.target as L.Marker).getLatLng(); + onMove(index, dLat, lng); + }, + contextmenu: (e) => { + L.DomEvent.preventDefault(e.originalEvent); + const orig = e.originalEvent as MouseEvent; + onContextMenu(index, orig.clientX, orig.clientY); + }, + }} + > + {note && ( + + + {note.length > 120 ? note.slice(0, 120) + "…" : note} + + + )} + + ); +}); interface PlannerMapProps { yjs: YjsState; sessionId: string; - onRouteRequest?: (waypoints: WaypointData[]) => void; + onRouteRequest?: (waypoints: { lat: number; lon: number; name?: string; overnight: boolean }[]) => void; onImportError?: (message: string) => void; highlightPosition?: [number, number] | null; highlightedWaypoint?: number | null; + selectedWaypointIndex?: number | null; onRouteHover?: (distance: number | null) => void; days?: DayStage[]; } -function MapExposer() { - const map = useMap(); - useEffect(() => { - if (typeof window !== "undefined") { - (window as unknown as Record).__leafletMap = map; - } - }, [map]); - return null; -} - -function RouteFitter({ coordinates }: { coordinates: [number, number, number][] | null }) { - const map = useMap(); - const hasFitted = useRef(false); - - useEffect(() => { - if (hasFitted.current || !coordinates || coordinates.length < 2) return; - - // Coordinates are in [lon, lat, elevation] GeoJSON format - const bounds = L.latLngBounds( - coordinates.map((c) => [c[1]!, c[0]!] as [number, number]), - ); - - if (!bounds.isValid()) return; - - // Delay fitBounds so the layout has settled (elevation chart may resize the map) - const raf = requestAnimationFrame(() => { - map.invalidateSize(); - map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 }); - hasFitted.current = true; - }); - return () => cancelAnimationFrame(raf); - }, [coordinates, map]); - - return null; -} - -function MapClickHandler({ onAdd, suppressRef }: { onAdd: (lat: number, lng: number) => void; suppressRef: React.RefObject }) { - useMapEvents({ - click(e) { - if (suppressRef.current) { - suppressRef.current = false; - return; - } - onAdd(e.latlng.lat, e.latlng.lng); - }, - }); - return null; -} - -function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) { - const map = useMap(); - const [cursors, setCursors] = useState>(new Map()); - - useEffect(() => { - const localId = awareness.clientID; - - const handleMouseMove = (e: L.LeafletMouseEvent) => { - awareness.setLocalStateField("mapCursor", { - lat: e.latlng.lat, - lng: e.latlng.lng, - }); - }; - - const handleMouseOut = () => { - awareness.setLocalStateField("mapCursor", null); - }; - - map.on("mousemove", handleMouseMove); - map.on("mouseout", handleMouseOut); - - const updateCursors = () => { - const states = awareness.getStates(); - const newCursors = new Map(); - states.forEach((state, clientId) => { - if (clientId !== localId && state.mapCursor && state.user) { - newCursors.set(clientId, { - lat: state.mapCursor.lat, - lng: state.mapCursor.lng, - color: state.user.color, - name: state.user.name, - }); - } - }); - setCursors(newCursors); - }; - - awareness.on("change", updateCursors); - - return () => { - map.off("mousemove", handleMouseMove); - map.off("mouseout", handleMouseOut); - awareness.off("change", updateCursors); - }; - }, [map, awareness]); - - return ( - <> - {Array.from(cursors.entries()).map(([clientId, cursor]) => ( - - - - - ${cursor.name} -
    `, - iconSize: [0, 0], - })} - /> - ))} - - ); -} - -function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) { - const ref = useRef(null); - - // Prevent clicks from reaching the Leaflet map (same as built-in controls) - useEffect(() => { - if (ref.current) L.DomEvent.disableClickPropagation(ref.current); - }, []); - - return ( - - ); -} - -function OverlaySync({ yjs, onOverlayChange, onBaseLayerChange }: { yjs: YjsState; onOverlayChange: (ids: string[]) => void; onBaseLayerChange: (name: string) => void }) { - const map = useMap(); - const suppressRef = useRef(false); - - // Map events → Yjs - useEffect(() => { - const handleAdd = (e: L.LayersControlEvent) => { - if (suppressRef.current) return; - const name = e.name; - const layer = overlayLayers.find((l) => l.name === name); - if (!layer) return; - const raw = yjs.routeData.get("overlays") as string | undefined; - const current: string[] = raw ? JSON.parse(raw) : []; - if (!current.includes(layer.id)) { - const updated = [...current, layer.id]; - yjs.routeData.set("overlays", JSON.stringify(updated)); - onOverlayChange(updated); - } - }; - - const handleRemove = (e: L.LayersControlEvent) => { - if (suppressRef.current) return; - const name = e.name; - const layer = overlayLayers.find((l) => l.name === name); - if (!layer) return; - const raw = yjs.routeData.get("overlays") as string | undefined; - const current: string[] = raw ? JSON.parse(raw) : []; - const updated = current.filter((id) => id !== layer.id); - yjs.routeData.set("overlays", JSON.stringify(updated)); - onOverlayChange(updated); - }; - - // Base layer change → Yjs - const handleBaseChange = (e: L.LayersControlEvent) => { - if (suppressRef.current) return; - yjs.routeData.set("baseLayer", e.name); - }; - - map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn); - map.on("overlayremove", handleRemove as L.LeafletEventHandlerFn); - map.on("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn); - return () => { - map.off("overlayadd", handleAdd as L.LeafletEventHandlerFn); - map.off("overlayremove", handleRemove as L.LeafletEventHandlerFn); - map.off("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn); - }; - }, [map, yjs, onOverlayChange]); - - // Yjs → Map: load initial state from Yjs - useEffect(() => { - const handleChange = () => { - const raw = yjs.routeData.get("overlays") as string | undefined; - if (raw) { - try { - onOverlayChange(JSON.parse(raw)); - } catch { /* ignore */ } - } - const base = yjs.routeData.get("baseLayer") as string | undefined; - if (base) onBaseLayerChange(base); - }; - yjs.routeData.observe(handleChange); - handleChange(); - return () => yjs.routeData.unobserve(handleChange); - }, [yjs, onOverlayChange, onBaseLayerChange]); - - return null; -} - -function PoiRefresher({ poiState }: { poiState: ReturnType }) { - const map = useMap(); - const refreshRef = useRef(poiState.refresh); - refreshRef.current = poiState.refresh; - - useEffect(() => { - const refresh = () => { - const bounds = map.getBounds(); - const zoom = map.getZoom(); - refreshRef.current({ - south: bounds.getSouth(), - west: bounds.getWest(), - north: bounds.getNorth(), - east: bounds.getEast(), - }, zoom); - }; - map.on("moveend", refresh); - // Don't call refresh() immediately — let moveend trigger it - return () => { map.off("moveend", refresh); }; - }, [map]); - - // Trigger refresh when categories change (but not on mount) - const prevCategories = useRef(poiState.enabledCategories); - useEffect(() => { - if (prevCategories.current === poiState.enabledCategories) return; - prevCategories.current = poiState.enabledCategories; - const bounds = map.getBounds(); - const zoom = map.getZoom(); - poiState.refresh({ - south: bounds.getSouth(), - west: bounds.getWest(), - north: bounds.getNorth(), - east: bounds.getEast(), - }, zoom); - }, [map, poiState.enabledCategories, poiState.refresh]); - - return null; -} - -export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, onRouteHover, onImportError, days }: PlannerMapProps) { +export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, selectedWaypointIndex, onRouteHover, onImportError, days }: PlannerMapProps) { const { t } = useTranslation("planner"); - const [waypoints, setWaypoints] = useState([]); const poiState = usePois(sessionId); useProfileDefaults(yjs, poiState); useYjsPoiSync(yjs, poiState); const [enabledOverlays, setEnabledOverlays] = useState([]); const [selectedBaseLayer, setSelectedBaseLayer] = useState(baseLayers[0]!.name); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; index: number } | null>(null); - const [draggingOver, setDraggingOver] = useState(false); - const dragCounterRef = useRef(0); - const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null); - const [segmentBoundaries, setSegmentBoundaries] = useState([]); - const [surfaces, setSurfaces] = useState([]); - const [highways, setHighways] = useState([]); - const [maxspeeds, setMaxspeeds] = useState([]); - const [smoothnesses, setSmoothnesses] = useState([]); - const [tracktypes, setTracktypes] = useState([]); - const [cycleways, setCycleways] = useState([]); - const [bikeroutes, setBikeroutes] = useState([]); - const [colorMode, setColorMode] = useState("plain"); const [noGoDrawing, setNoGoDrawing] = useState(false); const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []); const suppressMapClickRef = useRef(false); + const handleContextMenu = useCallback((index: number, x: number, y: number) => { + setContextMenu({ x, y, index }); + }, []); const routeInteractionSuspendedRef = useRef(false); const waypointDraggingRef = useRef(false); - // Sync waypoints from Yjs - useEffect(() => { - const update = () => { - const wps = getWaypointsFromYjs(yjs.waypoints); - setWaypoints(wps); - if (wps.length >= 2 && onRouteRequest) { - onRouteRequest(wps); - } - }; + const { + waypoints, + routeCoordinates, + segmentBoundaries, + surfaces, + highways, + maxspeeds, + smoothnesses, + tracktypes, + cycleways, + bikeroutes, + colorMode, + addWaypoint, + handleRouteInsert, + moveWaypoint, + deleteWaypoint, + handleRoutePolylineHover, + } = useWaypointManager(yjs, poiState, suppressMapClickRef, onRouteRequest); - yjs.waypoints.observeDeep(update); - update(); + const { draggingOver, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } = useGpxDrop(yjs, onImportError); - return () => { - yjs.waypoints.unobserveDeep(update); - }; - }, [yjs.waypoints, onRouteRequest]); + const selectedWp = selectedWaypointIndex != null ? waypoints[selectedWaypointIndex] : undefined; + const nearbyPoisState = useNearbyPois(selectedWp?.lat, selectedWp?.lon, sessionId); - // Sync route data from Yjs (enriched: coordinates + segment boundaries) - useEffect(() => { - const update = () => { - const coordsJson = yjs.routeData.get("coordinates") as string | undefined; - const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined; - const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined; - - if (coordsJson) { - try { - setRouteCoordinates(JSON.parse(coordsJson)); - } catch { - setRouteCoordinates(null); - } - } else { - // Fallback: parse from geojson for backwards compat - const geojson = yjs.routeData.get("geojson") as string | undefined; - if (geojson) { - try { - const parsed = JSON.parse(geojson); - const coords = parsed.features?.[0]?.geometry?.coordinates; - if (coords) { - setRouteCoordinates(coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number])); - } - } catch { setRouteCoordinates(null); } - } else { - setRouteCoordinates(null); - } - } - - if (boundsJson) { - try { setSegmentBoundaries(JSON.parse(boundsJson)); } catch { setSegmentBoundaries([]); } - } else { - setSegmentBoundaries([]); - } - - const surfacesJson = yjs.routeData.get("surfaces") as string | undefined; - if (surfacesJson) { - try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); } - } else { - setSurfaces([]); - } - - const highwaysJson = yjs.routeData.get("highways") as string | undefined; - if (highwaysJson) { - try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); } - } else { - setHighways([]); - } - - const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined; - if (maxspeedsJson) { - try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); } - } else { - setMaxspeeds([]); - } - - const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined; - if (smoothnessesJson) { - try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); } - } else { - setSmoothnesses([]); - } - - const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined; - if (tracktypesJson) { - try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); } - } else { - setTracktypes([]); - } - - const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined; - if (cyclewaysJson) { - try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); } - } else { - setCycleways([]); - } - - const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined; - if (bikeroutesJson) { - try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); } - } else { - setBikeroutes([]); - } - - if (modeVal) setColorMode(modeVal); - }; - - yjs.routeData.observe(update); - update(); - - return () => { - yjs.routeData.unobserve(update); - }; - }, [yjs.routeData]); - - const addWaypoint = useCallback( - (lat: number, lng: number, name?: string) => { - const snap = snapToPoi(lat, lng, poiState.pois); - const finalLat = snap.lat; - const finalLon = snap.snapped ? snap.lon : lng; - - // Find the best insertion index: if the point is near the route, - // insert between the closest segment's waypoints instead of appending - let insertIndex = yjs.waypoints.length; // default: append - if (routeCoordinates && routeCoordinates.length >= 2 && segmentBoundaries.length > 0) { - let bestDist = Infinity; - let bestSegment = -1; - // For each segment, find the closest point on the route - for (let seg = 0; seg < segmentBoundaries.length; seg++) { - const start = segmentBoundaries[seg]!; - const end = seg + 1 < segmentBoundaries.length ? segmentBoundaries[seg + 1]! : routeCoordinates.length; - for (let i = start; i < end - 1; i++) { - const c1 = routeCoordinates[i]!; - const c2 = routeCoordinates[i + 1]!; - const dist = pointToSegmentDist(finalLat, finalLon, c1[1]!, c1[0]!, c2[1]!, c2[0]!); - if (dist < bestDist) { - bestDist = dist; - bestSegment = seg; - } - } - } - // If within ~1km of the route, insert after the segment's waypoint - if (bestDist < 0.01 && bestSegment >= 0) { - insertIndex = bestSegment + 1; - } - } - - yjs.doc.transact(() => { - const yMap = new Y.Map(); - yMap.set("lat", finalLat); - yMap.set("lon", finalLon); - if (snap.name) yMap.set("name", snap.name); - else if (name) yMap.set("name", name); - if (snap.osmId) yMap.set("osmId", snap.osmId); - if (snap.poiTags) yMap.set("poiTags", snap.poiTags); - yjs.waypoints.insert(insertIndex, [yMap]); - }, "local"); - }, - [yjs.doc, yjs.waypoints, poiState.pois, routeCoordinates, segmentBoundaries], - ); - - const insertWaypointAtSegment = useCallback( - (segmentIndex: number, lat: number, lon: number) => { - const snap = snapToPoi(lat, lon, poiState.pois); - yjs.doc.transact(() => { - const yMap = new Y.Map(); - yMap.set("lat", snap.lat); - yMap.set("lon", snap.snapped ? snap.lon : lon); - if (snap.name) yMap.set("name", snap.name); - if (snap.osmId) yMap.set("osmId", snap.osmId); - if (snap.poiTags) yMap.set("poiTags", snap.poiTags); - yjs.waypoints.insert(segmentIndex + 1, [yMap]); - }, "local"); - }, - [yjs.doc, yjs.waypoints, poiState.pois], - ); - - const handleRouteInsert = useCallback( - (pointIndex: number, lat: number, lon: number) => { - suppressMapClickRef.current = true; - const segIdx = findSegmentForPoint(pointIndex, segmentBoundaries); - insertWaypointAtSegment(segIdx, lat, lon); - }, - [segmentBoundaries, insertWaypointAtSegment], - ); - - const moveWaypoint = useCallback( - (index: number, lat: number, lng: number) => { - const snap = snapToPoi(lat, lng, poiState.pois); - const yMap = yjs.waypoints.get(index); - if (yMap) { - yjs.doc.transact(() => { - yMap.set("lat", snap.lat); - yMap.set("lon", snap.snapped ? snap.lon : lng); - if (snap.snapped && snap.name) { - yMap.set("name", snap.name); - } else { - yMap.delete("name"); - } - if (snap.osmId) { - yMap.set("osmId", snap.osmId); - if (snap.poiTags) yMap.set("poiTags", snap.poiTags); - } else { - yMap.delete("osmId"); - yMap.delete("poiTags"); - } - }, "local"); - } - }, - [yjs.waypoints, yjs.doc, poiState.pois], - ); - - const deleteWaypoint = useCallback( - (index: number) => { - yjs.doc.transact(() => { - yjs.waypoints.delete(index, 1); - }, "local"); - }, - [yjs.waypoints], - ); - - const handleRoutePolylineHover = useCallback( - (e: L.LeafletMouseEvent) => { - if (!routeCoordinates || routeCoordinates.length < 2 || !onRouteHover) return; - const { lat, lng } = e.latlng; - // Find the closest coordinate index - let bestIdx = 0; - let bestDist = Infinity; - for (let i = 0; i < routeCoordinates.length; i++) { - const c = routeCoordinates[i]!; - const dLat = c[1]! - lat; - const dLon = c[0]! - lng; - const dist = dLat * dLat + dLon * dLon; - if (dist < bestDist) { - bestDist = dist; - bestIdx = i; - } - } - // Compute cumulative distance to this point using haversine - let totalDist = 0; - for (let i = 1; i <= bestIdx; i++) { - const prev = routeCoordinates[i - 1]!; - const curr = routeCoordinates[i]!; - const R = 6371000; - const toRad = (d: number) => (d * Math.PI) / 180; - const dLat = toRad(curr[1]! - prev[1]!); - const dLon = toRad(curr[0]! - prev[0]!); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(prev[1]!)) * Math.cos(toRad(curr[1]!)) * Math.sin(dLon / 2) ** 2; - totalDist += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - } - onRouteHover(totalDist); - }, - [routeCoordinates, onRouteHover], - ); + const handleSnapToPoi = useCallback((poi: Poi) => { + if (selectedWaypointIndex == null) return; + const yMap = yjs.waypoints.get(selectedWaypointIndex); + if (!yMap) return; + const cat = poiCategories.find((c) => c.id === poi.category); + const notePrefix = cat ? `${cat.icon} ${poi.name ?? cat.id}` : (poi.name ?? ""); + yjs.doc.transact(() => { + yMap.set("lat", poi.lat); + yMap.set("lon", poi.lon); + if (poi.name && !yMap.get("name")) yMap.set("name", poi.name); + const existing = yMap.get("note") as string | undefined; + yMap.set("note", existing ? `${notePrefix}\n${existing}` : notePrefix); + if (poi.id) yMap.set("osmId", poi.id); + }, "local"); + }, [selectedWaypointIndex, yjs, poiCategories]); const handleRoutePolylineOut = useCallback(() => { onRouteHover?.(null); }, [onRouteHover]); - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - dragCounterRef.current++; - if (dragCounterRef.current === 1) setDraggingOver(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - dragCounterRef.current--; - if (dragCounterRef.current === 0) setDraggingOver(false); - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - }, []); - - const handleDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - dragCounterRef.current = 0; - setDraggingOver(false); - - const file = e.dataTransfer.files[0]; - if (!file) return; - if (!file.name.toLowerCase().endsWith(".gpx")) { - onImportError?.(t("importGpxError")); - return; - } - - try { - const text = await file.text(); - const gpxData = await parseGpxAsync(text); - const newWaypoints = extractWaypoints(gpxData); - if (newWaypoints.length < 2) return; - - if (!window.confirm(t("replaceRouteConfirm"))) return; - - yjs.doc.transact(() => { - // Replace waypoints - yjs.waypoints.delete(0, yjs.waypoints.length); - for (const wp of newWaypoints) { - const yMap = new Y.Map(); - yMap.set("lat", wp.lat); - yMap.set("lon", wp.lon); - if (wp.name) yMap.set("name", wp.name); - if (wp.isDayBreak) yMap.set("overnight", true); - yjs.waypoints.push([yMap]); - } - - // Replace no-go areas - yjs.noGoAreas.delete(0, yjs.noGoAreas.length); - for (const area of gpxData.noGoAreas) { - const yMap = new Y.Map(); - yMap.set("points", area.points); - yjs.noGoAreas.push([yMap]); - } - - // Replace notes if GPX has a description - if (gpxData.description) { - yjs.notes.delete(0, yjs.notes.length); - yjs.notes.insert(0, gpxData.description); - } - }, "local"); - } catch { - onImportError?.(t("importGpxError")); - } - }, [yjs, t, onImportError]); - return (
    {draggingOver && ( -
    -
    +
    +
    {t("dropGpxHere")}
    )} - - - {baseLayers.map((layer) => ( - - - + + + {baseLayers.map((layer) => ( + + + + ))} + {overlayLayers.map((layer) => ( + + + + ))} + + + + + + {} : addWaypoint} suppressRef={suppressMapClickRef} /> + + + + + + + {selectedWaypointIndex != null && nearbyPoisState.pois.length > 0 && ( + + )} + + {waypoints.map((wp, i) => ( + ))} - {overlayLayers.map((layer) => ( - - - - ))} - - - - - {} : addWaypoint} suppressRef={suppressMapClickRef} /> - - - - - - - - {waypoints.map((wp, i) => ( - { - routeInteractionSuspendedRef.current = true; - }, - mouseout: () => { - if (!waypointDraggingRef.current) { - routeInteractionSuspendedRef.current = false; - } - }, - dragstart: () => { - waypointDraggingRef.current = true; - yjs.undoManager.stopCapturing(); - routeInteractionSuspendedRef.current = true; - }, - dragend: (e) => { - waypointDraggingRef.current = false; - routeInteractionSuspendedRef.current = false; - const { lat, lng } = e.target.getLatLng(); - moveWaypoint(i, lat, lng); - }, - contextmenu: (e) => { - L.DomEvent.preventDefault(e as unknown as Event); - const orig = e.originalEvent as MouseEvent; - setContextMenu({ x: orig.clientX, y: orig.clientY, index: i }); - }, - }} - /> - ))} - - {/* Day boundary labels on map */} - {days && days.length > 1 && days.map((day) => { - const wp = waypoints[day.endWaypointIndex]; - if (!wp || day.dayNumber === days.length) return null; - return ( - - ); - })} - - {routeCoordinates && routeCoordinates.length >= 2 && ( - <> - - - {onRouteHover && ( - [c[1]!, c[0]!] as [number, number])} - pathOptions={{ weight: 20, opacity: 0, interactive: true }} - eventHandlers={{ - mouseover: handleRoutePolylineHover, - mousemove: handleRoutePolylineHover, - mouseout: handleRoutePolylineOut, - }} + {days && days.length > 1 && days.map((day) => { + const wp = waypoints[day.endWaypointIndex]; + if (!wp || day.dayNumber === days.length) return null; + return ( + - )} - - )} + ); + })} - {highlightPosition && ( -
    ', - iconSize: [0, 0], - })} - /> - )} - + {routeCoordinates && routeCoordinates.length >= 2 && ( + <> + + + {onRouteHover && ( + [c[1]!, c[0]!] as [number, number])} + pathOptions={{ weight: 20, opacity: 0, interactive: true }} + eventHandlers={{ + mouseover: (e) => handleRoutePolylineHover(e, onRouteHover), + mousemove: (e) => handleRoutePolylineHover(e, onRouteHover), + mouseout: handleRoutePolylineOut, + }} + /> + )} + + )} + + {highlightPosition && ( +
    ', + iconSize: [0, 0], + })} + /> + )} + {contextMenu && ( { if (!mounted) return; - // After import, L.markerClusterGroup is available - const cluster = (L as unknown as { markerClusterGroup: (opts?: object) => L.LayerGroup }).markerClusterGroup({ + // After import, L.markerClusterGroup is available (typed via + // module augmentation in app/types/global.d.ts). + const cluster = L.markerClusterGroup({ maxClusterRadius: 40, disableClusteringAtZoom: 15, showCoverageOnHover: false, diff --git a/apps/planner/app/components/ProfileSelector.tsx b/apps/planner/app/components/ProfileSelector.tsx index 1c6c5b2..0131486 100644 --- a/apps/planner/app/components/ProfileSelector.tsx +++ b/apps/planner/app/components/ProfileSelector.tsx @@ -1,6 +1,8 @@ import { useEffect, useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { Select } from "@trails-cool/ui"; import type { YjsState } from "~/lib/use-yjs"; +import { DEFAULT_PROFILE, getProfile, setProfile as setYjsProfile } from "~/lib/route-data"; const PROFILE_IDS = ["fastbike", "safety", "shortest", "car", "trekking"] as const; @@ -10,11 +12,11 @@ interface ProfileSelectorProps { export function ProfileSelector({ yjs }: ProfileSelectorProps) { const { t } = useTranslation("planner"); - const [profile, setProfile] = useState("fastbike"); + const [profile, setProfile] = useState(DEFAULT_PROFILE); useEffect(() => { const update = () => { - const p = yjs.routeData.get("profile") as string | undefined; + const p = getProfile(yjs.routeData); if (p) setProfile(p); }; yjs.routeData.observe(update); @@ -26,28 +28,28 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) { (e: React.ChangeEvent) => { const value = e.target.value; setProfile(value); - yjs.routeData.set("profile", value); + setYjsProfile(yjs.routeData, value); }, [yjs.routeData], ); return (
    -
    ); } diff --git a/apps/planner/app/components/RouteInteraction.tsx b/apps/planner/app/components/RouteInteraction.tsx index 36375f9..802d402 100644 --- a/apps/planner/app/components/RouteInteraction.tsx +++ b/apps/planner/app/components/RouteInteraction.tsx @@ -18,7 +18,7 @@ const ghostIcon = L.divIcon({ className: "route-ghost-marker", html: `
    { - L.DomEvent.stop(e as unknown as Event); + L.DomEvent.stop(e.originalEvent); }); map.on("mousemove", onMouseMove); diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index a2e178a..217d6e7 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -1,18 +1,18 @@ import { useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import * as Y from "yjs"; +import { Button } from "@trails-cool/ui"; +import { computeSurfaceBreakdown } from "@trails-cool/map-core"; import type { YjsState } from "~/lib/use-yjs"; -import { generateGpx } from "@trails-cool/gpx"; -import type { TrackPoint, NoGoArea } from "@trails-cool/gpx"; +import { buildPlanGpx } from "~/lib/gpx-export"; +import { readComputedRoute } from "~/lib/route-data"; interface SaveToJournalButtonProps { yjs: YjsState; - callbackUrl: string; - callbackToken: string; + sessionId: string; returnUrl?: string; } -export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl }: SaveToJournalButtonProps) { +export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournalButtonProps) { const { t } = useTranslation("planner"); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); @@ -23,42 +23,31 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl setError(null); try { - // Build GPX from computed track with planning data (no-go areas) - // so the route round-trips correctly through the journal. - let tracks: TrackPoint[][] = []; - const geojsonStr = yjs.routeData.get("geojson") as string | undefined; - if (geojsonStr) { - try { - const geojson = JSON.parse(geojsonStr); - const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? []; - if (coords.length > 0) { - tracks = [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))]; - } - } catch { /* invalid geojson */ } - } + // Full plan GPX (track + waypoints + no-go areas + notes) so the + // route round-trips correctly through the journal. + const gpx = buildPlanGpx(yjs); - const noGoAreas: NoGoArea[] = yjs.noGoAreas.toArray().map((yMap: Y.Map) => ({ - points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], - })).filter((a) => a.points.length >= 3); + // Distance-weighted surface/waytype breakdown from the BRouter waytags + // already in routeData — sent alongside the GPX so the journal can show + // it without re-deriving (route-surface-breakdown, Path 1). + const route = readComputedRoute(yjs.routeData); + const breakdown = + route.coordinates && route.coordinates.length > 1 + ? computeSurfaceBreakdown(route.coordinates, route.surfaces, route.highways) + : null; + const surfaceBreakdown = + breakdown && (Object.keys(breakdown.surface).length > 0 || Object.keys(breakdown.highway).length > 0) + ? breakdown + : undefined; - const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ - lat: yMap.get("lat") as number, - lon: yMap.get("lon") as number, - name: yMap.get("name") as string | undefined, - isDayBreak: yMap.get("overnight") === true ? true : undefined, - })); - - const notes = yjs.notes.toString() || undefined; - const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas }); - - // POST to Journal callback - const response = await fetch(callbackUrl, { + // POST to the planner's server-side proxy. The proxy attaches the + // journal Bearer token (stored on the session row) and forwards + // the GPX. Token never leaves the planner server — see + // routes/api.save-to-journal.ts. + const response = await fetch("/api/save-to-journal", { method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${callbackToken}`, - }, - body: JSON.stringify({ gpx }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, gpx, ...(surfaceBreakdown ? { surfaceBreakdown } : {}) }), }); if (!response.ok) { @@ -72,21 +61,20 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl } finally { setSaving(false); } - }, [yjs, callbackUrl, callbackToken]); + }, [yjs, sessionId]); return (
    - - {saved && {t("saved")}} - {error && {error}} + + {saved && {t("saved")}} + {error && {error}} {saved && returnUrl && ( - + {t("returnToJournal")} )} diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 98783ee..87f65ce 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -1,18 +1,19 @@ import { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; import L from "leaflet"; import type { TFunction } from "i18next"; import * as Sentry from "@sentry/react"; import { useYjs, type YjsState } from "~/lib/use-yjs"; import { useRouting, type RouteError } from "~/lib/use-routing"; +import { getCoordinates } from "~/lib/route-data"; import { useDays } from "~/lib/use-days"; import { useUndo, useUndoShortcuts } from "~/lib/use-undo"; import { ProfileSelector } from "~/components/ProfileSelector"; import { ExportButton } from "~/components/ExportButton"; import { SaveToJournalButton } from "~/components/SaveToJournalButton"; import { YjsDebugPanel } from "~/components/YjsDebugPanel"; -import { ParticipantList } from "~/components/ParticipantList"; +import { Topbar } from "~/components/Topbar"; +import { useParticipants } from "~/lib/use-participants"; import { NotesPanel } from "~/components/NotesPanel"; const PlannerMap = lazy(() => @@ -113,22 +114,22 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction, addToast: (messa } -function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState; routeStats: ReturnType["routeStats"]; days: ReturnType; onWaypointHover: (index: number | null) => void }) { +function SidebarTabs({ yjs, sessionId, routeStats, days, onWaypointHover, onWaypointSelect }: { yjs: YjsState; sessionId: string; routeStats: ReturnType["routeStats"]; days: ReturnType; onWaypointHover: (index: number | null) => void; onWaypointSelect: (index: number | null) => void }) { const { t } = useTranslation("planner"); const [tab, setTab] = useState<"waypoints" | "notes">("waypoints"); return ( -