diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md index c6cb9b6..bf23721 100644 --- a/.claude/commands/opsx/apply.md +++ b/.claude/commands/opsx/apply.md @@ -1,15 +1,12 @@ --- name: "OPSX: Apply" description: Implement tasks from an OpenSpec change (Experimental) -allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, artifacts, experimental] --- Implement tasks from an OpenSpec change. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - **Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -29,7 +26,6 @@ Implement tasks from an OpenSpec change. ``` Parse the JSON to understand: - `schemaName`: The workflow being used (e.g., "spec-driven") - - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) 3. **Get apply instructions** @@ -39,7 +35,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Context file paths (varies by schema) - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state @@ -51,7 +47,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read every file path listed under `contextFiles` from the apply instructions output. + Read the files listed in `contextFiles` from the apply instructions output. The files depend on the schema being used: - **spec-driven**: proposal, specs, design, tasks - Other schemas: follow the contextFiles from CLI output diff --git a/.claude/commands/opsx/archive.md b/.claude/commands/opsx/archive.md index df8a2f2..5e91608 100644 --- a/.claude/commands/opsx/archive.md +++ b/.claude/commands/opsx/archive.md @@ -1,15 +1,12 @@ --- name: "OPSX: Archive" description: Archive a completed change in the experimental workflow -allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, archive, experimental] --- Archive a completed change in the experimental workflow. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - **Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -29,7 +26,6 @@ Archive a completed change in the experimental workflow. Parse the JSON to understand: - `schemaName`: The workflow being used - - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - `artifacts`: List of artifacts with their status (`done` or other) **If any artifacts are not `done`:** @@ -52,7 +48,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. + Check for delta specs at `openspec/changes//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 +63,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: + Create the archive directory if it doesn't exist: ```bash - mkdir -p "/archive" + mkdir -p openspec/changes/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 `changeRoot` to the archive directory + - If no: Move the change directory to archive ```bash - mv "" "/archive/YYYY-MM-DD-" + mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- ``` 6. **Display summary** @@ -98,7 +94,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs All artifacts complete. All tasks complete. @@ -111,7 +107,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** No delta specs All artifacts complete. All tasks complete. @@ -124,7 +120,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** Sync skipped (user chose to skip) **Warnings:** @@ -141,7 +137,7 @@ Review the archive if this was not intentional. ## Archive Failed **Change:** -**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Target:** openspec/changes/archive/YYYY-MM-DD-/ Target archive directory already exists. diff --git a/.claude/commands/opsx/explore.md b/.claude/commands/opsx/explore.md index 7558a63..30d9c57 100644 --- a/.claude/commands/opsx/explore.md +++ b/.claude/commands/opsx/explore.md @@ -1,7 +1,6 @@ --- name: "OPSX: Explore" description: "Enter explore mode - think through ideas, investigate problems, clarify requirements" -allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, explore, experimental, thinking] --- @@ -12,8 +11,6 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - **Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be: - A vague idea: "real-time collaboration" - A specific problem: "the auth system is getting unwieldy" @@ -62,10 +59,10 @@ Depending on what the user brings, you might: │ Use ASCII diagrams liberally │ ├─────────────────────────────────────────┤ │ │ -│ ┌────────┐ ┌────────┐ │ -│ │ State │────────▶│ State │ │ -│ │ A │ │ B │ │ -│ └────────┘ └────────┘ │ +│ ┌────────┐ ┌────────┐ │ +│ │ State │────────▶│ State │ │ +│ │ A │ │ B │ │ +│ └────────┘ └────────┘ │ │ │ │ System diagrams, state machines, │ │ data flows, architecture sketches, │ @@ -110,10 +107,11 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Resolve and read existing artifacts for context** - - Run `openspec status --change "" --json`. - - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. - - Read existing files from `artifactPaths..existingOutputPaths`. +1. **Read existing artifacts for context** + - `openspec/changes//proposal.md` + - `openspec/changes//design.md` + - `openspec/changes//tasks.md` + - etc. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -121,14 +119,14 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |----------------------------|--------------------------------| - | New requirement discovered | `specs//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 8d99588..05276f4 100644 --- a/.claude/commands/opsx/propose.md +++ b/.claude/commands/opsx/propose.md @@ -1,7 +1,6 @@ --- name: "OPSX: Propose" description: Propose a new change - create it and generate all artifacts in one step -allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, artifacts, experimental] --- @@ -17,8 +16,6 @@ When ready to implement, run /opsx:apply --- -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - **Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build. **Steps** @@ -36,7 +33,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. + This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -45,7 +42,6 @@ When ready to implement, run /opsx:apply Parse the JSON to get: - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - `artifacts`: list of all artifacts with their status and dependencies - - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. 4. **Create artifacts in sequence until apply-ready** @@ -63,10 +59,10 @@ When ready to implement, run /opsx:apply - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type - - `resolvedOutputPath`: Resolved path or pattern to write the artifact + - `outputPath`: Where to write the artifact - `dependencies`: Completed artifacts to read for context - Read any completed dependency files for context - - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` + - Create the artifact file using `template` as the structure - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.claude/skills b/.claude/skills deleted file mode 120000 index 2b7a412..0000000 --- a/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../.agents/skills \ No newline at end of file diff --git a/.agents/skills/cmux-browser/SKILL.md b/.claude/skills/cmux-browser/SKILL.md similarity index 100% rename from .agents/skills/cmux-browser/SKILL.md rename to .claude/skills/cmux-browser/SKILL.md diff --git a/.agents/skills/cmux-browser/agents/openai.yaml b/.claude/skills/cmux-browser/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux-browser/agents/openai.yaml rename to .claude/skills/cmux-browser/agents/openai.yaml diff --git a/.agents/skills/cmux-browser/references/authentication.md b/.claude/skills/cmux-browser/references/authentication.md similarity index 100% rename from .agents/skills/cmux-browser/references/authentication.md rename to .claude/skills/cmux-browser/references/authentication.md diff --git a/.agents/skills/cmux-browser/references/commands.md b/.claude/skills/cmux-browser/references/commands.md similarity index 100% rename from .agents/skills/cmux-browser/references/commands.md rename to .claude/skills/cmux-browser/references/commands.md diff --git a/.agents/skills/cmux-browser/references/proxy-support.md b/.claude/skills/cmux-browser/references/proxy-support.md similarity index 100% rename from .agents/skills/cmux-browser/references/proxy-support.md rename to .claude/skills/cmux-browser/references/proxy-support.md diff --git a/.agents/skills/cmux-browser/references/session-management.md b/.claude/skills/cmux-browser/references/session-management.md similarity index 100% rename from .agents/skills/cmux-browser/references/session-management.md rename to .claude/skills/cmux-browser/references/session-management.md diff --git a/.agents/skills/cmux-browser/references/snapshot-refs.md b/.claude/skills/cmux-browser/references/snapshot-refs.md similarity index 100% rename from .agents/skills/cmux-browser/references/snapshot-refs.md rename to .claude/skills/cmux-browser/references/snapshot-refs.md diff --git a/.agents/skills/cmux-browser/references/video-recording.md b/.claude/skills/cmux-browser/references/video-recording.md similarity index 100% rename from .agents/skills/cmux-browser/references/video-recording.md rename to .claude/skills/cmux-browser/references/video-recording.md diff --git a/.agents/skills/cmux-browser/templates/authenticated-session.sh b/.claude/skills/cmux-browser/templates/authenticated-session.sh similarity index 100% rename from .agents/skills/cmux-browser/templates/authenticated-session.sh rename to .claude/skills/cmux-browser/templates/authenticated-session.sh diff --git a/.agents/skills/cmux-browser/templates/capture-workflow.sh b/.claude/skills/cmux-browser/templates/capture-workflow.sh similarity index 100% rename from .agents/skills/cmux-browser/templates/capture-workflow.sh rename to .claude/skills/cmux-browser/templates/capture-workflow.sh diff --git a/.agents/skills/cmux-browser/templates/form-automation.sh b/.claude/skills/cmux-browser/templates/form-automation.sh similarity index 100% rename from .agents/skills/cmux-browser/templates/form-automation.sh rename to .claude/skills/cmux-browser/templates/form-automation.sh diff --git a/.agents/skills/cmux-debug-windows/SKILL.md b/.claude/skills/cmux-debug-windows/SKILL.md similarity index 100% rename from .agents/skills/cmux-debug-windows/SKILL.md rename to .claude/skills/cmux-debug-windows/SKILL.md diff --git a/.agents/skills/cmux-debug-windows/agents/openai.yaml b/.claude/skills/cmux-debug-windows/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux-debug-windows/agents/openai.yaml rename to .claude/skills/cmux-debug-windows/agents/openai.yaml diff --git a/.agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/.claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh similarity index 100% rename from .agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh rename to .claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh diff --git a/.agents/skills/cmux-markdown/SKILL.md b/.claude/skills/cmux-markdown/SKILL.md similarity index 100% rename from .agents/skills/cmux-markdown/SKILL.md rename to .claude/skills/cmux-markdown/SKILL.md diff --git a/.agents/skills/cmux-markdown/agents/openai.yaml b/.claude/skills/cmux-markdown/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux-markdown/agents/openai.yaml rename to .claude/skills/cmux-markdown/agents/openai.yaml diff --git a/.agents/skills/cmux-markdown/references/commands.md b/.claude/skills/cmux-markdown/references/commands.md similarity index 100% rename from .agents/skills/cmux-markdown/references/commands.md rename to .claude/skills/cmux-markdown/references/commands.md diff --git a/.agents/skills/cmux-markdown/references/live-reload.md b/.claude/skills/cmux-markdown/references/live-reload.md similarity index 100% rename from .agents/skills/cmux-markdown/references/live-reload.md rename to .claude/skills/cmux-markdown/references/live-reload.md diff --git a/.agents/skills/cmux/SKILL.md b/.claude/skills/cmux/SKILL.md similarity index 100% rename from .agents/skills/cmux/SKILL.md rename to .claude/skills/cmux/SKILL.md diff --git a/.agents/skills/cmux/agents/openai.yaml b/.claude/skills/cmux/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux/agents/openai.yaml rename to .claude/skills/cmux/agents/openai.yaml diff --git a/.agents/skills/cmux/references/handles-and-identify.md b/.claude/skills/cmux/references/handles-and-identify.md similarity index 100% rename from .agents/skills/cmux/references/handles-and-identify.md rename to .claude/skills/cmux/references/handles-and-identify.md diff --git a/.agents/skills/cmux/references/panes-surfaces.md b/.claude/skills/cmux/references/panes-surfaces.md similarity index 100% rename from .agents/skills/cmux/references/panes-surfaces.md rename to .claude/skills/cmux/references/panes-surfaces.md diff --git a/.agents/skills/cmux/references/trigger-flash-and-health.md b/.claude/skills/cmux/references/trigger-flash-and-health.md similarity index 100% rename from .agents/skills/cmux/references/trigger-flash-and-health.md rename to .claude/skills/cmux/references/trigger-flash-and-health.md diff --git a/.agents/skills/cmux/references/windows-workspaces.md b/.claude/skills/cmux/references/windows-workspaces.md similarity index 100% rename from .agents/skills/cmux/references/windows-workspaces.md rename to .claude/skills/cmux/references/windows-workspaces.md diff --git a/.agents/skills/crit-cli/SKILL.md b/.claude/skills/crit-cli/SKILL.md similarity index 100% rename from .agents/skills/crit-cli/SKILL.md rename to .claude/skills/crit-cli/SKILL.md diff --git a/.agents/skills/ia-review/SKILL.md b/.claude/skills/ia-review/SKILL.md similarity index 100% rename from .agents/skills/ia-review/SKILL.md rename to .claude/skills/ia-review/SKILL.md diff --git a/.agents/skills/openspec-apply-change/SKILL.md b/.claude/skills/openspec-apply-change/SKILL.md similarity index 82% rename from .agents/skills/openspec-apply-change/SKILL.md rename to .claude/skills/openspec-apply-change/SKILL.md index 1375861..d474dc1 100644 --- a/.agents/skills/openspec-apply-change/SKILL.md +++ b/.claude/skills/openspec-apply-change/SKILL.md @@ -1,19 +1,16 @@ --- name: openspec-apply-change description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. -allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.2.0" --- Implement tasks from an OpenSpec change. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -33,7 +30,6 @@ Implement tasks from an OpenSpec change. ``` Parse the JSON to understand: - `schemaName`: The workflow being used (e.g., "spec-driven") - - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) 3. **Get apply instructions** @@ -43,7 +39,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state @@ -55,7 +51,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read every file path listed under `contextFiles` from the apply instructions output. + Read the files listed in `contextFiles` from the apply instructions output. The files depend on the schema being used: - **spec-driven**: proposal, specs, design, tasks - Other schemas: follow the contextFiles from CLI output diff --git a/.agents/skills/openspec-archive-change/SKILL.md b/.claude/skills/openspec-archive-change/SKILL.md similarity index 75% rename from .agents/skills/openspec-archive-change/SKILL.md rename to .claude/skills/openspec-archive-change/SKILL.md index c0c169d..9b1f851 100644 --- a/.agents/skills/openspec-archive-change/SKILL.md +++ b/.claude/skills/openspec-archive-change/SKILL.md @@ -1,19 +1,16 @@ --- name: openspec-archive-change description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. -allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.2.0" --- Archive a completed change in the experimental workflow. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -33,7 +30,6 @@ Archive a completed change in the experimental workflow. Parse the JSON to understand: - `schemaName`: The workflow being used - - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - `artifacts`: List of artifacts with their status (`done` or other) **If any artifacts are not `done`:** @@ -56,7 +52,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. + Check for delta specs at `openspec/changes//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` @@ -71,19 +67,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: + Create the archive directory if it doesn't exist: ```bash - mkdir -p "/archive" + mkdir -p openspec/changes/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 `changeRoot` to the archive directory + - If no: Move the change directory to archive ```bash - mv "" "/archive/YYYY-MM-DD-" + mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- ``` 6. **Display summary** @@ -102,7 +98,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") All artifacts complete. All tasks complete. diff --git a/.agents/skills/openspec-explore/SKILL.md b/.claude/skills/openspec-explore/SKILL.md similarity index 84% rename from .agents/skills/openspec-explore/SKILL.md rename to .claude/skills/openspec-explore/SKILL.md index 771271a..ffa10ca 100644 --- a/.agents/skills/openspec-explore/SKILL.md +++ b/.claude/skills/openspec-explore/SKILL.md @@ -1,13 +1,12 @@ --- name: openspec-explore description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. -allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.2.0" --- Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. @@ -16,8 +15,6 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - --- ## The Stance @@ -59,10 +56,10 @@ Depending on what the user brings, you might: │ Use ASCII diagrams liberally │ ├─────────────────────────────────────────┤ │ │ -│ ┌────────┐ ┌────────┐ │ -│ │ State │────────▶│ State │ │ -│ │ A │ │ B │ │ -│ └────────┘ └────────┘ │ +│ ┌────────┐ ┌────────┐ │ +│ │ State │────────▶│ State │ │ +│ │ A │ │ B │ │ +│ └────────┘ └────────┘ │ │ │ │ System diagrams, state machines, │ │ data flows, architecture sketches, │ @@ -105,10 +102,11 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Resolve and read existing artifacts for context** - - Run `openspec status --change "" --json`. - - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. - - Read existing files from `artifactPaths..existingOutputPaths`. +1. **Read existing artifacts for context** + - `openspec/changes//proposal.md` + - `openspec/changes//design.md` + - `openspec/changes//tasks.md` + - etc. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -116,14 +114,14 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |----------------------------|--------------------------------| - | New requirement discovered | `specs//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?" @@ -229,7 +227,7 @@ User: A CLI tool that tracks local dev environments You: That changes everything. ┌─────────────────────────────────────────────────┐ - │ CLI TOOL DATA STORAGE │ + │ CLI TOOL DATA STORAGE │ └─────────────────────────────────────────────────┘ Key constraints: diff --git a/.agents/skills/openspec-propose/SKILL.md b/.claude/skills/openspec-propose/SKILL.md similarity index 81% rename from .agents/skills/openspec-propose/SKILL.md rename to .claude/skills/openspec-propose/SKILL.md index 716d2d3..d27bc53 100644 --- a/.agents/skills/openspec-propose/SKILL.md +++ b/.claude/skills/openspec-propose/SKILL.md @@ -1,13 +1,12 @@ --- name: openspec-propose description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. -allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.2.0" --- Propose a new change - create the change and generate all artifacts in one step. @@ -21,8 +20,6 @@ When ready to implement, run /opsx:apply --- -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** @@ -40,7 +37,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. + This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -49,7 +46,6 @@ When ready to implement, run /opsx:apply Parse the JSON to get: - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - `artifacts`: list of all artifacts with their status and dependencies - - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. 4. **Create artifacts in sequence until apply-ready** @@ -67,10 +63,10 @@ When ready to implement, run /opsx:apply - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type - - `resolvedOutputPath`: Resolved path or pattern to write the artifact + - `outputPath`: Where to write the artifact - `dependencies`: Completed artifacts to read for context - Read any completed dependency files for context - - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` + - Create the artifact file using `template` as the structure - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.agents/skills/spec-drift-review/SKILL.md b/.claude/skills/spec-drift-review/SKILL.md similarity index 100% rename from .agents/skills/spec-drift-review/SKILL.md rename to .claude/skills/spec-drift-review/SKILL.md diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 0d2af30..0000000 --- a/.dockerignore +++ /dev/null @@ -1,14 +0,0 @@ -# Keep Docker build contexts small and hermetic. CI builds from a clean -# checkout so this mostly matters for local builds (e2e/federation -# harness), where node_modules would otherwise bloat the context to -# gigabytes — and worse, `COPY . .` would overlay host-installed -# node_modules over the image's own pnpm install. -**/node_modules -**/.turbo -**/build -**/.react-router -.git -e2e/results -playwright-report -test-results -**/*.log diff --git a/.env.development.example b/.env.development.example deleted file mode 100644 index 4b73c10..0000000 --- a/.env.development.example +++ /dev/null @@ -1,10 +0,0 @@ -# Root-level local dev defaults. -# Copy to `.env.development` (gitignored) to override. -# All values here work out of the box — no changes needed for standard local dev. - -DATABASE_URL=postgres://trails:trails@localhost:5432/trails -BROUTER_URL=http://localhost:17777 - -# Change these for any environment that is not purely local. -JWT_SECRET=dev-secret-not-for-production -SESSION_SECRET=dev-secret-not-for-production diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f2e30a4..21f282c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,22 +20,10 @@ updates: update-types: ["version-update:semver-major"] - dependency-name: "@types/node" update-types: ["version-update:semver-major"] - # These packages are version-pinned by the Expo SDK (see - # expo/bundledNativeModules.json) — upgrade them via an Expo SDK - # bump / `npx expo install --fix`, never on their own. Dependabot - # bumping them past the SDK's expected version broke the native - # build (expo-modules-core macro mismatch, June 2026) because CI - # never compiles native code. `react` stays unignored: the web - # apps own its version via the workspace catalog, and apps/mobile - # excludes it from expo version checks. + # react-native is pinned by the Expo SDK — upgrade it via an Expo + # SDK bump, not on its own. Community react-native-* libraries are + # intentionally NOT ignored here; they can be bumped independently. - dependency-name: "react-native" - - dependency-name: "react-native-gesture-handler" - - dependency-name: "react-native-reanimated" - - dependency-name: "react-native-safe-area-context" - - dependency-name: "react-native-screens" - - dependency-name: "react-native-worklets" - - dependency-name: "@sentry/react-native" - - dependency-name: "jest-expo" - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index a5a967e..3fd046c 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -13,15 +13,6 @@ concurrency: group: deploy-apps cancel-in-progress: true -# Public Sentry DSNs for the trails.cool flagship instance. Public by -# design — Sentry DSNs are transmitted unencrypted from the client JS -# bundle, embedding them in this workflow is no worse than embedding -# them in the runtime env. Self-hosted forks should either replace -# these with their own DSNs or remove the lines to ship without Sentry. -env: - SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728" - SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208" - jobs: build-images: name: Build & Push Docker Images @@ -34,7 +25,7 @@ jobs: matrix: app: [journal, planner] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: docker/login-action@v4 with: @@ -57,12 +48,8 @@ jobs: tags: | ghcr.io/trails-cool/${{ matrix.app }}:latest ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} - # VITE_SENTRY_DSN bakes the client-side DSN into the journal's - # built bundle. Only journal has a client Sentry init; planner - # ignores the build-arg if present. build-args: | SENTRY_RELEASE=${{ github.sha }} - VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} secrets: | SENTRY_AUTH_TOKEN=/tmp/sentry_token @@ -72,7 +59,7 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Decrypt secrets run: | @@ -83,16 +70,6 @@ jobs: echo "DOMAIN=trails.cool" >> infrastructure/app.env # Flagship marker — see cd-infra.yml for what this gates. echo "IS_FLAGSHIP=true" >> infrastructure/app.env - # Federation on (social-federation rollout 12.5, flipped - # 2026-06-07 after the staging + Mastodon soak). The - # FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env - # decrypted above. Rollback: delete these two lines, merge, - # rerun cd-apps — instant off, federation surfaces 404. - echo "FEDERATION_ENABLED=true" >> infrastructure/app.env - echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env - # Sentry DSNs (public — see workflow top-level env for context). - echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env - echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env - name: Copy files to server uses: appleboy/scp-action@v1 @@ -100,7 +77,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/caddy" + source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile" target: /opt/trails-cool strip_components: 1 @@ -121,10 +98,6 @@ jobs: username: root key: ${{ secrets.DEPLOY_SSH_KEY }} script: | - # Abort the deploy on the first failure. Without this, a failed - # schema push deploys new code against an old schema (the - # 2026-06-06 schema-drift incident). - set -euo pipefail cd /opt/trails-cool # Login to ghcr.io @@ -133,37 +106,11 @@ jobs: # Pull and deploy app containers docker compose --env-file app.env pull journal planner - # Hand-written data migrations (idempotent) run BEFORE drizzle-kit - # push so unique-key reshapes can collapse duplicate rows first. - docker compose --env-file app.env run --rm journal node --experimental-strip-types /app/packages/db/src/migrate-data.ts - # drizzle-kit exits 0 even when it aborts on an interactive - # prompt it can't show (no TTY in CI) — that exact lie hid a - # month of staging schema drift. Treat any Error in its output - # as a failed deploy. - docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log - if grep -q "Error:" /tmp/drizzle-push.log; then - echo "drizzle-kit push reported an error — failing the deploy" - exit 1 - fi + docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force # --remove-orphans cleans up containers whose service was deleted # from the compose file, matching cd-infra's behaviour. docker compose --env-file app.env up -d --remove-orphans journal planner - # Gate on container health: a deploy that leaves journal or - # planner unhealthy must fail loudly, not report green. - for svc in journal planner; do - for i in $(seq 1 24); do - status=$(docker inspect -f '{{.State.Health.Status}}' "trails-cool-$svc-1" 2>/dev/null || echo missing) - [ "$status" = "healthy" ] && break - sleep 5 - done - if [ "$status" != "healthy" ]; then - echo "$svc did not become healthy (last status: $status)" - docker compose --env-file app.env logs "$svc" --tail 50 || true - exit 1 - fi - done - # Reload Caddy with the Caddyfile we just scp'd. cd-apps # ships infrastructure/Caddyfile alongside docker-compose.yml # (see scp step above), but containers don't auto-pick-up @@ -177,13 +124,8 @@ jobs: # deploy from failing if Caddy itself is unhealthy. docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true - # Clean up (best-effort). This runs AFTER the containers are - # swapped + Caddy reloaded, so the deploy already succeeded — - # a transient "a prune operation is already running" collision - # with a concurrent deploy/disk-maintenance prune must NOT fail - # an otherwise-green deploy. disk-maintenance.yml is the real - # image-prune safety net. - docker image prune -af || true + # Clean up + docker image prune -af docker compose ps # Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives diff --git a/.github/workflows/cd-brouter.yml b/.github/workflows/cd-brouter.yml index 5d28e86..907df2d 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@v7 + - uses: actions/checkout@v6 - uses: docker/login-action@v4 with: @@ -42,7 +42,7 @@ jobs: runs-on: ubuntu-latest environment: infra steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Decrypt shared secret id: decrypt @@ -87,7 +87,6 @@ jobs: TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \ docker-compose.yml Caddyfile promtail-config.yml \ download-segments.sh .env \ - poi-extract \ | base64 -w0) # One SSH session: untar, (re)start containers, report status @@ -100,7 +99,7 @@ jobs: set -euo pipefail mkdir -p ~/brouter && cd ~/brouter echo "$TARBALL_B64" | base64 -d | tar -xzf - - chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py + chmod +x download-segments.sh # Segment seeding is a one-shot operator task (~10 GB, a few # minutes). CD must not rerun it on every deploy. diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index df41b93..d317ae1 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@v7 + - uses: actions/checkout@v6 - name: Decrypt secrets run: | @@ -43,7 +43,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/caddy,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards,infrastructure/scripts" + source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards" target: /opt/trails-cool strip_components: 1 @@ -64,11 +64,6 @@ jobs: username: root key: ${{ secrets.DEPLOY_SSH_KEY }} script: | - # Abort on first failure. The 2026-06-06/07 outage: a network - # recreation stopped postgres, a later step failed, and the - # deploy left production down for ~9h while the job's partial - # progress looked plausible. Fail fast, verify health at the end. - set -euo pipefail cd /opt/trails-cool # .env was placed by the SCP step (decrypted app + infra secrets) @@ -84,77 +79,20 @@ jobs: docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true fi - # Capture whether prometheus was recreated. A fresh container already - # reads the new config on startup; sending it SIGHUP immediately can - # kill Prometheus 3.10 during early boot (exit 2 observed on - # 2026-06-09). - PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) - # Full restart: gh workflow run cd-infra.yml -f restart_all=true if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then docker compose --env-file .env up -d --remove-orphans else - # Restart infra services (config reloads handled below). + # Restart infra services (except Caddy — just reload its config). # --remove-orphans cleans up containers whose service was deleted # from the compose file (e.g., the flagship `brouter` removal in # PR #297 left an orphan that had to be removed by hand). docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile fi - PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) - - # Apply config-only changes that `up -d` skips — it recreates a - # container only when its compose *definition* changes, not when a - # mounted config file's content changes. The configs are mounted as - # directories (not single files), so a reload/restart re-reads the - # freshly scp'd file; a single-file mount would have pinned the old - # inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when - # the same container stayed up; a recreated container already loaded - # the new config on startup. Loki and Promtail reload their main - # config only on restart; Caddy reloads gracefully (validates, swaps - # live, no downtime). - if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then - docker compose --env-file .env kill -s SIGHUP prometheus - fi - docker compose --env-file .env restart loki promtail - docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile - docker compose ps - # Gate on the stack actually being up: postgres healthy, journal - # back to healthy after the DB bounce, and Prometheus answering - # /-/ready. A deploy that leaves any of them down must fail loudly - # (see the 2026-06-06/07 outage, plus the 2026-06-09 Prometheus - # startup/HUP regression). - for ctr in trails-cool-postgres-1 trails-cool-journal-1; do - for i in $(seq 1 36); do - status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing) - [ "$status" = "healthy" ] && break - sleep 5 - done - if [ "$status" != "healthy" ]; then - echo "$ctr did not become healthy (last status: $status)" - exit 1 - fi - done - - PROMETHEUS_READY= - for i in $(seq 1 36); do - prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing) - if [ "$prom_status" = "running" ]; then - prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1) - if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then - PROMETHEUS_READY=1 - break - fi - fi - sleep 5 - done - if [ -z "$PROMETHEUS_READY" ]; then - echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)" - exit 1 - fi - # Annotate deploy in Grafana GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) if [ -n "$GRAFANA_TOKEN" ]; then diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml deleted file mode 100644 index e69dec6..0000000 --- a/.github/workflows/cd-staging.yml +++ /dev/null @@ -1,520 +0,0 @@ -name: CD Staging - -# Builds, deploys, and tears down the persistent staging stack and per-PR -# preview environments. See `openspec/changes/staging-environments/` for the -# design (decisions on shared Postgres, port allocation, per-PR Caddyfile -# snippets) and CLAUDE.md "Staging & Previews" for the operator-facing view. - -on: - push: - branches: [main] - paths: - - "apps/**" - - "packages/**" - - "pnpm-lock.yaml" - # Also redeploy when the staging plumbing itself changes, so a port - # bump or compose edit doesn't sit unapplied until the next apps/ push. - - "infrastructure/docker-compose.staging.yml" - - ".github/workflows/cd-staging.yml" - pull_request: - # `labeled`/`unlabeled` so toggling the `preview` label on an existing PR - # starts / tears down its preview (see the opt-in gate on the jobs below). - types: [opened, synchronize, reopened, closed, labeled, unlabeled] - paths: - - "apps/**" - - "packages/**" - - "pnpm-lock.yaml" - workflow_dispatch: {} - -# Per-target concurrency: persistent staging deploys serialize against -# themselves, each PR's preview lifecycle serializes against itself. -concurrency: - group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }} - cancel-in-progress: true - -# Public Sentry DSNs (same as cd-apps.yml). See that workflow for the -# "public by design" rationale. -env: - SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728" - SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208" - -jobs: - # ── Build ───────────────────────────────────────────────────────────── - # Tags: - # main push → :staging + : - # PR open/sync/reopen → :pr- + :pr-- - # Skipped entirely on PR close (teardown doesn't need new images). - build-images: - name: Build & Push Docker Images - # PR previews are opt-in to keep flagship disk in check (each preview is a - # journal container + database): build a PR's images only when it carries - # the `preview` label or a `` marker in its description. - # Main-push / dispatch always build. - if: > - github.event_name != 'pull_request' || - (github.event.action != 'closed' && - (contains(github.event.pull_request.labels.*.name, 'preview') || - contains(github.event.pull_request.body, ''))) - runs-on: ubuntu-latest - environment: production - permissions: - contents: read - packages: write - strategy: - matrix: - app: [journal, planner] - outputs: - tag_primary: ${{ steps.tags.outputs.primary }} - tag_sha: ${{ steps.tags.outputs.sha }} - steps: - - uses: actions/checkout@v7 - - - id: tags - name: Compute image tags - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - PRIMARY="pr-${{ github.event.number }}" - SHA="pr-${{ github.event.number }}-${{ github.event.pull_request.head.sha }}" - else - PRIMARY="staging" - SHA="${{ github.sha }}" - fi - echo "primary=$PRIMARY" >> "$GITHUB_OUTPUT" - echo "sha=$SHA" >> "$GITHUB_OUTPUT" - - - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Decrypt Sentry auth token - run: | - curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 - chmod +x sops-v3.9.4.linux.amd64 - SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env - grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token - - - uses: docker/build-push-action@v7 - with: - context: . - file: apps/${{ matrix.app }}/Dockerfile - push: true - tags: | - ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.primary }} - ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }} - build-args: | - SENTRY_RELEASE=${{ steps.tags.outputs.sha }} - VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} - secrets: | - SENTRY_AUTH_TOKEN=/tmp/sentry_token - - # ── Deploy persistent staging (main push or manual dispatch) ───────── - deploy-staging: - name: Deploy Staging - if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' - needs: [build-images] - runs-on: ubuntu-latest - environment: production - steps: - - uses: actions/checkout@v7 - - - name: Decrypt secrets - run: | - curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 - chmod +x sops-v3.9.4.linux.amd64 - SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env - { - echo "DOMAIN=staging.trails.cool" - echo "STAGING_DATABASE=trails_staging" - # Loki binds 10.0.0.2:3100 on the vSwitch interface, so the - # staging stack can't share 3100 — see CLAUDE.md "Staging & - # Previews" port table. - echo "JOURNAL_HOST_PORT=3110" - echo "PLANNER_HOST_PORT=3111" - echo "JOURNAL_IMAGE_TAG=staging" - echo "PLANNER_IMAGE_TAG=staging" - echo "SENTRY_RELEASE=${{ github.sha }}" - echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" - echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" - # Federation soak on persistent staging only (social-federation - # rollout 12.2). FEDERATION_KEY_ENCRYPTION_KEY comes from the - # SOPS env decrypted above; previews never set this flag. - echo "FEDERATION_ENABLED=true" - # Verbose Fedify logs during the soak (signature verification - # detail). Dial down to info once federation is proven out. - echo "FEDERATION_LOG_LEVEL=debug" - } >> infrastructure/staging.env - - - name: Copy compose file + env to server - uses: appleboy/scp-action@v1 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env" - target: /opt/trails-cool - strip_components: 1 - - - name: Deploy via SSH - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - script: | - set -euo pipefail - cd /opt/trails-cool - - GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-) - echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin - - # Bootstrap the shared network so cd-staging works regardless of - # whether cd-infra has already run with the new docker-compose.yml. - # Once cd-infra runs, postgres is permanently joined via compose; - # until then, attach it imperatively. - docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared - PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1) - if [ -n "$PG_CONTAINER" ]; then - docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true - fi - - # Ensure trails_staging database exists with postgis. Production - # init scripts only run on first data-dir init, so a freshly - # created database has no extensions. - docker compose exec -T postgres psql -U trails -d postgres -tAc \ - "SELECT 1 FROM pg_database WHERE datname='trails_staging'" \ - | grep -q 1 \ - || docker compose exec -T postgres createdb -U trails trails_staging - docker compose exec -T postgres psql -U trails -d trails_staging -c \ - "CREATE EXTENSION IF NOT EXISTS postgis" - - # Pull and deploy staging containers (journal + planner via "persistent" profile) - docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent pull - # drizzle-kit exits 0 even when it aborts on an interactive - # prompt (no TTY in CI) — set -e alone can't catch it. That lie - # hid a month of staging schema drift (2026-06-06 incident). - docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log - if grep -q "Error:" /tmp/drizzle-push.log; then - echo "drizzle-kit push reported an error — failing the deploy" - exit 1 - fi - docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent up -d --remove-orphans - - # Reload Caddy so new staging routes (or Caddyfile changes shipped - # via cd-infra) are live. Idempotent. - docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true - - # Reclaim superseded image layers. cd-apps prunes after its own - # deploys, but a day of staging/preview deploys while cd-apps is - # red can fill the disk on its own (2026-06-07: 100% full, - # postgres down). The 1h filter avoids racing layers another - # in-flight deploy just pulled. - docker image prune -af --filter "until=1h" || true - - docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps - - # ── PR preview deploy ──────────────────────────────────────────────── - deploy-preview: - name: Deploy PR Preview - # Opt-in only (see build-images): the `preview` label or a `` - # marker in the PR body. Also skips GH-Actions-only Dependabot PRs — there - # is no app image to preview. - if: > - github.event_name == 'pull_request' && - github.event.action != 'closed' && - !startsWith(github.head_ref, 'dependabot/github_actions/') && - (contains(github.event.pull_request.labels.*.name, 'preview') || - contains(github.event.pull_request.body, '')) - needs: [build-images] - runs-on: ubuntu-latest - environment: production - permissions: - pull-requests: write - steps: - - uses: actions/checkout@v7 - - - id: ports - name: Compute preview ports + project name - run: | - PR=${{ github.event.number }} - # journal = 3200 + 2N, planner unused (PR previews are journal-only) - JOURNAL_PORT=$((3200 + 2 * PR)) - PLANNER_PORT=$((3201 + 2 * PR)) - echo "pr=$PR" >> "$GITHUB_OUTPUT" - echo "journal_port=$JOURNAL_PORT" >> "$GITHUB_OUTPUT" - echo "planner_port=$PLANNER_PORT" >> "$GITHUB_OUTPUT" - echo "host=pr-$PR.staging.trails.cool" >> "$GITHUB_OUTPUT" - echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT" - echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT" - - - name: Decrypt secrets + assemble env - run: | - curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 - chmod +x sops-v3.9.4.linux.amd64 - # Use a per-PR filename so concurrent SCP transfers don't overwrite each other. - SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env - { - echo "DOMAIN=${{ steps.ports.outputs.host }}" - echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}" - echo "JOURNAL_HOST_PORT=${{ steps.ports.outputs.journal_port }}" - echo "PLANNER_HOST_PORT=${{ steps.ports.outputs.planner_port }}" - echo "JOURNAL_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}" - echo "PLANNER_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}" - # PR-preview journals all share the persistent staging planner. - echo "PLANNER_URL=https://planner.staging.trails.cool" - echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}" - echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" - echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" - # Federation on previews too (social-federation rollout 12.4): - # makes any PR preview a second live trails instance that - # persistent staging can follow across — the trails-to-trails - # soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the - # SOPS env decrypted above. Preview teardown orphans remote - # follower rows on the other side; remotes handle dead - # instances via ordinary delivery-failure expiry. - echo "FEDERATION_ENABLED=true" - echo "FEDERATION_LOG_LEVEL=debug" - } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env - - - name: Generate per-PR Caddyfile snippet - run: | - mkdir -p infrastructure/sites - cat > infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile </tmp/trails-preview-deploy.lock - flock --timeout 300 9 || { echo "Timed out waiting for deploy lock after 300s"; exit 1; } - - # Same network bootstrap as deploy-staging — see comment there. - docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared - PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1) - if [ -n "$PG_CONTAINER" ]; then - docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true - fi - - # Concurrent preview limit (max 3): if we're at the cap and this - # PR isn't already running, evict the oldest preview project. - ACTIVE=$(docker compose ls --format json --filter "name=trails-pr-" | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\n".join(d["Name"] for d in data))' 2>/dev/null || true) - if [ -n "$ACTIVE" ] && ! echo "$ACTIVE" | grep -qx "$PROJECT"; then - COUNT=$(echo "$ACTIVE" | grep -c '^trails-pr-' || true) - if [ "$COUNT" -ge 3 ]; then - # Pick the oldest by container CreatedAt of any service in the project. - OLDEST=$(docker ps -a --filter "name=trails-pr-" --format '{{.Names}} {{.CreatedAt}}' \ - | awk '{ split($1,a,"-"); print "trails-pr-"a[3], $2" "$3" "$4 }' \ - | sort -k2 \ - | head -1 \ - | awk '{print $1}') - if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then - echo "At cap; evicting oldest preview: $OLDEST" - OLD_PR=${OLDEST#trails-pr-} - OLD_ENV="staging-pr-${OLD_PR}.env" - # Fall back to base secrets if the per-PR env was already cleaned up. - EVICT_ENV=$( [ -f "$OLD_ENV" ] && echo "$OLD_ENV" || echo "staging.env" ) - docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file "$EVICT_ENV" down --remove-orphans || true - docker compose exec -T postgres dropdb -U trails --if-exists "trails_pr_$OLD_PR" || true - rm -f "sites/pr-$OLD_PR.caddyfile" "$OLD_ENV" - fi - fi - fi - - # Ensure per-PR database exists with postgis. (See deploy-staging - # for why we have to enable the extension explicitly.) - docker compose exec -T postgres psql -U trails -d postgres -tAc \ - "SELECT 1 FROM pg_database WHERE datname='$DB'" \ - | grep -q 1 \ - || docker compose exec -T postgres createdb -U trails "$DB" - docker compose exec -T postgres psql -U trails -d "$DB" -c \ - "CREATE EXTENSION IF NOT EXISTS postgis" - - # Pull, migrate, deploy (journal-only — no --profile means planner skipped) - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" pull journal - # Same drizzle-kit exit-code-0-on-error guard as the persistent - # staging deploy above. - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log - if grep -q "Error:" /tmp/drizzle-push.log; then - echo "drizzle-kit push reported an error — failing the preview deploy" - exit 1 - fi - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" up -d --remove-orphans journal - - # Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step) - docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile - - # Same disk-hygiene prune as the persistent staging deploy — - # preview pushes are the highest-volume image source. - docker image prune -af --filter "until=1h" || true - - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps - - # Find any prior preview comment so we can update it in place rather - # than spamming a new one each push. The marker line at the bottom of - # the body is what `body-includes` matches on. - - name: Find existing preview comment - uses: peter-evans/find-comment@v4 - id: find-comment - with: - issue-number: ${{ github.event.number }} - comment-author: "github-actions[bot]" - body-includes: "" - - - name: Upsert preview comment on PR - uses: peter-evans/create-or-update-comment@v5 - with: - comment-id: ${{ steps.find-comment.outputs.comment-id }} - issue-number: ${{ github.event.number }} - edit-mode: replace - body: | - 🚀 **PR preview deployed** - - - **Journal:** https://${{ steps.ports.outputs.host }} - - **Planner (shared staging):** https://planner.staging.trails.cool - - **Database:** `${{ steps.ports.outputs.database }}` (separate from production / persistent staging) - - **Build:** [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit `${{ github.event.pull_request.head.sha }}` - - Updates automatically on push. Tears down when this PR closes. - - - - # ── PR preview teardown ────────────────────────────────────────────── - teardown-preview: - name: Tear Down PR Preview - # Tear down on close, or when the `preview` opt-in is removed (label pulled - # and no `` marker left) so a de-flagged PR doesn't orphan - # its preview stack on the flagship. - if: > - github.event_name == 'pull_request' && - (github.event.action == 'closed' || - (github.event.action == 'unlabeled' && - !(contains(github.event.pull_request.labels.*.name, 'preview') || - contains(github.event.pull_request.body, '')))) - runs-on: ubuntu-latest - environment: production - permissions: - pull-requests: write - steps: - - id: ports - name: Compute project + database name - run: | - PR=${{ github.event.number }} - echo "pr=$PR" >> "$GITHUB_OUTPUT" - echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT" - echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@v7 - - - name: Decrypt secrets (needed to satisfy compose env vars during down) - run: | - curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 - chmod +x sops-v3.9.4.linux.amd64 - SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env - { - echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool" - echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}" - echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))" - echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))" - } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env - - - name: Copy compose + env (teardown still needs the file) - uses: appleboy/scp-action@v1 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env" - target: /opt/trails-cool - strip_components: 1 - - - name: Tear down via SSH - uses: appleboy/ssh-action@v1 - env: - PR: ${{ steps.ports.outputs.pr }} - PROJECT: ${{ steps.ports.outputs.project }} - DB: ${{ steps.ports.outputs.database }} - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - envs: PR,PROJECT,DB - script: | - set -euo pipefail - cd /opt/trails-cool - ENV_FILE="staging-pr-${PR}.env" - - # Stop and remove containers + volumes for this PR - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" down --remove-orphans || true - - # Drop the per-PR database (idempotent) - docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true - - # Remove the per-PR Caddy snippet, env file, and reload - rm -f "sites/pr-$PR.caddyfile" "$ENV_FILE" - docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true - - - name: Find existing preview comment - uses: peter-evans/find-comment@v4 - id: find-comment - with: - issue-number: ${{ github.event.number }} - comment-author: "github-actions[bot]" - body-includes: "" - - - name: Update preview comment on close - if: steps.find-comment.outputs.comment-id - uses: peter-evans/create-or-update-comment@v5 - with: - comment-id: ${{ steps.find-comment.outputs.comment-id }} - edit-mode: replace - body: | - 🧹 **PR preview torn down** (PR ${{ github.event.pull_request.merged && 'merged' || 'closed' }}). - - Database `trails_pr_${{ steps.ports.outputs.pr }}` dropped, containers removed, Caddyfile snippet cleared. - - [Teardown run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) - - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 068ebc5..3240a80 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@v7 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Gitleaks @@ -42,27 +42,14 @@ jobs: name: Dockerfile Package Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - run: bash scripts/check-dockerfiles.sh - openspec: - name: OpenSpec Validate - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm openspec validate --all --strict --no-interactive - typecheck: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -75,7 +62,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -88,7 +75,7 @@ jobs: name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -101,7 +88,7 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -110,72 +97,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - visual-tests: - name: Visual Tests - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ hashFiles('pnpm-lock.yaml') }} - - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: pnpm exec playwright install --with-deps chromium - - - name: Install Playwright deps only - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: pnpm exec playwright install-deps chromium - - - name: Run visual regression tests - id: visual-tests - run: pnpm --filter @trails-cool/planner test:visual - - - name: Post diff comment on PR - if: failure() && steps.visual-tests.outcome == 'failure' && github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort) - if [ -z "$diffs" ]; then exit 0; fi - - artifact_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - body="## Visual regression failures"$'\n\n' - body+="The following tests produced screenshot diffs:"$'\n\n' - for diff in $diffs; do - name=$(basename "$diff" | sed 's/-diff-chromium-[a-z]*\.png//' | sed 's/-/ /g') - body+="- \`$name\`"$'\n' - done - body+=$'\n'"**[Download the \`visual-snapshots-diff\` artifact]($artifact_url)** to inspect the diffs locally."$'\n\n' - body+="To update snapshots if the change is intentional:"$'\n' - body+="\`\`\`"$'\n' - body+="pnpm --filter @trails-cool/planner test:visual:update"$'\n' - body+="\`\`\`" - - gh pr comment ${{ github.event.pull_request.number }} --body "$body" - - - name: Upload screenshots on failure - if: failure() - uses: actions/upload-artifact@v7 - with: - name: visual-snapshots-diff - path: apps/planner/.vitest-attachments/ - include-hidden-files: true - retention-days: 7 - e2e: name: E2E Tests needs: build @@ -183,7 +104,7 @@ jobs: env: DATABASE_URL: postgres://trails:trails@localhost:5432/trails steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -191,12 +112,64 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile + - name: Cache PostGIS Docker image + id: postgis-cache + uses: actions/cache@v5 + with: + path: /tmp/postgis-image.tar + key: postgis-16-3.4 + + - name: Load or pull PostGIS image + run: | + if [ -f /tmp/postgis-image.tar ]; then + docker load < /tmp/postgis-image.tar + else + docker pull postgis/postgis:16-3.4 + docker save postgis/postgis:16-3.4 > /tmp/postgis-image.tar + fi + + - name: Start PostgreSQL + run: | + docker run -d --name postgres \ + -e POSTGRES_USER=trails \ + -e POSTGRES_PASSWORD=trails \ + -e POSTGRES_DB=trails \ + -p 5432:5432 \ + postgis/postgis:16-3.4 + # Wait for pg_isready + for i in $(seq 1 30); do + docker exec postgres pg_isready -U trails > /dev/null 2>&1 && break + sleep 1 + done + # Wait for PostGIS extension to be ready + for i in $(seq 1 10); do + docker exec postgres psql -U trails -c "SELECT PostGIS_Version();" > /dev/null 2>&1 && break + sleep 1 + done + + - name: Push database schema + run: pnpm db:push + + - name: Build and cache BRouter + id: brouter-cache + uses: actions/cache@v5 + with: + path: /tmp/brouter + key: brouter-1.7.8 + + - name: Download BRouter + if: steps.brouter-cache.outputs.cache-hit != 'true' + run: | + mkdir -p /tmp/brouter + wget -q "https://github.com/abrensch/brouter/releases/download/v1.7.8/brouter-1.7.8.zip" -O /tmp/brouter/brouter.zip + cd /tmp/brouter && unzip -o brouter.zip && mv brouter-1.7.8/* . && rmdir brouter-1.7.8 && rm brouter.zip + - name: Cache BRouter segment id: segment-cache - uses: actions/cache@v6 + uses: actions/cache@v5 with: path: /tmp/brouter-segments - key: brouter-segment-E10_N50-v1.7.9 + key: brouter-segment-E10_N50 - name: Download Berlin segment if: steps.segment-cache.outputs.cache-hit != 'true' @@ -204,54 +177,26 @@ jobs: mkdir -p /tmp/brouter-segments wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5 - - name: Pre-seed BRouter segment volume + - name: Start BRouter run: | - docker volume create trails_brouter_segments - docker run --rm \ - -v /tmp/brouter-segments:/src:ro \ - -v trails_brouter_segments:/dst \ - alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5" - - - name: Start services - run: docker compose -f docker-compose.dev.yml up -d --wait --build + cd /tmp/brouter + java -Xmx256M -Xms64M \ + -DmaxRunningTime=300 \ + -cp brouter-1.7.8-all.jar \ + btools.server.RouteServer \ + /tmp/brouter-segments profiles2 profiles2 \ + 17777 2 & + # Wait for BRouter to start + for i in $(seq 1 30); do + curl -sf http://localhost:17777/brouter?lonlats=13.4,52.5\|13.5,52.5\&profile=trekking\&format=geojson > /dev/null 2>&1 && break + sleep 2 + done env: BROUTER_URL: http://localhost:17777 - - name: Wait for BRouter routing - run: | - for i in $(seq 1 60); do - curl -s 'http://localhost:17777/brouter?lonlats=13.4,52.5|13.5,52.5&profile=trekking&format=geojson' 2>/dev/null | grep -q "FeatureCollection" && echo "BRouter ready" && break - [ "$i" = "60" ] && echo "BRouter not ready after 120s" && exit 1 - sleep 2 - done - - - name: Push database schema - run: pnpm db:push - - - name: Seed database - run: pnpm db:seed - - - name: Run integration tests - # These talk to real Postgres. The unit-test job has no DB so - # the `*.integration.test.ts` files skip there; this job has - # the DB up + schema pushed, so flip the gate env vars to "1" - # and let them run. Each gate is read by one file — see - # `runIntegration` in each test. - # - # --no-file-parallelism: integration tests share the journal - # schema and clean up by `DELETE FROM ... WHERE email LIKE - # '%@example.test'`. Parallel files step on each other's rows - # and trip FK constraints. Running sequentially is still <3s. - run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts - env: - EXPLORE_INTEGRATION: "1" - FOLLOW_INTEGRATION: "1" - DEMO_BOT_INTEGRATION: "1" - NOTIFICATIONS_INTEGRATION: "1" - - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v6 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pnpm-lock.yaml') }} @@ -273,12 +218,6 @@ jobs: run: pnpm test:e2e env: BROUTER_URL: http://localhost:17777 - # E2E=true is the explicit opt-out from the fail-loud - # requireSecret() / getDatabaseUrl() guards — playwright boots - # the server via `react-router serve` (NODE_ENV=production) but - # against the local dev Postgres + local cookie secrets. - E2E: "true" - INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }} - name: Playwright job summary if: ${{ !cancelled() }} @@ -316,87 +255,3 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 30 - - journal-image-smoke: - # Build the journal's *production* Docker image (the `runtime` stage) - # and actually boot it. Nothing else in CI does this: typecheck / - # lint / test / build all run against the source tree, and the e2e - # job boots the journal via `react-router-serve`, not the production - # `node server.ts` entrypoint. The runtime stage copies source files - # in by name (server.ts, app/lib, serve-static.ts, ...), so a refactor - # that adds a file `server.ts` imports — without a matching COPY — - # builds green everywhere and only crash-loops once deployed - # (ERR_MODULE_NOT_FOUND). That has taken prod down more than once - # (app/lib, app/jobs, serve-static.ts). Booting the real image and - # hitting /api/health closes that gap: a missing static OR dynamic - # import never reaches a healthy 200. - name: Journal Image Smoke Test - runs-on: ubuntu-latest - services: - postgres: - image: imresamu/postgis:16-3.4 - env: - POSTGRES_USER: trails - POSTGRES_PASSWORD: trails - POSTGRES_DB: trails - ports: - - 5432:5432 - # The postgis image restarts mid-init while it creates the - # extension; the health check only passes once the final server - # is up, so dependents don't race the init restart. - options: >- - --health-cmd "pg_isready -U trails" - --health-interval 5s - --health-timeout 5s - --health-retries 20 - env: - DATABASE_URL: postgres://trails:trails@localhost:5432/trails - steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - # seedOAuthClient + the demo/notifications job worker run on boot - # and write to real tables, so the image needs a schema to come up - # healthy. The postgis extension is auto-created by the image. - - name: Push database schema - run: pnpm db:push - - - name: Build journal runtime image - run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke . - - - name: Boot image and wait for healthy - run: | - # --network host: reach the service Postgres at localhost:5432 - # and publish the server on localhost:3000 in one shot. - # E2E=true is the documented opt-out from the fail-loud - # getDatabaseUrl() prod guard (CI points at a local Postgres). - docker run -d --name journal-smoke --network host \ - -e NODE_ENV=production -e E2E=true \ - -e DATABASE_URL="$DATABASE_URL" \ - journal-smoke - code=000 - for i in $(seq 1 30); do - code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000) - echo "attempt $i: /api/health -> $code" - [ "$code" = "200" ] && break - if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then - echo "::error::journal container exited during boot" - break - fi - sleep 2 - done - if [ "$code" != "200" ]; then - echo "::error::journal production image failed to boot healthy (see logs below)" - docker logs journal-smoke 2>&1 || true - exit 1 - fi - echo "journal production image booted healthy" - - - name: Container logs - if: always() - run: docker logs journal-smoke 2>&1 | tail -40 || true diff --git a/.github/workflows/dependabot-auto-fix.yml b/.github/workflows/dependabot-auto-fix.yml deleted file mode 100644 index 85e110f..0000000 --- a/.github/workflows/dependabot-auto-fix.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Dependabot auto-fix - -# Post-processing that runs on every dependabot PR and pushes the result -# back to the PR branch. Two fixups, in one workflow so there is a single -# checkout / commit / push (two workflows racing to push the same branch -# would collide on a non-fast-forward): -# -# 1. pnpm dedupe — `pnpm install` alone doesn't dedupe peer copies of a -# package (e.g. two versions of i18next, each holding their own -# singleton state). That split caused a hydration mismatch on #272 -# until a manual `pnpm dedupe` collapsed them. -# -# 2. openspec update — the OpenSpec agent skills (.agents/skills/openspec-*) -# and opsx slash commands (.claude/commands/opsx/*) are generated files -# stamped with the CLI version that produced them. When dependabot bumps -# @fission-ai/openspec they go stale until regenerated (see PR #567, -# which did the 1.2.0 -> 1.6.0 regen by hand). `openspec update --force` -# rewrites them to match the freshly-installed CLI. -# -# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a -# fine-grained PAT (or GitHub App token) with `contents: write` on -# this repo. The default `GITHUB_TOKEN` would work for the push but -# would NOT trigger a subsequent CI run on that push (GitHub's -# anti-loop safeguard), leaving the PR with stale green CI from -# before the fixup commit. A PAT re-triggers CI so the reviewer -# sees test results for the state they'd actually be merging. - -on: - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - autofix: - if: github.actor == 'dependabot[bot]' - runs-on: ubuntu-latest - steps: - - name: Require DEPENDABOT_DEDUPE_TOKEN - env: - TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} - run: | - if [ -z "$TOKEN" ]; then - echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow" - echo "::error::requires a PAT so the fixup commit re-triggers CI." - echo "::error::See .github/workflows/dependabot-auto-fix.yml header." - exit 1 - fi - - uses: actions/checkout@v7 - with: - ref: ${{ github.head_ref }} - # With `persist-credentials: true`, this PAT is stashed by the - # action (under $RUNNER_TEMP in recent versions) and made - # available to subsequent git operations in this workspace — - # so the later `git push` authenticates as the PAT, which is - # what gets CI to re-trigger on the fixup commit. `true` is - # the checkout default; pinned explicitly here because it's - # load-bearing for this workflow. - token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} - persist-credentials: true - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile=false - - name: Dedupe lockfile - run: pnpm dedupe - - name: Regenerate OpenSpec tool files - # --force so the generated files always match the installed CLI, - # even if `update` would otherwise consider them up to date. No-op - # (no diff) when @fission-ai/openspec wasn't bumped in this PR. - run: pnpm exec openspec update --force - - name: Commit fixups - env: - GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} - run: | - git add pnpm-lock.yaml .agents/skills/openspec-* .claude/commands/opsx - if git diff --cached --quiet; then - echo "Nothing to fix up — lockfile deduped and OpenSpec files current." - exit 0 - fi - # Build a message naming only the parts that actually changed. - parts="" - git diff --cached --name-only | grep -q '^pnpm-lock.yaml$' && parts="pnpm dedupe" - if git diff --cached --name-only | grep -qE '^(\.agents/skills/openspec-|\.claude/commands/opsx)'; then - parts="${parts:+$parts + }openspec update" - fi - git config user.name "dependabot[bot]" - git config user.email "49699333+dependabot[bot]@users.noreply.github.com" - git commit -m "[github-actions] $parts" - git push - echo "Pushed fixups: $parts" diff --git a/.github/workflows/dependabot-dedupe.yml b/.github/workflows/dependabot-dedupe.yml new file mode 100644 index 0000000..09c90b1 --- /dev/null +++ b/.github/workflows/dependabot-dedupe.yml @@ -0,0 +1,74 @@ +name: Dependabot dedupe + +# Dependabot opens a PR after a bump, but `pnpm install` alone doesn't +# dedupe peer copies of packages (e.g. two versions of i18next, each +# holding their own singleton state). That split caused a hydration +# mismatch on #272 until a manual `pnpm dedupe` collapsed them. +# +# This workflow fires on every dependabot PR, runs `pnpm dedupe`, and +# pushes the resulting lockfile update back to the PR branch so the +# subsequent CI run tests the deduped tree. +# +# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a +# fine-grained PAT (or GitHub App token) with `contents: write` on +# this repo. The default `GITHUB_TOKEN` would work for the push but +# would NOT trigger a subsequent CI run on that push (GitHub's +# anti-loop safeguard), leaving the PR with stale green CI from +# before the dedupe commit. A PAT re-triggers CI so the reviewer +# sees test results for the state they'd actually be merging. + +on: + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + dedupe: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Require DEPENDABOT_DEDUPE_TOKEN + env: + TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} + run: | + if [ -z "$TOKEN" ]; then + echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow" + echo "::error::requires a PAT so the dedupe commit re-triggers CI." + echo "::error::See .github/workflows/dependabot-dedupe.yml header." + exit 1 + fi + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + # With `persist-credentials: true`, this PAT is stashed by the + # action (under $RUNNER_TEMP in recent versions) and made + # available to subsequent git operations in this workspace — + # so the later `git push` authenticates as the PAT, which is + # what gets CI to re-trigger on the dedupe commit. `true` is + # the checkout default; pinned explicitly here because it's + # load-bearing for this workflow. + token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} + persist-credentials: true + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile=false + - run: pnpm dedupe + - name: Commit dedupe changes + env: + GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} + run: | + if [ -n "$(git status --porcelain pnpm-lock.yaml)" ]; then + git config user.name "dependabot[bot]" + git config user.email "49699333+dependabot[bot]@users.noreply.github.com" + git add pnpm-lock.yaml + git commit -m "[github-actions] pnpm dedupe" + git push + echo "Deduped lockfile pushed." + else + echo "Lockfile already deduped." + fi diff --git a/.github/workflows/disk-maintenance.yml b/.github/workflows/disk-maintenance.yml deleted file mode 100644 index d8f6a09..0000000 --- a/.github/workflows/disk-maintenance.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Disk Maintenance - -# Daily safety net for flagship disk usage. The deploy workflows prune -# superseded image layers after their own runs, but that protection -# disappears exactly when it's needed most: a deploy that fails early -# never reaches its prune step, while other workflows keep pulling -# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging -# deploys, disk 100% full, postgres down on a Saturday morning). -# -# Also doubles as a redundant alert channel: the run FAILS when the -# disk is still above the threshold after pruning, so a scheduled-run -# failure email lands even if the Grafana disk alert drowns in other -# noise (which is what happened during the incident). - -on: - schedule: - # Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00) - - cron: "30 4 * * *" - workflow_dispatch: {} - -concurrency: - group: disk-maintenance - cancel-in-progress: false - -jobs: - prune-flagship: - name: Prune unused images (flagship) - runs-on: ubuntu-latest - environment: production - steps: - - name: Prune and check disk headroom - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - script: | - set -euo pipefail - echo "before: $(df -h / | tail -1)" - # 12h filter: never touch layers a same-day deploy may still - # be assembling; running containers' images are never pruned. - # `|| true`: a concurrent deploy's prune can make this collide - # with "a prune operation is already running" — that's benign - # (the other prune is freeing space too), and the disk-% gate - # below is the real assertion, so don't fail on the collision. - docker image prune -af --filter "until=12h" || true - echo "after: $(df -h / | tail -1)" - - PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9') - if [ "$PCT" -ge 85 ]; then - echo "Disk still at ${PCT}% after pruning — needs a human." - echo "Largest docker consumers:" - docker system df - exit 1 - fi - echo "Disk at ${PCT}% — healthy." diff --git a/.github/workflows/staging-cleanup.yml b/.github/workflows/staging-cleanup.yml deleted file mode 100644 index bed2fd4..0000000 --- a/.github/workflows/staging-cleanup.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Staging Cleanup - -# Sweeps the production server for orphaned PR-preview resources whose PRs -# have closed without the cd-staging teardown job running (e.g., the -# teardown failed, the workflow file was changed mid-flight, or the PR was -# closed while runners were down). Runs weekly and can be triggered ad-hoc. - -on: - schedule: - # Every Monday at 04:00 UTC - - cron: "0 4 * * 1" - workflow_dispatch: {} - -concurrency: - group: staging-cleanup - cancel-in-progress: false - -jobs: - cleanup: - name: Sweep orphaned previews - runs-on: ubuntu-latest - environment: production - permissions: - contents: read - pull-requests: read - steps: - - name: List active preview projects - id: list - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - script_stop: true - script: | - cd /opt/trails-cool - # Emit one project name per line, e.g. "trails-pr-123" - docker compose ls --format json --filter "name=trails-pr-" \ - | python3 -c 'import json,sys - try: - data = json.load(sys.stdin) - except Exception: - data = [] - for d in data: - n = d.get("Name","") - if n.startswith("trails-pr-"): - print(n)' \ - || true - - - name: Determine which PRs are still open - id: orphans - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PROJECTS: ${{ steps.list.outputs.stdout }} - run: | - set -euo pipefail - ORPHANS=() - if [ -z "${PROJECTS:-}" ]; then - echo "No active preview projects." - echo "orphans=" >> "$GITHUB_OUTPUT" - exit 0 - fi - while IFS= read -r project; do - [ -z "$project" ] && continue - pr="${project#trails-pr-}" - # If gh can't find the PR (deleted) or it's not OPEN, treat as orphan. - state=$(gh pr view "$pr" --repo "${{ github.repository }}" --json state -q .state 2>/dev/null || echo "MISSING") - if [ "$state" != "OPEN" ]; then - echo "Orphan: PR #$pr (state=$state) → tear down $project" - ORPHANS+=("$pr") - fi - done <<< "${PROJECTS}" - IFS=, - echo "orphans=${ORPHANS[*]:-}" >> "$GITHUB_OUTPUT" - - - name: Tear down orphans - if: steps.orphans.outputs.orphans != '' - env: - ORPHANS: ${{ steps.orphans.outputs.orphans }} - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - envs: ORPHANS - script: | - set -euo pipefail - cd /opt/trails-cool - IFS=, read -ra PRS <<< "$ORPHANS" - for PR in "${PRS[@]}"; do - [ -z "$PR" ] && continue - PROJECT="trails-pr-$PR" - DB="trails_pr_$PR" - echo "→ tearing down $PROJECT" - # `down` needs the same env file the deploy used; staging.env on - # disk may belong to a different PR, so synthesize a minimal one. - cat > /tmp/cleanup.env < -# -# 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 450df80..58a8d39 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,8 @@ dist/ .crit.json e2e/results/ test-results/ -.env.development playwright-report/ playwright-results.json .claude/worktrees/ .claude/settings.local.json .claude/scheduled_tasks.lock -docs/reviews/internal/ diff --git a/CLAUDE.md b/CLAUDE.md index 2109593..f4be011 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,7 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with Full architecture: `docs/architecture.md` Philosophy: `docs/philosophy.md` -Roadmap: `docs/roadmap.md` -Ideas (pre-spec explorations): `docs/ideas/` -OpenSpec changes: `openspec/changes/` +OpenSpec change: `openspec/changes/phase-1-mvp/` ## Principles @@ -27,7 +25,7 @@ OpenSpec changes: `openspec/changes/` - **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack) - **Maps**: Leaflet + OpenStreetMap tiles - **CRDT**: Yjs + y-websocket (Planner only) -- **Federation**: Fedify (Journal only) +- **Federation**: Fedify (Journal only, Phase 2) - **Database**: PostgreSQL + PostGIS - **Media storage**: S3-compatible (Garage) - **Routing engine**: BRouter (Java, runs as separate Docker container) @@ -41,15 +39,11 @@ apps/ planner/ — Planner app (React Router 7) journal/ — Journal app (React Router 7 + Fedify) packages/ - types/ — Shared wire types both apps exchange (Waypoint) - map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side + types/ — Shared TypeScript interfaces (Route, Activity, Waypoint) + ui/ — Shared React components (Tailwind) + map/ — Leaflet map wrappers and tile layer configs gpx/ — GPX parsing, generation, validation - fit/ — FIT file generation (Wahoo route push) i18n/ — react-i18next config + translations - api/ — Shared API contracts (endpoints, pagination, error types, versioning) - db/ — Drizzle schema, database client, migration helpers - jobs/ — pg-boss setup, worker, and background job types - sentry-config/ — Shared Sentry configuration infrastructure/ — Terraform + Docker Compose openspec/ — OpenSpec specs and changes docs/ — Architecture, philosophy, tooling docs @@ -123,8 +117,8 @@ local config symmetric. - **Route registration**: Both apps use explicit `routes.ts` (not file-based routing). When adding a new route file, you **must** add it to `apps/*/app/routes.ts` or it won't be compiled into the build. - All user-facing strings must use i18n (`useTranslation()` hook, never hardcode strings) -- Database row types are derived from the Drizzle schema (`@trails-cool/db`); API wire shapes are the Zod contracts in `@trails-cool/api`; only types both apps exchange (e.g. Waypoint) live in `@trails-cool/types` -- Map constants (colors, tiles, POI categories, z-indexes) go in `@trails-cool/map-core`; React/Leaflet map components live in the app that uses them +- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions +- Map components go in `@trails-cool/map`, not in individual apps - GPX parsing/generation goes in `@trails-cool/gpx` - Database schemas: `planner.*` for Planner data, `journal.*` for Journal data - Route geometry must be stored as PostGIS LineString (extracted from GPX on save) @@ -183,15 +177,13 @@ Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a ## Deployment -Five CD workflows triggered by path or event: +Three separate CD workflows triggered by path: | Workflow | Triggers on | Deploys | Target | |----------|-------------|---------|--------| | `cd-apps.yml` | `apps/`, `packages/`, `pnpm-lock.yaml` | journal, planner | flagship (`root@trails.cool`) | | `cd-infra.yml` | `infrastructure/` (except `brouter-host/**`) | caddy, postgres, prometheus, loki, grafana, exporters | flagship (`root@trails.cool`) | | `cd-brouter.yml` | `docker/brouter/`, `infrastructure/brouter-host/**` | brouter + caddy sidecar | dedicated (`trails@ullrich.is:2232`) | -| `cd-staging.yml` | main push or PR open/sync/close on `apps/`, `packages/` | persistent staging + per-PR previews | flagship (alongside production) | -| `staging-cleanup.yml` | weekly cron + manual | sweeps orphaned PR previews | flagship | ### Hosts @@ -223,49 +215,6 @@ ssh -i ~/.ssh/trails-brouter-deploy -p 2232 trails@ullrich.is ### Grafana `https://grafana.internal.trails.cool` — GitHub OAuth (trails-cool org) -### Staging & Previews - -A persistent staging stack and ephemeral PR previews share the flagship server with production. - -| Surface | URL | Database | Triggered by | -|---------|-----|----------|--------------| -| Persistent staging journal | `https://staging.trails.cool` | `trails_staging` | push to `main` | -| Persistent staging planner | `https://planner.staging.trails.cool` | `trails_staging` | push to `main` | -| PR preview journal | `https://pr-.staging.trails.cool` | `trails_pr_` | PR open/sync | - -PR previews are **journal-only** — their `PLANNER_URL` points at the persistent staging planner so we don't pay 256MB per preview for an extra planner. The persistent staging planner's CSP allows `connect-src wss://*.staging.trails.cool` so PR-preview journals can talk to it. - -**Port scheme** (host-published, reverse-proxied by Caddy via `host.docker.internal`): -- Persistent staging: journal `3110`, planner `3111` (3100 collides with Loki on the vSwitch interface) -- PR `` preview: journal `3200 + 2N`, planner `3201 + 2N` (planner unused for previews) - -**Compose project namespacing** keeps each preview isolated: -- Persistent staging: `-p trails-staging` -- PR ``: `-p trails-pr-` - -The shared file `infrastructure/docker-compose.staging.yml` covers both — env vars (`DOMAIN`, `STAGING_DATABASE`, `JOURNAL_HOST_PORT`, `JOURNAL_IMAGE_TAG`, …) parametrize per target. Persistent staging uses `--profile persistent` to also start the planner; PR previews omit the profile. - -**Caddy routing.** Persistent staging has fixed site blocks in `infrastructure/Caddyfile`. Per-PR site blocks are written by `cd-staging.yml` to `/opt/trails-cool/sites/pr-.caddyfile` (mounted into Caddy at `/etc/caddy/sites/`) and picked up via `import sites/*.caddyfile` on a Caddy reload. No on-demand TLS; standard automatic HTTPS issues a per-host cert. - -**Database isolation.** Each preview gets its own database on the production Postgres instance, schema applied via `drizzle-kit push --force`. Created on PR open, dropped on close. The persistent staging DB is never touched by previews. - -**Concurrent preview cap.** Max 3 concurrent PR previews. When a 4th opens, the deploy job evicts the oldest project before deploying. - -**Cleanup.** `cd-staging.yml`'s teardown job runs on PR close. `staging-cleanup.yml` runs weekly to catch orphans whose teardown never ran. - -**Debugging.** SSH to the flagship (`ssh -i ~/.ssh/trails-cool-deploy root@trails.cool`) and run `docker compose -f docker-compose.staging.yml -p trails-pr- logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up. - -## Agent Skills & Config - -Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink). - -``` -.agents/skills/ ← canonical location -.claude/skills ← symlink → ../.agents/skills -``` - -Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup. - ## OpenSpec Workflow Specs live in `openspec/`. Use these slash commands: diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index e48200e..0000000 --- a/CONTEXT.md +++ /dev/null @@ -1,198 +0,0 @@ -# trails.cool domain glossary - -This file names the domain concepts used in the codebase. New terms get added -here as decisions crystallize during architecture work; the goal is that one -concept has one name everywhere — specs, code, conversations. - -If you're naming a new module, a new column, or a new UI surface, look here -first. If the term you need isn't here, propose it (don't invent a synonym). - ---- - -## GPX Save - -The atomic unit of persisting spatial data in the Journal. Any write of a GPX track — whether a new route, an updated route, a new activity, or a route derived from an activity — goes through a single path that validates, writes the row, and writes the PostGIS geometry in one transaction. - -### gpx-save module -`apps/journal/app/lib/gpx-save.server.ts`. The sole owner of GPX validation and geometry persistence. Imported by `routes.server.ts`, `activities.server.ts`, and `demo-bot.server.ts`. Nothing else calls `setGeomFromGpx` directly. - -### GpxValidationError -Typed error thrown by `validateGpx` when the GPX string cannot produce a valid LineString. Conditions: fewer than 2 track points, or coordinates outside valid ranges (lat −90..90, lon −180..180). Callers catch this to return a user-facing 400. - -### validateGpx -`(gpx: string) → Promise`. Entry point of the gpx-save module. Parses the GPX string once and validates the result. Returns the `ParsedGpx` so callers can extract stats without re-parsing. Throws `GpxValidationError` on invalid input. Called at the start of `createRoute`, `updateRoute`, `createActivity`, and `createRouteFromActivity` — before any DB write. - -### atomic GPX save -The invariant: a route or activity row with a `gpx` column set **always** has a corresponding `geom` column set. Enforced by wrapping the row insert/update, the PostGIS geometry write, and the version snapshot in a single `db.transaction()`. A PostGIS failure rolls back the row write; partial state (row exists, geom NULL) is not possible through the normal save path. - ---- - -## Connected Services - -The user-facing surface for linking external accounts and devices to a Journal -account. Spec: `openspec/specs/connected-services/`. - -### ConnectedService -A single linked external account or device, owned by one user. Stored in the -`connected_services` table (renamed from `sync_connections`). At most one row -per `(user_id, provider)`. - -### provider -String identifier for the external system: `wahoo`, `komoot`, `apple-health`, -and future `coros`, `garmin`, `strava`. The provider determines the -`credential_kind` and which capabilities (import / push / webhook) the -connection has, via the provider's manifest. - -### credential kind -Discriminator on `connected_services` describing the credential shape stored -in the `credentials` JSONB blob. Three kinds today: - -- **oauth** — OAuth2 access token, refresh token, expiry. Wahoo, and the - expected shape for Coros / Garmin / Strava. -- **web-login** — email + encrypted password + session jar. Komoot. No - official API; we authenticate against the provider's normal web login and - reuse the resulting session cookies. Refresh = re-login. Web-login - breakage (form changes, captchas, password rotation) surfaces at the - import layer, not at the credential layer. -- **device** — no remote credential. Apple Health (and future Health Connect - on Android). Data arrives via authenticated mobile API uploads, not - server-initiated pulls. The `credentials` blob is empty; the connection - exists so the UI can show "Apple Health is paired." - -Credential kind is determined by the provider via its manifest, but stored -explicitly on the row so queries don't need to join the manifest. - -### granted_scopes -Column on `connected_services`, populated only for `credential_kind = oauth`, -NULL otherwise. Lists the OAuth scopes the user actually granted (e.g. -`routes_write`). Feature gates query this column directly; missing a scope -triggers re-authorization. - -### provider_user_id -The external service's identifier for the user. Used to route incoming -webhooks to the right local user. Nullable (Apple Health has none in the -remote-id sense). - -### CredentialAdapter -Per-kind module that knows how to maintain credentials of that kind: - -- `oauth.refresh(creds) → creds | NeedsRelink` -- `web-login.relogin(creds) → creds | InvalidCredentials` -- `device` — no-op - -Adapters do not import data, push routes, or handle webhooks. They only own -the credential lifecycle. - -### ConnectedServiceManager -The deep module callers see. Owns: - -- `link(userId, provider, credentials)` / `unlink(serviceId)` -- `withFreshCredentials(serviceId, fn)` — refreshes via the right - `CredentialAdapter` if expired, calls `fn(creds)`, marks the connection - `needs_relink` if refresh fails. -- `markNeedsRelink(serviceId, reason)` — called by import / push / webhook - layers when they observe a credential failure (e.g. Komoot web-login - fails, Wahoo returns 401 after a successful refresh). - -Per-provider importers / pushers / webhook handlers always go through -`withFreshCredentials` — they never read the `credentials` JSONB directly. - -### provider manifest -Per-provider declaration co-located with the provider's code -(`providers/wahoo/manifest.ts`, etc.). Declares: - -- the provider's `credential_kind` -- which capabilities the provider implements (import? push? webhook?) -- references to the per-capability modules - -A small `providers/registry.ts` imports each manifest. Adding a provider is -one new directory plus one import line. - -## Sync Capabilities - -Three orthogonal capabilities a provider may implement. Each is its own seam -when there are ≥2 adapters; today most are single-adapter and held to a -named shape so the second adapter doesn't reshape the interface. - -### Importer -Pulls workouts / activities from the external service into the Journal. -Wahoo (OAuth pull), Komoot (web-login pull), Apple Health (mobile-pushed) all -implement this, with very different mechanics. Dedup via `sync_imports`. - -### RoutePusher -Pushes a Journal route out to the external service. One adapter today -(Wahoo); the seam exists so Coros / Garmin / Strava push won't reshape it. - -The public seam is `pushRoute(connectedService, route) → {remoteId, version}`. -Provider-specific concerns — FIT Course conversion, the `route:` -`external_id` convention, the PUT→POST-on-404 fallback — are **handled -internally by the adapter**, not exposed on the seam. Idempotency is tracked -via `sync_pushes`. - -### WebhookReceiver -Handles inbound webhooks. Today: Wahoo workout-published. Routes incoming -webhooks to the right user via `provider_user_id`. Unknown -`provider_user_id` returns 200 and is silently dropped (no leak). - -## Storage tables - -- `connected_services` — user ↔ provider links (renamed from - `sync_connections`). -- `sync_imports` — dedup cache for imported workouts, keyed by - `(user_id, provider, workout_id)`. -- `sync_pushes` — push state per `(user_id, route_id, provider)` → - `remote_id`, `last_pushed_version`. - ---- - -## Authentication - -User identity in the Journal. Two **authentication methods** are supported -and are intentionally the entire surface (see ADR-0005): **passkey** -(WebAuthn) as the preferred method and **magic-link + 6-digit code** -(email) as a fallback for users without passkey support or for cross-device -sign-in. There is no plan for social sign-in (Google/Apple/etc.) — passkeys -already deliver the one-tap UX, and adding centralized identity providers -would conflict with the privacy-first ethos and ActivityPub federation. - -OAuth2/PKCE (the `mobile-app` flow) is **not** a third authentication -method. It is a **session transport** for native clients: users still -authenticate via passkey or magic-link in a WebView, then the mobile app -exchanges the resulting authorization code for long-lived bearer tokens. -The peer of OAuth2 transport is the cookie session, not passkey or -magic-link. - -### completeAuth -The single chokepoint for the post-verify orchestration of every web -auth flow. Lives at `apps/journal/app/lib/auth/completion.ts` (see -ADR-0004). Called by every route handler that has just verified a -user's identity (passkey login finish, magic-link code verify, magic -link consumer). Does three things in order: - -1. If `isNewRegistration`, records the accepted Terms version - (`recordTermsAcceptance`). -2. Creates the session cookie via `createSession`. -3. Returns `redirect(returnTo ?? "/")` with the session `Set-Cookie` - header attached. - -Identity-method-specific work (WebAuthn ceremony verification, magic -token consumption) stays in the per-method functions and runs *before* -`completeAuth`. The chokepoint deliberately knows nothing about how -identity was proved. - -### Terms gate -Cross-cutting middleware enforcing that `users.terms_version` matches -the current `TERMS_VERSION` constant before any non-allow-listed -authenticated request succeeds. Two enforcement points: - -- **Web (cookie sessions)**: the root loader redirects stale-terms users - to `/auth/accept-terms`. Allow-list: `/auth/accept-terms`, - `/auth/logout`, `/legal/*`. `/oauth/authorize` is *not* on the - allow-list, so OAuth code issuance is gated by this same redirect - before mobile sees an authorization code. -- **API (bearer tokens)**: `requireApiUser` returns - `403 { code: "TERMS_OUTDATED", currentTermsVersion }` for stale-terms - bearer-token traffic. (Added in `mobile-terms-gate`, 2026-05-08.) - -`completeAuth` only **records** terms on registration; it does not -enforce them. Enforcement remains middleware's job. diff --git a/FEDERATION.md b/FEDERATION.md deleted file mode 100644 index aa4b88f..0000000 --- a/FEDERATION.md +++ /dev/null @@ -1,198 +0,0 @@ -# Federation protocol - -trails.cool's Journal federates over [ActivityPub](https://www.w3.org/TR/activitypub/), -implemented with [Fedify](https://fedify.dev). This document describes the -wire protocol precisely enough for another implementation to interoperate -deliberately — actor discovery, the object and activity types we emit and -accept, addressing, signatures, deduplication, delivery retry, and -moderation. Examples use `trails.example` for our instance and -`remote.example` for a peer. - -Federation is per-instance opt-in (`FEDERATION_ENABLED`). When it is off, -every federation surface returns 404 — a disabled instance is -indistinguishable from one without the feature. Only users with -`profile_visibility = 'public'` federate; a private user's actor, -WebFinger, inbox, and outbox all 404, so their existence never leaks. - -## Actor discovery - -### WebFinger - -`GET /.well-known/webfinger?resource=acct:alice@trails.example` resolves a -handle to an actor IRI: - -```json -{ - "subject": "acct:alice@trails.example", - "links": [ - { "rel": "self", "type": "application/activity+json", "href": "https://trails.example/users/alice" } - ] -} -``` - -### Actor - -`GET https://trails.example/users/alice` with `Accept: application/activity+json` -returns a `Person`. The actor IRI and the human profile `url` are the same -by design (browsers get HTML at that URL via content negotiation): - -```json -{ - "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], - "id": "https://trails.example/users/alice", - "type": "Person", - "preferredUsername": "alice", - "name": "Alice", - "summary": "trail runner", - "url": "https://trails.example/users/alice", - "inbox": "https://trails.example/users/alice/inbox", - "outbox": "https://trails.example/users/alice/outbox", - "publicKey": { - "id": "https://trails.example/users/alice#main-key", - "owner": "https://trails.example/users/alice", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n" - }, - "assertionMethod": [ { "type": "Multikey", "…": "…" } ], - "attachment": [ - { "type": "PropertyValue", "name": "🥾 trails.cool", "value": "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 1bc4215..36f7493 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,6 @@ docker compose up -d See [docs/architecture.md](docs/architecture.md) for details on self-hosting configuration. -## Federation - -The Journal federates over ActivityPub. The wire protocol — actor -discovery, object/activity types with JSON examples, addressing, -signatures, deduplication, delivery retry, and moderation — is documented -in [FEDERATION.md](FEDERATION.md), which is precise enough for another -implementation to interoperate against. - ## Philosophy - **Privacy by design** — The Planner collects zero user data diff --git a/apps/journal/.env.example b/apps/journal/.env.example index 8a51ed0..ef8718a 100644 --- a/apps/journal/.env.example +++ b/apps/journal/.env.example @@ -49,14 +49,6 @@ # 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 306948b..585803c 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-slim AS base +FROM node:25-slim AS base # curl is used by the docker-compose healthcheck (node:25-slim is Debian # trixie-slim and ships neither curl nor wget by default). RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* @@ -9,12 +9,13 @@ 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/ @@ -22,16 +23,11 @@ RUN pnpm install --frozen-lockfile FROM base AS build ARG SENTRY_RELEASE -# Client-side Sentry DSN baked into the bundle at build time. Empty (or -# unset) produces a Sentry-free client. Public-by-design: the DSN -# appears in the shipped client JS regardless. -ARG VITE_SENTRY_DSN="" COPY --from=deps /app/ ./ COPY . . RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \ SENTRY_RELEASE="$SENTRY_RELEASE" \ - VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \ pnpm --filter @trails-cool/journal build FROM base AS runtime @@ -40,7 +36,6 @@ COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules COPY --from=build /app/apps/journal/build ./apps/journal/build COPY --from=build /app/apps/journal/server.ts ./apps/journal/server.ts -COPY --from=build /app/apps/journal/serve-static.ts ./apps/journal/serve-static.ts COPY --from=build /app/apps/journal/app/lib ./apps/journal/app/lib COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json diff --git a/apps/journal/app/components/CollectionPage.tsx b/apps/journal/app/components/CollectionPage.tsx index 2632b44..08c8bab 100644 --- a/apps/journal/app/components/CollectionPage.tsx +++ b/apps/journal/app/components/CollectionPage.tsx @@ -4,9 +4,6 @@ interface Entry { username: string; displayName: string | null; domain: string; - /** Local path (`/users/x`) or, for federated entries, the remote profile URL. */ - profileUrl: string; - remote: boolean; } interface Props { @@ -45,10 +42,9 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) { ) : ( -
-

- 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

    diff --git a/apps/journal/app/routes/notifications.server.ts b/apps/journal/app/routes/notifications.server.ts deleted file mode 100644 index ed47ef6..0000000 --- a/apps/journal/app/routes/notifications.server.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Server-only loader for /notifications. See `home.server.ts`. - -import { redirect } from "react-router"; -import { inArray, eq, and } from "drizzle-orm"; -import { getDb } from "~/lib/db"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { listForUser } from "~/lib/notifications.server"; -import { linkFor } from "~/lib/notifications/link-for"; -import { readPayload } from "~/lib/notifications/payload"; -import { - countPendingFollowRequests, - listPendingFollowRequests, -} from "~/lib/follow.server"; -import { activities } from "@trails-cool/db/schema/journal"; - -type Tab = "activity" | "requests"; - -export interface NotificationRow { - id: string; - type: string; - readAt: string | null; - createdAt: string; - link: string; - payload: Record | null; -} - -export interface RequestRow { - id: string; - followerUsername: string; - followerDisplayName: string | null; - followerDomain: string; - createdAt: string; -} - -export async function loadNotifications(request: Request) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - - const url = new URL(request.url); - const tab: Tab = url.searchParams.get("tab") === "requests" ? "requests" : "activity"; - - // Pending count drives the Requests tab dot regardless of which tab is - // currently active, so we always fetch it. It's a single COUNT(*) query. - const pendingCount = await countPendingFollowRequests(user.id); - - if (tab === "requests") { - const requests = await listPendingFollowRequests(user.id); - return { - tab: "requests" as const, - pendingCount, - requests: requests.map((r) => ({ - id: r.id, - followerUsername: r.followerUsername, - followerDisplayName: r.followerDisplayName, - followerDomain: r.followerDomain, - createdAt: r.createdAt.toISOString(), - })), - notifications: [] as NotificationRow[], - nextCursor: null as string | null, - }; - } - - const before = url.searchParams.get("before") ?? undefined; - const { rows, nextCursor } = await listForUser(user.id, { before }); - - // Renderer guard: drop activity_published rows whose subject is gone - // or no longer public (visibility flipped from public → private/unlisted). - // Fetch the still-public subject IDs in one query, then filter. - const activitySubjectIds = rows - .filter((r) => r.type === "activity_published" && r.subjectId) - .map((r) => r.subjectId as string); - let publicActivityIds = new Set(); - if (activitySubjectIds.length > 0) { - const db = getDb(); - const visible = await db - .select({ id: activities.id }) - .from(activities) - .where(and(inArray(activities.id, activitySubjectIds), eq(activities.visibility, "public"))); - publicActivityIds = new Set(visible.map((v) => v.id)); - } - - const visibleRows = rows.filter((r) => { - if (r.type !== "activity_published") return true; - if (!r.subjectId) return false; - return publicActivityIds.has(r.subjectId); - }); - - return { - tab: "activity" as const, - pendingCount, - requests: [] as RequestRow[], - notifications: visibleRows.map((r) => { - const link = linkFor({ - type: r.type, - subjectId: r.subjectId, - payload: r.payload, - payloadVersion: r.payloadVersion, - }); - const payload = readPayload(r.type, r.payloadVersion, r.payload); - return { - id: r.id, - type: r.type, - readAt: r.readAt?.toISOString() ?? null, - createdAt: r.createdAt.toISOString(), - link: link.web, - payload, - }; - }), - nextCursor, - }; -} diff --git a/apps/journal/app/routes/notifications.tsx b/apps/journal/app/routes/notifications.tsx index 13c567a..5306d16 100644 --- a/apps/journal/app/routes/notifications.tsx +++ b/apps/journal/app/routes/notifications.tsx @@ -1,12 +1,98 @@ -import { data, useFetcher } from "react-router"; +import { data, redirect, useFetcher } from "react-router"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; +import { inArray, eq, and } from "drizzle-orm"; import type { Route } from "./+types/notifications"; +import { getDb } from "~/lib/db"; +import { getSessionUser } from "~/lib/auth.server"; +import { listForUser } from "~/lib/notifications.server"; +import { linkFor } from "~/lib/notifications/link-for"; +import { readPayload } from "~/lib/notifications/payload"; +import { + countPendingFollowRequests, + listPendingFollowRequests, +} from "~/lib/follow.server"; import { ClientDate } from "~/components/ClientDate"; -import { loadNotifications } from "./notifications.server"; +import { activities } from "@trails-cool/db/schema/journal"; + +type Tab = "activity" | "requests"; export async function loader({ request }: Route.LoaderArgs) { - return data(await loadNotifications(request)); + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const url = new URL(request.url); + const tab: Tab = url.searchParams.get("tab") === "requests" ? "requests" : "activity"; + + // Pending count drives the Requests tab dot regardless of which tab is + // currently active, so we always fetch it. It's a single COUNT(*) query. + const pendingCount = await countPendingFollowRequests(user.id); + + if (tab === "requests") { + const requests = await listPendingFollowRequests(user.id); + return data({ + tab: "requests" as const, + pendingCount, + requests: requests.map((r) => ({ + id: r.id, + followerUsername: r.followerUsername, + followerDisplayName: r.followerDisplayName, + followerDomain: r.followerDomain, + createdAt: r.createdAt.toISOString(), + })), + notifications: [] as NotificationRow[], + nextCursor: null as string | null, + }); + } + + const before = url.searchParams.get("before") ?? undefined; + const { rows, nextCursor } = await listForUser(user.id, { before }); + + // Renderer guard: drop activity_published rows whose subject is gone + // or no longer public (visibility flipped from public → private/unlisted). + // Fetch the still-public subject IDs in one query, then filter. + const activitySubjectIds = rows + .filter((r) => r.type === "activity_published" && r.subjectId) + .map((r) => r.subjectId as string); + let publicActivityIds = new Set(); + if (activitySubjectIds.length > 0) { + const db = getDb(); + const visible = await db + .select({ id: activities.id }) + .from(activities) + .where(and(inArray(activities.id, activitySubjectIds), eq(activities.visibility, "public"))); + publicActivityIds = new Set(visible.map((v) => v.id)); + } + + const visibleRows = rows.filter((r) => { + if (r.type !== "activity_published") return true; + if (!r.subjectId) return false; + return publicActivityIds.has(r.subjectId); + }); + + return data({ + tab: "activity" as const, + pendingCount, + requests: [] as RequestRow[], + notifications: visibleRows.map((r) => { + const link = linkFor({ + type: r.type, + subjectId: r.subjectId, + payload: r.payload, + payloadVersion: r.payloadVersion, + }); + const payload = readPayload(r.type, r.payloadVersion, r.payload); + return { + id: r.id, + type: r.type, + readAt: r.readAt?.toISOString() ?? null, + createdAt: r.createdAt.toISOString(), + link: link.web, + payload, + }; + }), + nextCursor, + }); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/oauth.authorize.tsx b/apps/journal/app/routes/oauth.authorize.tsx index fa918f0..91342d9 100644 --- a/apps/journal/app/routes/oauth.authorize.tsx +++ b/apps/journal/app/routes/oauth.authorize.tsx @@ -1,6 +1,6 @@ import type { Route } from "./+types/oauth.authorize"; import { redirect } from "react-router"; -import { getSessionUser } from "../lib/auth/session.server.ts"; +import { getSessionUser } from "../lib/auth.server.ts"; import { getOAuthClient, validateRedirectUri, diff --git a/apps/journal/app/routes/routes.$id.edit.server.ts b/apps/journal/app/routes/routes.$id.edit.server.ts deleted file mode 100644 index 379438c..0000000 --- a/apps/journal/app/routes/routes.$id.edit.server.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Server-only loader/action for /routes/:id/edit. See `home.server.ts`. - -import { redirect } from "react-router"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { updateRoute } from "~/lib/routes.server"; -import { requireOwnedRoute } from "~/lib/ownership.server"; -import type { Visibility } from "@trails-cool/db/schema/journal"; - -const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); - -export async function loadRouteEdit(request: Request, id: string | undefined) { - const user = await requireSessionUser(request); - - const route = await requireOwnedRoute(id ?? "", user.id, { notOwnerStatus: 403 }); - - return { - route: { - id: route.id, - name: route.name, - description: route.description, - visibility: route.visibility, - }, - }; -} - -export async function routeEditAction(request: Request, id: string | undefined) { - const user = await requireSessionUser(request); - const routeId = id ?? ""; - - const formData = await request.formData(); - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const gpxFile = formData.get("gpx") as File | null; - const visibilityRaw = formData.get("visibility") as string | null; - - const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {}; - if (name) input.name = name; - if (description !== null) input.description = description; - if (gpxFile && gpxFile.size > 0) { - input.gpx = await gpxFile.text(); - } - if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) { - input.visibility = visibilityRaw as Visibility; - } - - const route = await requireOwnedRoute(routeId, user.id, { notOwnerStatus: 403 }); - await updateRoute(route, input); - return redirect(`/routes/${routeId}`); -} diff --git a/apps/journal/app/routes/routes.$id.edit.tsx b/apps/journal/app/routes/routes.$id.edit.tsx index 7ab1bc0..e1fe2e9 100644 --- a/apps/journal/app/routes/routes.$id.edit.tsx +++ b/apps/journal/app/routes/routes.$id.edit.tsx @@ -1,14 +1,52 @@ -import { data } from "react-router"; +import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id.edit"; -import { loadRouteEdit, routeEditAction } from "./routes.$id.edit.server"; +import { getSessionUser } from "~/lib/auth.server"; +import { getRoute, updateRoute } from "~/lib/routes.server"; +import type { Visibility } from "@trails-cool/db/schema/journal"; + +const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); export async function loader({ params, request }: Route.LoaderArgs) { - return data(await loadRouteEdit(request, params.id)); + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const route = await getRoute(params.id); + if (!route) throw data({ error: "Route not found" }, { status: 404 }); + if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 }); + + return data({ + route: { + id: route.id, + name: route.name, + description: route.description, + visibility: route.visibility, + }, + }); } export async function action({ params, request }: Route.ActionArgs) { - return await routeEditAction(request, params.id); + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const formData = await request.formData(); + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + const visibilityRaw = formData.get("visibility") as string | null; + + const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {}; + if (name) input.name = name; + if (description !== null) input.description = description; + if (gpxFile && gpxFile.size > 0) { + input.gpx = await gpxFile.text(); + } + if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) { + input.visibility = visibilityRaw as Visibility; + } + + await updateRoute(params.id, user.id, input); + return redirect(`/routes/${params.id}`); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/routes.$id.server.ts b/apps/journal/app/routes/routes.$id.server.ts deleted file mode 100644 index 27f0b3b..0000000 --- a/apps/journal/app/routes/routes.$id.server.ts +++ /dev/null @@ -1,191 +0,0 @@ -// Server-only loader/action for /routes/:id. See `home.server.ts`. - -import { data, redirect } from "react-router"; -import { and, eq } from "drizzle-orm"; -import { canView } from "~/lib/auth.server"; -import { getSessionUser, requireSessionUser } from "~/lib/auth/session.server"; -import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; -import { requireOwnedRoute } from "~/lib/ownership.server"; -import { getDb } from "~/lib/db"; -import { syncPushes } from "@trails-cool/db/schema/journal"; -import { getService } from "~/lib/connected-services"; -import { computeDays, parseGpxAsync, elevationSeries } from "@trails-cool/gpx"; -import type { ElevationSample } from "@trails-cool/gpx"; -import { enqueueOptional } from "~/lib/boss.server"; - -export async function loadRouteDetail(request: Request, id: string | undefined) { - const routeId = id ?? ""; - const [routeWithVersions, routeWithGeojson] = await Promise.all([ - getRouteWithVersions(routeId), - getRoute(routeId), - ]); - if (!routeWithVersions) throw data({ error: "Route not found" }, { status: 404 }); - const route = routeWithVersions; - - const user = await getSessionUser(request); - const isOwner = user?.id === route.ownerId; - - // Visibility gate: public always renders, unlisted renders on direct link, - // private requires ownership. Return 404 (not 403) to avoid leaking existence. - if (!canView(route, user, { asDirectLink: true })) { - throw data({ error: "Route not found" }, { status: 404 }); - } - - // Lazy surface backfill for the owner's own routes that lack a breakdown - // (non-Planner routes — Planner saves provide it synchronously). Owner-gated; - // idempotent via singletonKey. The SSE event fills the bars in live. - if (isOwner && routeWithGeojson?.geojson && !route.surfaceBreakdown) { - await enqueueOptional( - "surface-backfill", - { kind: "route", id: route.id }, - { source: "route-detail" }, - { singletonKey: `surface:route:${route.id}` }, - ); - } - - // Parse GPX once for day stats and waypoint POI data - let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = []; - let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record }> = []; - let elevation: ElevationSample[] = []; - if (route.gpx) { - try { - const gpxData = await parseGpxAsync(route.gpx); - elevation = elevationSeries(gpxData.tracks); - waypoints = gpxData.waypoints.map((w) => ({ - lat: w.lat, - lon: w.lon, - name: w.name, - isDayBreak: w.isDayBreak, - note: w.note, - osmId: w.osmId, - poiTags: w.poiTags as Record | undefined, - })); - if (route.dayBreaks && route.dayBreaks.length > 0) { - dayStats = computeDays(gpxData.waypoints, gpxData.tracks); - } - } catch { - // Fall back to empty - } - } - - const currentVersion = route.versions[0]?.version ?? 1; - - // Wahoo push state — only meaningful for the owner. The single - // sync_pushes row per (user, route, wahoo) carries `lastPushedVersion`, - // which we compare against the current local version to render one of - // three states: matches, local newer, or last attempt failed. - let wahooPush: - | { - canPush: boolean; - needsReauth: boolean; - currentVersion: number; - latest: { - pushedAt: string | null; - remoteId: string | null; - lastPushedVersion: number | null; - error: string | null; - } | null; - } - | null = null; - if (isOwner && user && !!route.gpx) { - const connection = await getService(user.id, "wahoo"); - let latest: - | { - pushedAt: string | null; - remoteId: string | null; - lastPushedVersion: number | null; - error: string | null; - } - | null = null; - if (connection) { - const db = getDb(); - const [row] = await db - .select() - .from(syncPushes) - .where( - and( - eq(syncPushes.userId, user.id), - eq(syncPushes.routeId, route.id), - eq(syncPushes.provider, "wahoo"), - ), - ) - .limit(1); - latest = row - ? { - pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null, - remoteId: row.remoteId, - lastPushedVersion: row.lastPushedVersion, - error: row.error, - } - : null; - } - wahooPush = { - canPush: !!connection, - needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"), - currentVersion, - latest, - }; - } - - return { - route: { - id: route.id, - name: route.name, - description: route.description, - distance: route.distance, - elevationGain: route.elevationGain, - elevationLoss: route.elevationLoss, - routingProfile: route.routingProfile, - hasGpx: !!route.gpx, - dayBreaks: route.dayBreaks ?? [], - elevation, - surfaceBreakdown: route.surfaceBreakdown ?? null, - geojson: routeWithGeojson?.geojson ?? null, - visibility: route.visibility, - createdAt: route.createdAt.toISOString(), - updatedAt: route.updatedAt.toISOString(), - }, - dayStats, - waypoints, - versions: route.versions.map((v) => ({ - version: v.version, - changeDescription: v.changeDescription, - createdAt: v.createdAt.toISOString(), - })), - isOwner, - wahooPush, - }; -} - -export async function routeDetailAction(request: Request, id: string | undefined) { - const user = await requireSessionUser(request); - const routeId = id ?? ""; - - const formData = await request.formData(); - const intent = formData.get("intent"); - - if (intent === "delete") { - const route = await requireOwnedRoute(routeId, user.id); - await deleteRoute(route); - return redirect("/routes"); - } - - if (intent === "update") { - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const gpxFile = formData.get("gpx") as File | null; - - const input: Record = {}; - if (name) input.name = name; - if (description !== null) input.description = description; - if (gpxFile && gpxFile.size > 0) { - input.gpx = await gpxFile.text(); - } - - const route = await requireOwnedRoute(routeId, user.id); - await updateRoute(route, input as { name?: string; description?: string; gpx?: string }); - return redirect(`/routes/${routeId}`); - } - - return data({ error: "Unknown action" }, { status: 400 }); -} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index b45a02d..1683de9 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -1,22 +1,150 @@ -import { useState, useCallback, useMemo } from "react"; -import { data } from "react-router"; +import { useState, useCallback } from "react"; +import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id"; +import { and, desc, eq } from "drizzle-orm"; +import { canView, getSessionUser } from "~/lib/auth.server"; +import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; +import { getDb } from "~/lib/db"; +import { syncPushes } from "@trails-cool/db/schema/journal"; +import { getConnection } from "~/lib/sync/connections.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; -import { StatRow } from "~/components/StatRow"; -import { ElevationProfile } from "~/components/ElevationProfile"; -import { SurfaceBreakdown } from "~/components/SurfaceBreakdown"; -import { useSurfaceBackfillUpdates } from "~/hooks/useSurfaceBackfill"; -import { activityStatItems } from "~/lib/stats"; -import { loadRouteDetail, routeDetailAction } from "./routes.$id.server"; + export async function loader({ params, request }: Route.LoaderArgs) { - return data(await loadRouteDetail(request, params.id)); + const [routeWithVersions, routeWithGeojson] = await Promise.all([ + getRouteWithVersions(params.id), + getRoute(params.id), + ]); + if (!routeWithVersions) throw data({ error: "Route not found" }, { status: 404 }); + const route = routeWithVersions; + + const user = await getSessionUser(request); + const isOwner = user?.id === route.ownerId; + + // Visibility gate: public always renders, unlisted renders on direct link, + // private requires ownership. Return 404 (not 403) to avoid leaking existence. + if (!canView(route, user, { asDirectLink: true })) { + throw data({ error: "Route not found" }, { status: 404 }); + } + + // Compute per-day stats if route has day breaks and GPX + let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = []; + if (route.dayBreaks && route.dayBreaks.length > 0 && route.gpx) { + try { + const { computeDays } = await import("@trails-cool/gpx"); + const { parseGpxAsync } = await import("@trails-cool/gpx"); + const gpxData = await parseGpxAsync(route.gpx); + dayStats = computeDays(gpxData.waypoints, gpxData.tracks); + } catch { + // Fall back to no day stats + } + } + + const currentVersion = route.versions[0]?.version ?? 1; + + // Wahoo push state — only meaningful for the owner. Includes the latest + // sync_pushes row for the current version (if any) and whether the user + // has a Wahoo connection with the routes_write scope. + let wahooPush: + | { + canPush: boolean; + needsReauth: boolean; + latest: { pushedAt: string | null; remoteId: string | null; error: string | null } | null; + } + | null = null; + if (isOwner && user && !!route.gpx) { + const connection = await getConnection(user.id, "wahoo"); + let latest: + | { pushedAt: string | null; remoteId: string | null; error: string | null } + | null = null; + if (connection) { + const db = getDb(); + const [row] = await db + .select() + .from(syncPushes) + .where( + and( + eq(syncPushes.userId, user.id), + eq(syncPushes.routeId, route.id), + eq(syncPushes.routeVersion, currentVersion), + eq(syncPushes.provider, "wahoo"), + ), + ) + .orderBy(desc(syncPushes.createdAt)) + .limit(1); + latest = row + ? { + pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null, + remoteId: row.remoteId, + error: row.error, + } + : null; + } + wahooPush = { + canPush: !!connection, + needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"), + latest, + }; + } + + return data({ + route: { + id: route.id, + name: route.name, + description: route.description, + distance: route.distance, + elevationGain: route.elevationGain, + elevationLoss: route.elevationLoss, + routingProfile: route.routingProfile, + hasGpx: !!route.gpx, + dayBreaks: route.dayBreaks ?? [], + geojson: routeWithGeojson?.geojson ?? null, + visibility: route.visibility, + createdAt: route.createdAt.toISOString(), + updatedAt: route.updatedAt.toISOString(), + }, + dayStats, + versions: route.versions.map((v) => ({ + version: v.version, + changeDescription: v.changeDescription, + createdAt: v.createdAt.toISOString(), + })), + isOwner, + wahooPush, + }); } export async function action({ params, request }: Route.ActionArgs) { - return routeDetailAction(request, params.id); + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "delete") { + await deleteRoute(params.id, user.id); + return redirect("/routes"); + } + + if (intent === "update") { + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + + const input: Record = {}; + if (name) input.name = name; + if (description !== null) input.description = description; + if (gpxFile && gpxFile.size > 0) { + input.gpx = await gpxFile.text(); + } + + await updateRoute(params.id, user.id, input as { name?: string; description?: string; gpx?: string }); + return redirect(`/routes/${params.id}`); + } + + return data({ error: "Unknown action" }, { status: 400 }); } export function meta({ data: loaderData }: Route.MetaArgs) { @@ -46,33 +174,11 @@ export function meta({ data: loaderData }: Route.MetaArgs) { } export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { - const { route, dayStats, waypoints, versions, isOwner, wahooPush } = loaderData; + const { route, dayStats, versions, isOwner, wahooPush } = loaderData; const { t, i18n } = useTranslation("journal"); - - // Live-update when the async surface backfill lands (owner-only). - useSurfaceBackfillUpdates("route", route.id, isOwner && !route.surfaceBreakdown); - const [editLoading, setEditLoading] = useState(false); const [highlightedDay, setHighlightedDay] = useState(null); - // Elevation profile ↔ map sync via a shared "active" sample index. - const elevation = route.elevation; - const [activeIndex, setActiveIndex] = useState(null); - const [centerOn, setCenterOn] = useState<{ lat: number; lng: number; v: number } | null>(null); - const hoverSeries = useMemo( - () => elevation.map((s) => [s.lat, s.lng] as [number, number]), - [elevation], - ); - const activePoint = - activeIndex != null && elevation[activeIndex] - ? { lat: elevation[activeIndex]!.lat, lng: elevation[activeIndex]!.lng } - : null; - const seekElevation = (i: number) => { - const s = elevation[i]; - if (s) setCenterOn((prev) => ({ lat: s.lat, lng: s.lng, v: (prev?.v ?? 0) + 1 })); - setActiveIndex(i); - }; - const pushStatus = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("push") : null; @@ -131,49 +237,33 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { {t("routes.exportGpx")} )} - {wahooPush?.canPush && (() => { - const latest = wahooPush.latest; - const matches = - latest?.pushedAt && - latest.lastPushedVersion === wahooPush.currentVersion; - const localNewer = - latest?.pushedAt && - latest.lastPushedVersion != null && - latest.lastPushedVersion < wahooPush.currentVersion; - if (matches) { - return ( - - {t("routes.sentToWahoo", { - date: new Date(latest!.pushedAt!).toLocaleDateString(i18n.language), - })} - - ); - } - return ( + {wahooPush?.canPush && ( + wahooPush.latest?.pushedAt ? ( + + {t("routes.sentToWahoo", { + date: new Date(wahooPush.latest.pushedAt).toLocaleDateString(i18n.language), + })} + + ) : (
    - {localNewer && ( - - {t("routes.onWahooNewer", { n: latest!.lastPushedVersion })} - - )} - {latest?.error && ( + {wahooPush.latest?.error && ( - {t("routes.sendToWahooFailed", { error: latest.error })} + {t("routes.sendToWahooFailed", { error: wahooPush.latest.error })} )}
    - ); - })()} + ) + )} )} @@ -201,18 +291,28 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { )} - + {route.distance != null && ( +
    +

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

    +

    {t("routes.distance")}

    +
    )} - /> + {route.elevationGain != null && ( +
    +

    ↑ {route.elevationGain} m

    +

    Ascent

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

    ↓ {route.elevationLoss} m

    +

    Descent

    +
    + )} + {dayStats.length > 1 && (
    @@ -258,88 +358,12 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
    )} - {waypoints.some((w) => w.osmId || w.poiTags || w.note) && ( -
    -

    {t("routes.waypoints")}

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

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

      - {w.note && ( -

      {w.note}

      - )} - {w.poiTags && ( -
      - {w.poiTags.phone && ( -
      -
      {t("routes.poi.phone")}
      -
      {w.poiTags.phone}
      -
      - )} - {w.poiTags.website && ( -
      -
      {t("routes.poi.website")}
      -
      {w.poiTags.website}
      -
      - )} - {w.poiTags.opening_hours && ( -
      -
      {t("routes.poi.openingHours")}
      -
      {w.poiTags.opening_hours}
      -
      - )} - {(w.poiTags["addr:street"] || w.poiTags["addr:city"]) && ( -
      -
      {t("routes.poi.address")}
      -
      - {[ - w.poiTags["addr:street"] && `${w.poiTags["addr:street"]}${w.poiTags["addr:housenumber"] ? " " + w.poiTags["addr:housenumber"] : ""}`, - w.poiTags["addr:postcode"] && w.poiTags["addr:city"] - ? `${w.poiTags["addr:postcode"]} ${w.poiTags["addr:city"]}` - : w.poiTags["addr:city"], - ].filter(Boolean).join(", ")} -
      -
      - )} -
      - )} -
    • - ))} -
    -
    - )} - {route.geojson && (
    - 0 ? route.dayBreaks : undefined} - highlightedDay={highlightedDay} - activePoint={activePoint} - hoverSeries={hoverSeries} - onHoverIndex={setActiveIndex} - centerOn={centerOn} - /> + 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} />
    )} - {elevation.length > 1 && ( - - )} - - - {isEmpty && (
    diff --git a/apps/journal/app/routes/routes._index.server.ts b/apps/journal/app/routes/routes._index.server.ts deleted file mode 100644 index 55b1432..0000000 --- a/apps/journal/app/routes/routes._index.server.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Server-only loader for /routes index. See `home.server.ts`. - -import { requireSessionUser } from "~/lib/auth/session.server"; -import { listRoutes } from "~/lib/routes.server"; - -export async function loadRoutesIndex(request: Request) { - const user = await requireSessionUser(request); - - const userRoutes = await listRoutes(user.id); - return { - routes: userRoutes.map((r) => ({ - id: r.id, - name: r.name, - distance: r.distance, - elevationGain: r.elevationGain, - updatedAt: r.updatedAt.toISOString(), - geojson: r.geojson ?? null, - })), - }; -} diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx index dd67556..85511a6 100644 --- a/apps/journal/app/routes/routes._index.tsx +++ b/apps/journal/app/routes/routes._index.tsx @@ -1,12 +1,26 @@ -import { data } from "react-router"; +import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes._index"; +import { getSessionUser } from "~/lib/auth.server"; +import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; -import { loadRoutesIndex } from "./routes._index.server"; export async function loader({ request }: Route.LoaderArgs) { - return data(await loadRoutesIndex(request)); + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const userRoutes = await listRoutes(user.id); + return data({ + routes: userRoutes.map((r) => ({ + id: r.id, + name: r.name, + distance: r.distance, + elevationGain: r.elevationGain, + updatedAt: r.updatedAt.toISOString(), + geojson: r.geojson ?? null, + })), + }); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/routes.new.server.ts b/apps/journal/app/routes/routes.new.server.ts deleted file mode 100644 index cf6f9ac..0000000 --- a/apps/journal/app/routes/routes.new.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Server-only loader/action for /routes/new. See `home.server.ts`. - -import { data, redirect } from "react-router"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { createRoute } from "~/lib/routes.server"; - -export async function loadRoutesNew(request: Request) { - await requireSessionUser(request); - return {}; -} - -export async function routesNewAction(request: Request) { - const user = await requireSessionUser(request); - - const formData = await request.formData(); - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const gpxFile = formData.get("gpx") as File | null; - - if (!name) return data({ error: "Name is required" }, { status: 400 }); - - let gpx: string | undefined; - if (gpxFile && gpxFile.size > 0) { - gpx = await gpxFile.text(); - } - - const routeId = await createRoute(user.id, { name, description, gpx }); - return redirect(`/routes/${routeId}`); -} diff --git a/apps/journal/app/routes/routes.new.tsx b/apps/journal/app/routes/routes.new.tsx index 03f2446..7a939bd 100644 --- a/apps/journal/app/routes/routes.new.tsx +++ b/apps/journal/app/routes/routes.new.tsx @@ -1,14 +1,33 @@ -import { data } from "react-router"; +import { data, redirect } from "react-router"; import type { Route } from "./+types/routes.new"; -import { loadRoutesNew, routesNewAction } from "./routes.new.server"; +import { getSessionUser } from "~/lib/auth.server"; +import { createRoute } from "~/lib/routes.server"; export async function loader({ request }: Route.LoaderArgs) { - return data(await loadRoutesNew(request)); + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + return data({}); } export async function action({ request }: Route.ActionArgs) { - return await routesNewAction(request); + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const formData = await request.formData(); + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + + if (!name) return data({ error: "Name is required" }, { status: 400 }); + + let gpx: string | undefined; + if (gpxFile && gpxFile.size > 0) { + gpx = await gpxFile.text(); + } + + const routeId = await createRoute(user.id, { name, description, gpx }); + return redirect(`/routes/${routeId}`); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/settings.account.server.ts b/apps/journal/app/routes/settings.account.server.ts deleted file mode 100644 index af253b7..0000000 --- a/apps/journal/app/routes/settings.account.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Server-only loader for /settings/account. See `home.server.ts`. - -import { redirect } from "react-router"; -import { getSessionUser } from "~/lib/auth/session.server"; - -export async function loadAccountSettings(request: Request) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - return { - user: { - username: user.username, - email: user.email, - }, - }; -} diff --git a/apps/journal/app/routes/settings.account.tsx b/apps/journal/app/routes/settings.account.tsx index f48cade..17aa3ca 100644 --- a/apps/journal/app/routes/settings.account.tsx +++ b/apps/journal/app/routes/settings.account.tsx @@ -1,15 +1,22 @@ import { useState, useEffect } from "react"; -import { data, useFetcher } from "react-router"; +import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings.account"; -import { loadAccountSettings } from "./settings.account.server"; +import { getSessionUser } from "~/lib/auth.server"; export function meta() { return [{ title: "Account — Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - return data(await loadAccountSettings(request)); + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return data({ + user: { + username: user.username, + email: user.email, + }, + }); } export default function AccountSettings({ loaderData }: Route.ComponentProps) { diff --git a/apps/journal/app/routes/settings.connections.komoot.server.ts b/apps/journal/app/routes/settings.connections.komoot.server.ts deleted file mode 100644 index ea9adb5..0000000 --- a/apps/journal/app/routes/settings.connections.komoot.server.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Server-only loader for /settings/connections/komoot. See `home.server.ts`. - -import { redirect } from "react-router"; -import { getOrigin } from "~/lib/config.server"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { getService } from "~/lib/connected-services/manager"; - -export async function loadKomootConnection(request: Request) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - - const service = await getService(user.id, "komoot"); - const origin = getOrigin(); - const trailsProfileUrl = `${origin}/users/${user.username}`; - - return { - connected: !!service, - mode: service ? (service.credentials as { mode?: string }).mode ?? null : null, - providerUserId: service?.providerUserId ?? null, - serviceId: service?.id ?? null, - trailsProfileUrl, - }; -} diff --git a/apps/journal/app/routes/settings.connections.komoot.tsx b/apps/journal/app/routes/settings.connections.komoot.tsx deleted file mode 100644 index 7c48941..0000000 --- a/apps/journal/app/routes/settings.connections.komoot.tsx +++ /dev/null @@ -1,193 +0,0 @@ -// Komoot connection management page. Supports two modes: -// Public — verify ownership via bio link (no password stored) -// Authenticated — email + password (password encrypted at rest) - -import { useState } from "react"; -import { data, useFetcher } from "react-router"; -import { useTranslation } from "react-i18next"; -import type { Route } from "./+types/settings.connections.komoot"; -import { loadKomootConnection } from "./settings.connections.komoot.server"; - -export function meta() { - return [{ title: "Connect Komoot — Settings — trails.cool" }]; -} - -export async function loader({ request }: Route.LoaderArgs) { - return data(await loadKomootConnection(request)); -} - -type VerifyResponse = { success?: boolean; error?: string }; -type ConnectResponse = { success?: boolean; error?: string }; - -export default function KomootConnectPage({ loaderData }: Route.ComponentProps) { - const { connected, mode, providerUserId, serviceId, trailsProfileUrl } = loaderData; - const { t } = useTranslation("journal"); - - const verifyFetcher = useFetcher(); - const connectFetcher = useFetcher(); - - const [komootProfileUrl, setKomootProfileUrl] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - - const isVerifying = verifyFetcher.state !== "idle"; - const isConnecting = connectFetcher.state !== "idle"; - - const verifyError = verifyFetcher.data?.error; - const connectError = connectFetcher.data?.error; - - function handleVerify(e: React.FormEvent) { - e.preventDefault(); - verifyFetcher.submit( - JSON.stringify({ komootProfileUrl }), - { method: "post", action: "/api/sync/komoot/verify", encType: "application/json" }, - ); - } - - function handleConnect(e: React.FormEvent) { - e.preventDefault(); - connectFetcher.submit( - JSON.stringify({ email, password }), - { method: "post", action: "/api/sync/komoot/connect", encType: "application/json" }, - ); - } - - return ( -
    -
    -

    {t("komoot.title")}

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

    {t("komoot.publicSection")}

    -

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

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

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

    - )} - {isVerifying && ( -

    {t("komoot.verifyPending")}

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

    {t("komoot.authenticatedSection")}

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

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

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

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

    - {errorKey && ( -
    - {t(`settings.services.errors.${errorKey}`)} -
    - )}
    {providers.map((p) => ( ) : ( {t("settings.services.connect")} diff --git a/apps/journal/app/routes/settings.profile.server.ts b/apps/journal/app/routes/settings.profile.server.ts deleted file mode 100644 index 62865a2..0000000 --- a/apps/journal/app/routes/settings.profile.server.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Server-only loader for /settings/profile. See `home.server.ts`. - -import { redirect } from "react-router"; -import { getSessionUser } from "~/lib/auth/session.server"; - -export async function loadProfileSettings(request: Request) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - return { - user: { - username: user.username, - displayName: user.displayName, - bio: user.bio, - profileVisibility: user.profileVisibility, - }, - }; -} diff --git a/apps/journal/app/routes/settings.profile.tsx b/apps/journal/app/routes/settings.profile.tsx index 3dda3d6..2c9956b 100644 --- a/apps/journal/app/routes/settings.profile.tsx +++ b/apps/journal/app/routes/settings.profile.tsx @@ -1,15 +1,24 @@ import { useState, useEffect } from "react"; -import { data, useFetcher } from "react-router"; +import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings.profile"; -import { loadProfileSettings } from "./settings.profile.server"; +import { getSessionUser } from "~/lib/auth.server"; export function meta() { return [{ title: "Profile — Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - return data(await loadProfileSettings(request)); + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return data({ + user: { + username: user.username, + displayName: user.displayName, + bio: user.bio, + profileVisibility: user.profileVisibility, + }, + }); } export default function ProfileSettings({ loaderData }: Route.ComponentProps) { diff --git a/apps/journal/app/routes/settings.security.server.ts b/apps/journal/app/routes/settings.security.server.ts deleted file mode 100644 index 1853494..0000000 --- a/apps/journal/app/routes/settings.security.server.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Server-only loader for /settings/security. See `home.server.ts`. - -import { redirect } from "react-router"; -import { eq } from "drizzle-orm"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { getDb } from "~/lib/db"; -import { credentials } from "@trails-cool/db/schema/journal"; - -export async function loadSecuritySettings(request: Request) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - - const db = getDb(); - const passkeys = await db - .select({ - id: credentials.id, - deviceType: credentials.deviceType, - transports: credentials.transports, - createdAt: credentials.createdAt, - }) - .from(credentials) - .where(eq(credentials.userId, user.id)); - - return { - userId: user.id, - passkeys: passkeys.map((p) => ({ - id: p.id, - deviceType: p.deviceType, - transports: p.transports as string[] | null, - createdAt: p.createdAt.toISOString(), - })), - }; -} diff --git a/apps/journal/app/routes/settings.security.tsx b/apps/journal/app/routes/settings.security.tsx index e98ab78..d499120 100644 --- a/apps/journal/app/routes/settings.security.tsx +++ b/apps/journal/app/routes/settings.security.tsx @@ -1,16 +1,41 @@ import { useState, useEffect, useCallback } from "react"; -import { data, useFetcher } from "react-router"; +import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; +import { eq } from "drizzle-orm"; import type { Route } from "./+types/settings.security"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { credentials } from "@trails-cool/db/schema/journal"; import { ClientDate } from "~/components/ClientDate"; -import { loadSecuritySettings } from "./settings.security.server"; export function meta() { return [{ title: "Security — Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - return data(await loadSecuritySettings(request)); + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const db = getDb(); + const passkeys = await db + .select({ + id: credentials.id, + deviceType: credentials.deviceType, + transports: credentials.transports, + createdAt: credentials.createdAt, + }) + .from(credentials) + .where(eq(credentials.userId, user.id)); + + return data({ + userId: user.id, + passkeys: passkeys.map((p) => ({ + id: p.id, + deviceType: p.deviceType, + transports: p.transports as string[] | null, + createdAt: p.createdAt.toISOString(), + })), + }); } function transportLabel(transports: string[] | null, t: (key: string) => string): string { diff --git a/apps/journal/app/routes/settings.server.ts b/apps/journal/app/routes/settings.server.ts deleted file mode 100644 index 62e87c3..0000000 --- a/apps/journal/app/routes/settings.server.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Server-only loader for /settings layout. See `home.server.ts`. - -import { redirect } from "react-router"; -import { getSessionUser } from "~/lib/auth/session.server"; - -export async function loadSettingsLayout(request: Request) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - return { username: user.username }; -} diff --git a/apps/journal/app/routes/settings.tsx b/apps/journal/app/routes/settings.tsx index f7dec21..1df5d11 100644 --- a/apps/journal/app/routes/settings.tsx +++ b/apps/journal/app/routes/settings.tsx @@ -1,15 +1,17 @@ -import { Outlet } from "react-router"; +import { Outlet, redirect } from "react-router"; import { Link, useLocation } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings"; -import { loadSettingsLayout } from "./settings.server"; +import { getSessionUser } from "~/lib/auth.server"; export function meta() { return [{ title: "Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - return await loadSettingsLayout(request); + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return { username: user.username }; } interface NavItem { diff --git a/apps/journal/app/routes/sync.import.$provider.server.ts b/apps/journal/app/routes/sync.import.$provider.server.ts deleted file mode 100644 index da973b2..0000000 --- a/apps/journal/app/routes/sync.import.$provider.server.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Server-only loader/action for /sync/import/:provider. See `home.server.ts`. - -import { data, redirect } from "react-router"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { - getManifest, - getService, - capabilityContextFor, -} from "~/lib/connected-services"; -import { getImportedIds, recordImport } from "~/lib/sync/imports.server"; -import { createActivity } from "~/lib/activities.server"; -import { generateGpx } from "@trails-cool/gpx"; - -export async function loadSyncImportProvider(request: Request, provider: string | undefined) { - const user = await requireSessionUser(request); - - const manifest = getManifest(provider ?? ""); - if (!manifest || !manifest.importer) { - throw data({ error: "Unknown provider" }, { status: 404 }); - } - - const service = await getService(user.id, manifest.id); - if (!service) throw redirect("/settings"); - - const url = new URL(request.url); - const page = parseInt(url.searchParams.get("page") ?? "1"); - - const ctx = capabilityContextFor(service.id); - const workoutList = await manifest.importer.listImportable(ctx, page); - - const importedIds = await getImportedIds( - user.id, - manifest.id, - workoutList.workouts.map((w) => w.id), - ); - - return { - provider: { id: manifest.id, name: manifest.displayName }, - workouts: workoutList.workouts.map((w) => ({ - ...w, - imported: importedIds.has(w.id), - })), - page: workoutList.page, - totalPages: Math.ceil(workoutList.total / workoutList.perPage), - }; -} - -export async function syncImportProviderAction(request: Request, provider: string | undefined) { - const user = await requireSessionUser(request); - - const manifest = getManifest(provider ?? ""); - if (!manifest || !manifest.importer) { - throw data({ error: "Unknown provider" }, { status: 404 }); - } - - const service = await getService(user.id, manifest.id); - if (!service) return redirect("/settings"); - - const formData = await request.formData(); - const workoutId = formData.get("workoutId") as string; - const workoutName = formData.get("workoutName") as string; - const fileUrl = formData.get("fileUrl") as string; - const startedAt = formData.get("startedAt") as string; - const distance = formData.get("distance") as string; - const duration = formData.get("duration") as string; - - // Inline import here (not via Importer.importOne) so we can pass through - // the form-supplied metadata (started_at, distance, duration) the - // capability seam doesn't carry. The seam-shape importer is reserved for - // automatic / webhook-driven imports. - let gpx: string | null = null; - if (fileUrl) { - const ctx = capabilityContextFor(service.id); - // Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials - // so the manager handles refresh of any near-expired token. - const buffer = await ctx.withFreshCredentials(async () => { - const resp = await fetch(fileUrl); - if (!resp.ok) throw new Error(`Download failed: ${resp.status}`); - return Buffer.from(await resp.arrayBuffer()); - }); - // Lazy-load fit-file-parser only — it's heavy and only the FIT - // ingestion path needs it. @trails-cool/gpx is already pulled in - // statically by other modules in the chunk, so the dynamic import - // there was ineffective. - const { default: FitParser } = await import("fit-file-parser"); - const parsed = await new Promise>((resolve, reject) => { - const parser = new FitParser({ force: true }); - // See lib/connected-services/fit.ts for the same Buffer - // narrowing rationale. - parser.parse(buffer as Buffer, (err, d) => - err ? reject(err) : resolve((d ?? {}) as Record), - ); - }); - const records = (parsed.records ?? []) as Array<{ - position_lat?: number; - position_long?: number; - altitude?: number; - timestamp?: string | Date; - }>; - const trackPoints = records - .filter((r) => r.position_lat != null && r.position_long != null) - .map((r) => ({ - lat: r.position_lat!, - lon: r.position_long!, - ele: r.altitude, - time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp, - })); - if (trackPoints.length >= 2) { - gpx = generateGpx({ name: workoutName, tracks: [trackPoints] }); - } - } - - const activityId = await createActivity(user.id, { - name: workoutName || `${manifest.displayName} workout`, - gpx: gpx ?? undefined, - distance: distance ? parseFloat(distance) : null, - duration: duration ? parseInt(duration) : null, - startedAt: startedAt ? new Date(startedAt) : null, - }); - - await recordImport(user.id, manifest.id, workoutId, activityId); - - return { imported: workoutId }; -} diff --git a/apps/journal/app/routes/sync.import.$provider.tsx b/apps/journal/app/routes/sync.import.$provider.tsx index 37e6832..a59f2c7 100644 --- a/apps/journal/app/routes/sync.import.$provider.tsx +++ b/apps/journal/app/routes/sync.import.$provider.tsx @@ -1,16 +1,103 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { data, useFetcher } from "react-router"; +import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/sync.import.$provider"; +import { getSessionUser } from "~/lib/auth.server"; +import { getProvider } from "~/lib/sync/registry"; +import { getConnection, updateTokens } from "~/lib/sync/connections.server"; +import { getImportedIds, recordImport } from "~/lib/sync/imports.server"; +import { createActivity } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; -import { loadSyncImportProvider, syncImportProviderAction } from "./sync.import.$provider.server"; export async function loader({ params, request }: Route.LoaderArgs) { - return data(await loadSyncImportProvider(request, params.provider)); + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const provider = getProvider(params.provider); + if (!provider) throw data({ error: "Unknown provider" }, { status: 404 }); + + const connection = await getConnection(user.id, provider.id); + if (!connection) return redirect("/settings"); + + const url = new URL(request.url); + const page = parseInt(url.searchParams.get("page") ?? "1"); + + // Refresh token if needed + let tokens = { + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + expiresAt: connection.expiresAt, + }; + if (new Date() >= tokens.expiresAt) { + tokens = await provider.refreshToken(tokens.refreshToken); + await updateTokens(connection.id, tokens); + } + + const workoutList = await provider.listWorkouts(tokens, page); + const importedIds = await getImportedIds( + user.id, + provider.id, + workoutList.workouts.map((w) => w.id), + ); + + return data({ + provider: { id: provider.id, name: provider.name }, + workouts: workoutList.workouts.map((w) => ({ + ...w, + imported: importedIds.has(w.id), + })), + page: workoutList.page, + totalPages: Math.ceil(workoutList.total / workoutList.perPage), + }); } export async function action({ params, request }: Route.ActionArgs) { - return data(await syncImportProviderAction(request, params.provider)); + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const provider = getProvider(params.provider); + if (!provider) throw data({ error: "Unknown provider" }, { status: 404 }); + + const connection = await getConnection(user.id, provider.id); + if (!connection) return redirect("/settings"); + + const formData = await request.formData(); + const workoutId = formData.get("workoutId") as string; + const workoutName = formData.get("workoutName") as string; + const fileUrl = formData.get("fileUrl") as string; + const startedAt = formData.get("startedAt") as string; + const distance = formData.get("distance") as string; + const duration = formData.get("duration") as string; + + // Refresh token if needed + let tokens = { + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + expiresAt: connection.expiresAt, + }; + if (new Date() >= tokens.expiresAt) { + tokens = await provider.refreshToken(tokens.refreshToken); + await updateTokens(connection.id, tokens); + } + + let gpx: string | null = null; + if (fileUrl) { + const workout = { id: workoutId, name: workoutName, type: "", startedAt: "", duration: null, distance: null, fileUrl }; + const fileBuffer = await provider.downloadFile(tokens, workout); + gpx = await provider.convertToGpx(fileBuffer); + } + + const activityId = await createActivity(user.id, { + name: workoutName || `${provider.name} workout`, + gpx: gpx ?? undefined, + distance: distance ? parseFloat(distance) : null, + duration: duration ? parseInt(duration) : null, + startedAt: startedAt ? new Date(startedAt) : null, + }); + + await recordImport(user.id, provider.id, workoutId, activityId); + + return data({ imported: workoutId }); } export function meta({ data: loaderData }: Route.MetaArgs) { @@ -97,6 +184,10 @@ export default function SyncImportPage({ loaderData }: Route.ComponentProps) { const importAllFetcher = useFetcher<{ imported?: string }>(); const importAllRef = useRef({ index: 0, workouts: importableWorkouts }); + useEffect(() => { + importAllRef.current.workouts = importableWorkouts; + }, [importableWorkouts]); + useEffect(() => { if (!importAllActive) return; if (importAllFetcher.state !== "idle") return; diff --git a/apps/journal/app/routes/sync.import.garmin.server.ts b/apps/journal/app/routes/sync.import.garmin.server.ts deleted file mode 100644 index 275a40f..0000000 --- a/apps/journal/app/routes/sync.import.garmin.server.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Server logic for the Garmin import page (spec: garmin-import, -// "Historical import via backfill"). Garmin has no list endpoint, so -// this page is a date-range backfill requester with honest async -// progress — not a pick list. - -import { and, desc, eq, gte, count } from "drizzle-orm"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { getDb } from "~/lib/db"; -import { importBatches, syncImports } from "@trails-cool/db/schema/journal"; -import { getService } from "~/lib/connected-services/manager"; -import { requestBackfill } from "~/lib/connected-services/providers/garmin/backfill"; - -export interface GarminBackfillRow { - id: string; - rangeStart: string | null; - rangeEnd: string | null; - requestedAt: string; - // Garmin activities imported since this request was made. Activities - // arrive asynchronously and notifications don't carry a batch id, so - // "imports since the request" is the honest measurable proxy for - // range progress (overlapping ranges share arrivals). - importedSince: number; -} - -export async function loadGarminImportPage(request: Request) { - const user = await requireSessionUser(request); - const service = await getService(user.id, "garmin"); - - if (!service) { - return { connected: false as const, status: null, batches: [] as GarminBackfillRow[] }; - } - - const db = getDb(); - const rows = await db - .select() - .from(importBatches) - .where(and(eq(importBatches.userId, user.id), eq(importBatches.provider, "garmin"))) - .orderBy(desc(importBatches.startedAt)) - .limit(20); - - const batches: GarminBackfillRow[] = []; - for (const row of rows) { - const [imported] = await db - .select({ n: count() }) - .from(syncImports) - .where( - and( - eq(syncImports.userId, user.id), - eq(syncImports.provider, "garmin"), - gte(syncImports.importedAt, row.startedAt), - ), - ); - batches.push({ - id: row.id, - rangeStart: row.rangeStart?.toISOString() ?? null, - rangeEnd: row.rangeEnd?.toISOString() ?? null, - requestedAt: row.startedAt.toISOString(), - importedSince: imported?.n ?? 0, - }); - } - - return { connected: true as const, status: service.status, batches }; -} - -export type GarminBackfillActionResult = - | { ok: true; chunks: number } - | { ok: false; error: "not_connected" | "needs_relink" | "invalid_range" | "request_failed" }; - -export async function handleGarminBackfillAction( - request: Request, -): Promise { - const user = await requireSessionUser(request); - const service = await getService(user.id, "garmin"); - if (!service) return { ok: false, error: "not_connected" }; - if (service.status !== "active") return { ok: false, error: "needs_relink" }; - - const form = await request.formData(); - const from = new Date(String(form.get("from") ?? "")); - const to = new Date(String(form.get("to") ?? "")); - if (isNaN(from.getTime()) || isNaN(to.getTime()) || from >= to || to > new Date()) { - return { ok: false, error: "invalid_range" }; - } - - try { - const { chunks } = await requestBackfill({ id: service.id, userId: user.id }, from, to); - return { ok: true, chunks }; - } catch (e) { - console.error("garmin backfill request failed:", e); - return { ok: false, error: "request_failed" }; - } -} diff --git a/apps/journal/app/routes/sync.import.garmin.tsx b/apps/journal/app/routes/sync.import.garmin.tsx deleted file mode 100644 index 7642647..0000000 --- a/apps/journal/app/routes/sync.import.garmin.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { data, Form, Link, useNavigation } from "react-router"; -import { useTranslation } from "react-i18next"; -import type { Route } from "./+types/sync.import.garmin"; -import { ClientDate } from "~/components/ClientDate"; - -export function meta() { - return [{ title: "Garmin import — trails.cool" }]; -} - -export async function loader({ request }: Route.LoaderArgs) { - const { loadGarminImportPage } = await import("./sync.import.garmin.server"); - return data(await loadGarminImportPage(request)); -} - -export async function action({ request }: Route.ActionArgs) { - const { handleGarminBackfillAction } = await import("./sync.import.garmin.server"); - return data(await handleGarminBackfillAction(request)); -} - -export default function GarminImport({ loaderData, actionData }: Route.ComponentProps) { - const { t } = useTranslation(["journal"]); - const navigation = useNavigation(); - const busy = navigation.state !== "idle"; - - if (!loaderData.connected) { - return ( -
    -

    {t("sync.garmin.title")}

    -

    {t("sync.garmin.notConnected")}

    - - {t("sync.garmin.goConnect")} - -
    - ); - } - - return ( -
    -

    {t("sync.garmin.title")}

    -

    {t("sync.garmin.subtitle")}

    - - {loaderData.status !== "active" && ( -
    - {t("sync.garmin.needsRelink")} -
    - )} - -
    - - - -
    - - {actionData && "ok" in actionData && actionData.ok && ( -

    {t("sync.garmin.requested")}

    - )} - {actionData && "ok" in actionData && !actionData.ok && ( -

    - {t(`sync.garmin.errors.${actionData.error}`)} -

    - )} - -

    {t("sync.garmin.asyncNote")}

    - - {loaderData.batches.length > 0 && ( -
      - {loaderData.batches.map((b) => ( -
    • - - {b.rangeStart && b.rangeEnd ? ( - <> - - - ) : ( - - )} - - - {t("sync.garmin.importedSince", { count: b.importedSince })} - -
    • - ))} -
    - )} -
    - ); -} diff --git a/apps/journal/app/routes/sync.import.komoot.server.ts b/apps/journal/app/routes/sync.import.komoot.server.ts deleted file mode 100644 index aedc307..0000000 --- a/apps/journal/app/routes/sync.import.komoot.server.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Server-only loader/action for /sync/import/komoot. See `home.server.ts`. - -import { data, redirect } from "react-router"; -import { desc, eq, and } from "drizzle-orm"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { getService } from "~/lib/connected-services"; -import { getDb } from "~/lib/db"; -import { importBatches } from "@trails-cool/db/schema/journal"; - -export async function loadKomootImport(request: Request) { - const user = await requireSessionUser(request); - - const service = await getService(user.id, "komoot"); - if (!service) throw redirect("/settings/connections/komoot"); - - const db = getDb(); - const [batch] = await db - .select() - .from(importBatches) - .where(and(eq(importBatches.userId, user.id), eq(importBatches.connectionId, service.id))) - .orderBy(desc(importBatches.startedAt)) - .limit(1); - - return { - batch: batch - ? { - id: batch.id, - status: batch.status, - totalFound: batch.totalFound, - importedCount: batch.importedCount, - duplicateCount: batch.duplicateCount, - errorMessage: batch.errorMessage, - startedAt: batch.startedAt.toISOString(), - completedAt: batch.completedAt?.toISOString() ?? null, - } - : null, - }; -} - -export async function komootImportAction(request: Request) { - await requireSessionUser(request); - - // Delegate to the API route — just redirect so the page reloads with - // the new batch after the POST. - const resp = await fetch( - new URL("/api/sync/komoot/import", new URL(request.url).origin), - { method: "POST", headers: { cookie: request.headers.get("cookie") ?? "" } }, - ); - if (!resp.ok) { - const body = (await resp.json()) as { error?: string }; - return data({ error: body.error ?? "failed" }, { status: resp.status }); - } - return redirect("/sync/import/komoot"); -} diff --git a/apps/journal/app/routes/sync.import.komoot.tsx b/apps/journal/app/routes/sync.import.komoot.tsx deleted file mode 100644 index 290d35a..0000000 --- a/apps/journal/app/routes/sync.import.komoot.tsx +++ /dev/null @@ -1,164 +0,0 @@ -// Komoot import page — shows live progress for background bulk imports -// and lets the user trigger a new import run. - -import { useEffect, useRef } from "react"; -import { data, useFetcher, useRevalidator } from "react-router"; -import { useTranslation } from "react-i18next"; -import type { Route } from "./+types/sync.import.komoot"; -import { loadKomootImport, komootImportAction } from "./sync.import.komoot.server"; - -export function meta() { - return [{ title: "Import from Komoot — trails.cool" }]; -} - -export async function loader({ request }: Route.LoaderArgs) { - return data(await loadKomootImport(request)); -} - -export async function action({ request }: Route.ActionArgs) { - return await komootImportAction(request); -} - -function formatDuration(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - -export default function KomootImportPage({ loaderData }: Route.ComponentProps) { - const { batch } = loaderData; - const { t } = useTranslation("journal"); - const revalidator = useRevalidator(); - const triggerFetcher = useFetcher<{ error?: string }>(); - const pollingRef = useRef | null>(null); - - const isActive = batch?.status === "pending" || batch?.status === "running"; - - useEffect(() => { - if (isActive) { - pollingRef.current = setInterval(() => { - revalidator.revalidate(); - }, 2000); - } - return () => { - if (pollingRef.current) clearInterval(pollingRef.current); - }; - }, [isActive]); - - const elapsedSeconds = batch - ? Math.floor( - (batch.completedAt - ? new Date(batch.completedAt).getTime() - : Date.now()) - new Date(batch.startedAt).getTime(), - ) / 1000 - : 0; - - return ( -
    -

    - {t("sync.importFrom", { provider: "Komoot" })} -

    - -
    - ); -} - -function StatusBadge({ status, t }: { status: string; t: (k: string) => string }) { - const styles: Record = { - pending: "bg-gray-100 text-gray-600", - running: "bg-blue-100 text-blue-700", - completed: "bg-green-100 text-green-700", - failed: "bg-red-100 text-red-700", - }; - return ( - - {t(`komoot.import.status.${status}`)} - - ); -} - -function StatBox({ label, value }: { label: string; value: number }) { - return ( -
    -

    {value}

    -

    {label}

    -
    - ); -} diff --git a/apps/journal/app/routes/users.$username.followers.server.ts b/apps/journal/app/routes/users.$username.followers.server.ts deleted file mode 100644 index 4190412..0000000 --- a/apps/journal/app/routes/users.$username.followers.server.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Server-only loader for /users/:username/followers. See `home.server.ts`. - -import { data } from "react-router"; -import { eq } from "drizzle-orm"; -import { getDb } from "~/lib/db"; -import { users } from "@trails-cool/db/schema/journal"; -import { listFollowers, countFollowers, getFollowState } from "~/lib/follow.server"; -import { getSessionUser } from "~/lib/auth/session.server"; - -export async function loadUserFollowers(request: Request, username: string) { - const db = getDb(); - const [user] = await db.select().from(users).where(eq(users.username, username)); - if (!user) { - throw data({ error: "User not found" }, { status: 404 }); - } - - // Locked-account model: only the owner and accepted followers can see - // a private user's followers list. Non-followers (anonymous or signed-in) - // get the same 404 a stranger sees, mirroring the profile-route policy. - const currentUser = await getSessionUser(request); - const isOwn = currentUser?.id === user.id; - const followState = !isOwn && currentUser - ? await getFollowState(currentUser.id, user.username) - : null; - const canSee = - isOwn || - user.profileVisibility === "public" || - (followState !== null && followState.following === true); - if (!canSee) { - throw data({ error: "User not found" }, { status: 404 }); - } - - const url = new URL(request.url); - const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1); - const [entries, total] = await Promise.all([ - listFollowers(user.id, page), - countFollowers(user.id), - ]); - - return { - user: { username: user.username, displayName: user.displayName }, - page, - total, - entries, - }; -} diff --git a/apps/journal/app/routes/users.$username.followers.tsx b/apps/journal/app/routes/users.$username.followers.tsx index c1058ff..df2ab30 100644 --- a/apps/journal/app/routes/users.$username.followers.tsx +++ b/apps/journal/app/routes/users.$username.followers.tsx @@ -1,10 +1,48 @@ import { data } from "react-router"; +import { eq } from "drizzle-orm"; import type { Route } from "./+types/users.$username.followers"; +import { getDb } from "~/lib/db"; +import { users } from "@trails-cool/db/schema/journal"; +import { listFollowers, countFollowers, getFollowState } from "~/lib/follow.server"; +import { getSessionUser } from "~/lib/auth.server"; import { CollectionPage } from "~/components/CollectionPage"; -import { loadUserFollowers } from "./users.$username.followers.server"; export async function loader({ params, request }: Route.LoaderArgs) { - return data(await loadUserFollowers(request, params.username)); + const db = getDb(); + const [user] = await db.select().from(users).where(eq(users.username, params.username)); + if (!user) { + throw data({ error: "User not found" }, { status: 404 }); + } + + // Locked-account model: only the owner and accepted followers can see + // a private user's followers list. Non-followers (anonymous or signed-in) + // get the same 404 a stranger sees, mirroring the profile-route policy. + const currentUser = await getSessionUser(request); + const isOwn = currentUser?.id === user.id; + const followState = !isOwn && currentUser + ? await getFollowState(currentUser.id, user.username) + : null; + const canSee = + isOwn || + user.profileVisibility === "public" || + (followState !== null && followState.following === true); + if (!canSee) { + throw data({ error: "User not found" }, { status: 404 }); + } + + const url = new URL(request.url); + const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1); + const [entries, total] = await Promise.all([ + listFollowers(user.id, page), + countFollowers(user.id), + ]); + + return data({ + user: { username: user.username, displayName: user.displayName }, + page, + total, + entries, + }); } export function meta({ data: d }: Route.MetaArgs) { diff --git a/apps/journal/app/routes/users.$username.following.server.ts b/apps/journal/app/routes/users.$username.following.server.ts deleted file mode 100644 index 98a87a8..0000000 --- a/apps/journal/app/routes/users.$username.following.server.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Server-only loader for /users/:username/following. See `home.server.ts`. - -import { data } from "react-router"; -import { eq } from "drizzle-orm"; -import { getDb } from "~/lib/db"; -import { users } from "@trails-cool/db/schema/journal"; -import { listFollowing, countFollowing, getFollowState } from "~/lib/follow.server"; -import { getSessionUser } from "~/lib/auth/session.server"; - -export async function loadUserFollowing(request: Request, username: string) { - const db = getDb(); - const [user] = await db.select().from(users).where(eq(users.username, username)); - if (!user) { - throw data({ error: "User not found" }, { status: 404 }); - } - - // Locked-account model — see users.$username.followers.tsx for the - // policy. Same canSee rule applies to the following list. - const currentUser = await getSessionUser(request); - const isOwn = currentUser?.id === user.id; - const followState = !isOwn && currentUser - ? await getFollowState(currentUser.id, user.username) - : null; - const canSee = - isOwn || - user.profileVisibility === "public" || - (followState !== null && followState.following === true); - if (!canSee) { - throw data({ error: "User not found" }, { status: 404 }); - } - - const url = new URL(request.url); - const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1); - const [entries, total] = await Promise.all([ - listFollowing(user.id, page), - countFollowing(user.id), - ]); - - return { - user: { username: user.username, displayName: user.displayName }, - page, - total, - entries, - }; -} diff --git a/apps/journal/app/routes/users.$username.following.tsx b/apps/journal/app/routes/users.$username.following.tsx index a36eb2f..680083b 100644 --- a/apps/journal/app/routes/users.$username.following.tsx +++ b/apps/journal/app/routes/users.$username.following.tsx @@ -1,10 +1,47 @@ import { data } from "react-router"; +import { eq } from "drizzle-orm"; import type { Route } from "./+types/users.$username.following"; +import { getDb } from "~/lib/db"; +import { users } from "@trails-cool/db/schema/journal"; +import { listFollowing, countFollowing, getFollowState } from "~/lib/follow.server"; +import { getSessionUser } from "~/lib/auth.server"; import { CollectionPage } from "~/components/CollectionPage"; -import { loadUserFollowing } from "./users.$username.following.server"; export async function loader({ params, request }: Route.LoaderArgs) { - return data(await loadUserFollowing(request, params.username)); + const db = getDb(); + const [user] = await db.select().from(users).where(eq(users.username, params.username)); + if (!user) { + throw data({ error: "User not found" }, { status: 404 }); + } + + // Locked-account model — see users.$username.followers.tsx for the + // policy. Same canSee rule applies to the following list. + const currentUser = await getSessionUser(request); + const isOwn = currentUser?.id === user.id; + const followState = !isOwn && currentUser + ? await getFollowState(currentUser.id, user.username) + : null; + const canSee = + isOwn || + user.profileVisibility === "public" || + (followState !== null && followState.following === true); + if (!canSee) { + throw data({ error: "User not found" }, { status: 404 }); + } + + const url = new URL(request.url); + const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1); + const [entries, total] = await Promise.all([ + listFollowing(user.id, page), + countFollowing(user.id), + ]); + + return data({ + user: { username: user.username, displayName: user.displayName }, + page, + total, + entries, + }); } export function meta({ data: d }: Route.MetaArgs) { diff --git a/apps/journal/app/routes/users.$username.inbox.integration.test.ts b/apps/journal/app/routes/users.$username.inbox.integration.test.ts deleted file mode 100644 index 5abb203..0000000 --- a/apps/journal/app/routes/users.$username.inbox.integration.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect, beforeAll, afterEach } from "vitest"; -import { eq } from "drizzle-orm"; -import { randomUUID } from "node:crypto"; -import { getDb } from "~/lib/db"; -import { users } from "@trails-cool/db/schema/journal"; - -// Regression guard for the inbox's HTTP Signature requirement. Fedify -// verifies signatures on inbox listeners by default; this fails loudly -// if a future config change or Fedify upgrade silently stops rejecting -// unsigned activities — the property the whole federation trust model -// rests on. -// -// Talks to real Postgres + builds the Fedify instance, so it's gated -// behind FEDERATION_INTEGRATION=1 like the sibling federation -// integration tests (the recipient actor must exist for Fedify to get -// as far as verifying the signature, rather than 404ing). -const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; -const ORIGIN = process.env.ORIGIN ?? "http://localhost:3000"; - -const createdUserIds: string[] = []; - -async function makePublicUser(username: string): Promise { - const db = getDb(); - const id = randomUUID(); - await db.insert(users).values({ - id, - email: `${username}@example.test`, - username, - domain: new URL(ORIGIN).host, - profileVisibility: "public", - }); - createdUserIds.push(id); -} - -function unsignedInboxRequest(username: string): Request { - return new Request(`${ORIGIN}/users/${username}/inbox`, { - method: "POST", - headers: { "Content-Type": "application/activity+json" }, - // Deliberately NO Signature header. - body: JSON.stringify({ - "@context": "https://www.w3.org/ns/activitystreams", - id: "https://attacker.example/activities/1", - type: "Follow", - actor: "https://attacker.example/users/mallory", - object: `${ORIGIN}/users/${username}`, - }), - }); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function routeArgs(request: Request, username: string): any { - return { - request, - params: { username }, - context: {}, - unstable_url: new URL(request.url), - unstable_pattern: "/users/:username/inbox", - }; -} - -describe.runIf(runIntegration)("POST /users/:username/inbox — signature enforcement", () => { - beforeAll(() => { - process.env.FEDERATION_ENABLED = "true"; - process.env.ORIGIN ??= ORIGIN; - }); - - afterEach(async () => { - const db = getDb(); - for (const id of createdUserIds.splice(0)) { - await db.delete(users).where(eq(users.id, id)); - } - }); - - it("rejects an unsigned activity with 401 (never 2xx)", async () => { - const username = `inbox-unsigned-${Date.now()}`; - await makePublicUser(username); - - const { action } = await import("./users.$username.inbox.ts"); - const resp = (await action(routeArgs(unsignedInboxRequest(username), username))) as Response; - - // The contract that matters: an unsigned activity is never accepted. - expect(resp.status).toBeGreaterThanOrEqual(400); - expect(resp.status).toBeLessThan(500); - expect(resp.status).toBe(401); - }); -}); diff --git a/apps/journal/app/routes/users.$username.inbox.ts b/apps/journal/app/routes/users.$username.inbox.ts deleted file mode 100644 index e4fe0d5..0000000 --- a/apps/journal/app/routes/users.$username.inbox.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Route } from "./+types/users.$username.inbox"; -import { handleFederationRequest, federationSourceHost } from "~/lib/federation.server"; -import { consumeRateLimit } from "~/lib/rate-limit.server"; - -/** - * POST /users/:username/inbox — ActivityPub inbox (spec: - * social-federation, "Narrow inbox"). Fedify verifies the HTTP - * Signature and dispatches to the registered listeners; everything - * else about the request is its problem. We rate-limit per source - * instance *before* signature verification so a hostile instance - * can't burn CPU on key fetches + crypto (spec 4.8: 60 req / 5 min - * per host). - */ -export function action({ request }: Route.ActionArgs) { - const { allowed, resetMs } = consumeRateLimit({ - scope: "federation-inbox", - key: federationSourceHost(request), - limit: 60, - windowMs: 5 * 60 * 1000, - }); - if (!allowed) { - return Promise.resolve( - new Response("Too Many Requests", { - status: 429, - headers: { "Retry-After": String(Math.ceil(resetMs / 1000)) }, - }), - ); - } - return handleFederationRequest(request); -} - -/** Inboxes are POST-only; GET has no representation here. */ -export function loader() { - return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } }); -} diff --git a/apps/journal/app/routes/users.$username.outbox.ts b/apps/journal/app/routes/users.$username.outbox.ts deleted file mode 100644 index 1e1c17f..0000000 --- a/apps/journal/app/routes/users.$username.outbox.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Route } from "./+types/users.$username.outbox"; -import { - federationEnabled, - handleFederationRequest, - isFederatableUser, -} from "~/lib/federation.server"; - -/** - * GET /users/:username/outbox — paginated OrderedCollection of the - * user's public activities as Create(Note) (spec: social-federation, - * "Outbox publishes user's public activities"). Served by Fedify's - * collection dispatcher; 404s for private users (checked here because - * Fedify builds the collection-level response from counter/cursors - * without consulting the page dispatcher) and while FEDERATION_ENABLED - * is off. - */ -export async function loader({ request, params }: Route.LoaderArgs) { - if (!federationEnabled() || !(await isFederatableUser(params.username))) { - return new Response("Not Found", { status: 404 }); - } - return handleFederationRequest(request); -} diff --git a/apps/journal/app/routes/users.$username.server.ts b/apps/journal/app/routes/users.$username.server.ts deleted file mode 100644 index 4961695..0000000 --- a/apps/journal/app/routes/users.$username.server.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Server-only loader for the user profile page. Splitting this out keeps -// the route component file free of direct DB/auth/follow imports — see -// `home.server.ts` for the pattern. - -import { data } from "react-router"; -import { eq } from "drizzle-orm"; -import { getDb } from "~/lib/db"; -import { users } from "@trails-cool/db/schema/journal"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { listPublicRoutesForOwner } from "~/lib/routes.server"; -import { listPublicActivitiesForOwner, getActivityStats, getWeeklyDistance } from "~/lib/activities.server"; -import { loadPersona } from "~/lib/demo-bot.server"; -import { countFollowers, countFollowing, getFollowState } from "~/lib/follow.server"; - -export async function loadUserProfile(request: Request, username: string) { - const db = getDb(); - const [user] = await db.select().from(users).where(eq(users.username, username)); - - if (!user) { - throw data({ error: "User not found" }, { status: 404 }); - } - - const currentUser = await getSessionUser(request); - const isOwn = currentUser?.id === user.id; - - // Follow state: null when anonymous or owner; { following, pending } - // otherwise. - const followState = !isOwn && currentUser - ? await getFollowState(currentUser.id, user.username) - : null; - - // Locked-account model: a private profile renders a stub for - // non-followers (anonymous OR signed-in but not an accepted follower). - // Owners always see their own profile in full. - const canSeeContent = - isOwn || - user.profileVisibility === "public" || - (followState !== null && followState.following === true); - - // For private-stub viewers we still want counts (cheap) but skip the - // expensive content fetches. - const [followers, following] = await Promise.all([ - countFollowers(user.id), - countFollowing(user.id), - ]); - const url = new URL(request.url); - const sortParam = url.searchParams.get("sort"); - const activitySort = sortParam === "addedAt" ? "addedAt" : "startedAt"; - - const [publicRoutes, publicActivities] = canSeeContent - ? await Promise.all([ - listPublicRoutesForOwner(user.id), - listPublicActivitiesForOwner(user.id, activitySort), - ]) - : [[], []]; - - // Roll-up + weekly trend, scoped to what this viewer may see (public-only for - // non-owners). Skipped for the locked stub, which shows no content. - const [stats, weeklyDistance] = canSeeContent - ? await Promise.all([ - getActivityStats(user.id, { publicOnly: !isOwn }), - getWeeklyDistance(user.id, { publicOnly: !isOwn }), - ]) - : [null, null]; - - // Demo-account badge: true when this profile matches the instance's - // configured demo persona username. Computed server-side so we don't - // ship the persona config through client HTML. - const isDemoUser = user.username === loadPersona().username; - - return { - user: { - username: user.username, - displayName: user.displayName, - bio: user.bio, - domain: user.domain, - createdAt: user.createdAt.toISOString(), - }, - routes: publicRoutes.map((r) => ({ - id: r.id, - name: r.name, - description: r.description, - distance: r.distance, - elevationGain: r.elevationGain, - updatedAt: r.updatedAt.toISOString(), - })), - activities: publicActivities.map((a) => ({ - id: a.id, - name: a.name, - description: a.description, - sportType: a.sportType, - distance: a.distance, - duration: a.duration, - startedAt: a.startedAt?.toISOString() ?? null, - createdAt: a.createdAt.toISOString(), - })), - stats, - weeklyDistance, - activitySort, - isOwn, - isDemoUser, - followers, - following, - followState, - isLoggedIn: currentUser !== null, - profileVisibility: user.profileVisibility, - canSeeContent, - }; -} diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index 17ec1cd..5163971 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -1,35 +1,94 @@ import { data } from "react-router"; import type { Route } from "./+types/users.$username"; import { useTranslation } from "react-i18next"; +import { getDb } from "~/lib/db"; +import { users } from "@trails-cool/db/schema/journal"; +import { eq } from "drizzle-orm"; +import { getSessionUser } from "~/lib/auth.server"; +import { listPublicRoutesForOwner } from "~/lib/routes.server"; +import { listPublicActivitiesForOwner } from "~/lib/activities.server"; +import { loadPersona } from "~/lib/demo-bot.server"; +import { countFollowers, countFollowing, getFollowState } from "~/lib/follow.server"; import { ClientDate } from "~/components/ClientDate"; import { FollowButton } from "~/components/FollowButton"; -import { SportBadge } from "~/components/SportBadge"; -import { StatRow } from "~/components/StatRow"; -import { ProfileStats } from "~/components/ProfileStats"; -import { WeeklyDistanceChart } from "~/components/WeeklyDistanceChart"; -import { activityStatItems } from "~/lib/stats"; -import { loadUserProfile } from "./users.$username.server"; -import { - federationEnabled, - wantsActivityJson, - handleFederationRequest, -} from "~/lib/federation.server"; - -// Content negotiation: this URL is both the human profile (HTML) and the -// ActivityPub actor IRI (application/activity+json). AP clients are -// short-circuited to Fedify before the HTML loader runs; browsers fall -// through untouched. -export const middleware: Route.MiddlewareFunction[] = [ - async ({ request }, next) => { - if (federationEnabled() && wantsActivityJson(request)) { - return handleFederationRequest(request); - } - return next(); - }, -]; export async function loader({ params, request }: Route.LoaderArgs) { - return data(await loadUserProfile(request, params.username)); + const db = getDb(); + const [user] = await db.select().from(users).where(eq(users.username, params.username)); + + if (!user) { + throw data({ error: "User not found" }, { status: 404 }); + } + + const currentUser = await getSessionUser(request); + const isOwn = currentUser?.id === user.id; + + // Follow state: null when anonymous or owner; { following, pending } + // otherwise. + const followState = !isOwn && currentUser + ? await getFollowState(currentUser.id, user.username) + : null; + + // Locked-account model: a private profile renders a stub for + // non-followers (anonymous OR signed-in but not an accepted follower). + // Owners always see their own profile in full. + const canSeeContent = + isOwn || + user.profileVisibility === "public" || + (followState !== null && followState.following === true); + + // For private-stub viewers we still want counts (cheap) but skip the + // expensive content fetches. + const [followers, following] = await Promise.all([ + countFollowers(user.id), + countFollowing(user.id), + ]); + const [publicRoutes, publicActivities] = canSeeContent + ? await Promise.all([ + listPublicRoutesForOwner(user.id), + listPublicActivitiesForOwner(user.id), + ]) + : [[], []]; + + // Demo-account badge: true when this profile matches the instance's + // configured demo persona username. Computed server-side so we don't + // ship the persona config through client HTML. + const isDemoUser = user.username === loadPersona().username; + + return data({ + user: { + username: user.username, + displayName: user.displayName, + bio: user.bio, + domain: user.domain, + createdAt: user.createdAt.toISOString(), + }, + routes: publicRoutes.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + distance: r.distance, + elevationGain: r.elevationGain, + updatedAt: r.updatedAt.toISOString(), + })), + activities: publicActivities.map((a) => ({ + id: a.id, + name: a.name, + description: a.description, + distance: a.distance, + duration: a.duration, + startedAt: a.startedAt?.toISOString() ?? null, + createdAt: a.createdAt.toISOString(), + })), + isOwn, + isDemoUser, + followers, + following, + followState, + isLoggedIn: currentUser !== null, + profileVisibility: user.profileVisibility, + canSeeContent, + }); } export function meta({ data: loaderData }: Route.MetaArgs) { @@ -54,7 +113,7 @@ export function meta({ data: loaderData }: Route.MetaArgs) { } export default function UserProfilePage({ loaderData }: Route.ComponentProps) { - const { user, routes, activities, stats, weeklyDistance, activitySort, isOwn, isDemoUser, followers, following, followState, isLoggedIn, canSeeContent } = loaderData; + const { user, routes, activities, isOwn, isDemoUser, followers, following, followState, isLoggedIn, canSeeContent } = loaderData; const { t } = useTranslation("journal"); return ( @@ -126,11 +185,6 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) { )}
    - {stats && } - {weeklyDistance && weeklyDistance.length > 0 && ( - - )} - {!canSeeContent && (
    @@ -205,27 +259,9 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
    -
    -

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

    - {activities.length > 0 && ( - - )} -
    +

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

    {activities.length === 0 ? (

    {t("profile.noPublicActivities")}

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

    {a.description}

    diff --git a/apps/journal/app/styles.css b/apps/journal/app/styles.css deleted file mode 100644 index a6335a7..0000000 --- a/apps/journal/app/styles.css +++ /dev/null @@ -1,10 +0,0 @@ -@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 659169b..a8ed3d9 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -12,41 +12,38 @@ "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.2", + "@simplewebauthn/server": "^13.3.0", "@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/jobs": "workspace:*", - "@trails-cool/map-core": "workspace:*", - "@trails-cool/ui": "workspace:*", + "@trails-cool/map": "workspace:*", "@trails-cool/sentry-config": "workspace:*", "@trails-cool/types": "workspace:*", + "@trails-cool/ui": "workspace:*", "drizzle-orm": "catalog:", - "isbot": "^5.2.0", - "jose": "^6.2.3", - "nodemailer": "^9.0.3", + "isbot": "^5.1.39", + "jose": "^6.2.2", + "nodemailer": "^8.0.6", "pino": "^10.3.1", "prom-client": "^15.1.3", "react": "catalog:", "react-dom": "catalog:", "react-router": "catalog:", - "zod": "^4.4.3" + "zod": "^4.0.0" }, "devDependencies": { "@react-router/dev": "catalog:", "@simplewebauthn/types": "^12.0.0", "@tailwindcss/vite": "catalog:", - "@types/nodemailer": "^8.0.1", + "@types/nodemailer": "^8.0.0", "@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 5142398..e45e273 100644 --- a/apps/journal/react-router.config.ts +++ b/apps/journal/react-router.config.ts @@ -2,11 +2,4 @@ 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 deleted file mode 100644 index f35debd..0000000 --- a/apps/journal/serve-static.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 3b317ef..0000000 --- a/apps/journal/serve-static.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 3e0d8f4..6367191 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -2,29 +2,56 @@ 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 { 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 { 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 { createBoss, startWorker } from "@trails-cool/jobs"; -import { getDatabaseUrl } from "@trails-cool/db"; -import postgres, { type Sql } from "postgres"; +import postgres from "postgres"; -// 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, - }); -} +Sentry.init({ + dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", + ...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, @@ -38,27 +65,17 @@ 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 getHealthClient()`SELECT 1`; + await client`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(); } } @@ -66,32 +83,22 @@ const server = createServer((req, res) => { const url = req.url ?? "/"; const start = Date.now(); - // 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.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, + ); + }); + } - 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); - } - }); + 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 () => { @@ -136,43 +143,13 @@ 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"); - 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); - } + jobs.push(notificationsFanoutJob, notificationsPurgeJob); - const boss = createBoss(getDatabaseUrl()); + const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"); await startWorker(boss, jobs); // Register the started boss so feature code can enqueue jobs against // the same instance via getBoss() / enqueueOptional(). - const { setBoss, enqueue } = await import("./app/lib/boss.server.ts"); + const { setBoss } = 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 ecd0a99..558bad0 100644 --- a/apps/journal/vitest.config.ts +++ b/apps/journal/vitest.config.ts @@ -3,13 +3,6 @@ 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 deleted file mode 100644 index 2c724f2..0000000 --- a/apps/mobile/__mocks__/expo-crypto.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 714e5e2..7e0dc7b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,5 +1,7 @@ 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 => ({ @@ -12,6 +14,11 @@ 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", @@ -34,12 +41,6 @@ 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 f7922d9..907e02a 100644 --- a/apps/mobile/app/__tests__/smoke.test.tsx +++ b/apps/mobile/app/__tests__/smoke.test.tsx @@ -3,10 +3,8 @@ import { render, screen } from "@testing-library/react-native"; import { Text } from "react-native"; describe("Jest + React Native Testing Library", () => { - 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); + it("renders a component", () => { + 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 d627860..afcd600 100644 --- a/apps/mobile/lib/editor/RouteMap.tsx +++ b/apps/mobile/lib/editor/RouteMap.tsx @@ -77,11 +77,10 @@ function RouteMapInner({ const cameraRef = useRef(null); const handleLongPress = useCallback( - // 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] } }) => { + // 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). 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 db3f822..fb69142 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 (merge with Expo defaults) -config.watchFolders = [...(config.watchFolders ?? []), monorepoRoot]; +// Watch all files in the monorepo +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 984a4c3..a9b3d27 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -6,8 +6,7 @@ "expo": { "install": { "exclude": [ - "react", - "@sentry/react-native" + "react" ] } }, @@ -30,51 +29,51 @@ ] }, "dependencies": { - "@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", + "@expo/metro-runtime": "^55.0.10", + "@gorhom/bottom-sheet": "^5.2.10", + "@maplibre/maplibre-react-native": "^11.0.2", + "@sentry/cli": "^3.4.0", + "@sentry/react-native": "~8.9.1", "@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": "~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", + "expo": "~55.0.17", + "expo-constants": "~55.0.15", + "expo-crypto": "~55.0.14", + "expo-dev-client": "~55.0.28", + "expo-dev-menu": "^55.0.24", + "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.20", + "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", "react": "catalog:", - "react-native": "0.85.3", - "react-native-gesture-handler": "^2.31.2", - "react-native-reanimated": "^4.3.1", + "react-native": "0.83.4", + "react-native-gesture-handler": "^2.31.1", + "react-native-reanimated": "^4.3.0", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "~4.25.2", - "react-native-worklets": "0.8.3", - "use-latest-callback": "^0.3.4", - "zod": "^4.4.3" + "react-native-screens": "~4.24.0", + "use-latest-callback": "^0.3.3", + "zod": "^4.0.0" }, "devDependencies": { - "@testing-library/react-native": "^14.0.1", + "@testing-library/react-native": "^13.3.3", "@types/jest": "^29.5.14", - "@types/react": "~19.2.17", - "jest-expo": "^56.0.4", - "react-test-renderer": "^19.2.7", - "typescript": "~6.0.3" + "@types/react": "~19.2.14", + "jest-expo": "^55.0.15", + "react-test-renderer": "^19.2.5", + "typescript": "~5.9.2" } } diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 1dda7a8..dc95243 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "strict": true, "allowImportingTsExtensions": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "types": ["jest"] + "lib": ["DOM", "DOM.Iterable", "ESNext"] } } diff --git a/apps/planner/.gitignore b/apps/planner/.gitignore deleted file mode 100644 index 2625868..0000000 --- a/apps/planner/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.vitest-attachments/ diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index e0e567b..e4fd73e 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-slim AS base +FROM node:25-slim AS base # curl is used by the docker-compose healthcheck (node:25-slim is Debian # trixie-slim and ships neither curl nor wget by default). RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* @@ -9,12 +9,13 @@ 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 d67b0d7..f0379d7 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -11,12 +11,7 @@ import { elevationColor, routeGradeColor, maxspeedColor, } from "@trails-cool/map-core"; -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 }; +export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway" | "maxspeed" | "smoothness" | "tracktype" | "cycleway" | "bikeroute"; interface ColoredRouteProps { coordinates: [number, number, number][]; // [lon, lat, ele] @@ -32,59 +27,174 @@ interface ColoredRouteProps { export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }: ColoredRouteProps) { const segments = useMemo(() => { - 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 === "plain" || coordinates.length < 2) { + return null; + } if (colorMode === "elevation") { - 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 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 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; - 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; + result.push({ + positions: [ + [c0[1], c0[0]], + [c1[1], c1[0]], + ], + color: routeGradeColor(grade), + }); + } + return result; } - return buildColorRuns(coordinates, colorAt); + // 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; }, [coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes]); const plainPositions = useMemo( @@ -96,7 +206,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 4500f76..58cfa9c 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 0f6e13a..5d5e006 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -1,22 +1,76 @@ 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 { - SURFACE_COLORS, - DEFAULT_SURFACE_COLOR, - HIGHWAY_COLORS, - DEFAULT_HIGHWAY_COLOR, - SMOOTHNESS_COLORS, - DEFAULT_SMOOTHNESS_COLOR, - CYCLEWAY_COLORS, - DEFAULT_CYCLEWAY_COLOR, + 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, } from "@trails-cool/map-core"; -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"; +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 }; interface ElevationChartProps { yjs: YjsState; @@ -25,79 +79,28 @@ 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 }; } -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) { +export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days }: ElevationChartProps) { const { t } = useTranslation("planner"); - 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 [points, setPoints] = useState([]); 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(() => { @@ -121,9 +124,68 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio } isExternalHover.current = true; setHoverIdx(closest); + // Do NOT call onHover here to avoid feedback loop }, [highlightDistance, points]); - const draw = useCallback( + 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( (highlightIdx: number | null) => { const canvas = canvasRef.current; if (!canvas || points.length < 2) return; @@ -137,44 +199,406 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); - 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, - }); + 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); + } }, - [points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days], + [points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days, t], ); useEffect(() => { - draw(hoverIdx); - }, [points, hoverIdx, colorMode, draw]); + drawChart(hoverIdx); + }, [points, hoverIdx, colorMode, drawChart]); 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); @@ -184,6 +608,7 @@ 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++) { @@ -199,15 +624,16 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio const p = points[closest]!; onHover?.([p.lat, p.lon]); }, - [points, onHover], + [points, onHover, hoverIdx, drawChart], ); 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( @@ -215,84 +641,86 @@ 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 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]); - } - } - } + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas || points.length < 2) { dragStartX.current = null; dragStartClientX.current = null; isDragging.current = false; dragCurrentX.current = null; - draw(null); + return; } - dragCleanup.current = detach; - window.addEventListener("mousemove", onWindowMove); - window.addEventListener("mouseup", onWindowUp); + 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); }, - [points, onHover, onDragSelect, onClickPosition, draw], + [points, onClickPosition, onDragSelect, hoverIdx, drawChart], ); + // Touch handlers for mobile const handleTouchStart = useCallback( (e: React.TouchEvent) => { e.preventDefault(); @@ -301,11 +729,13 @@ 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) { @@ -323,15 +753,16 @@ 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; - draw(hoverIdx); + drawChart(hoverIdx); } }, - [points, onHover, hoverIdx, draw], + [points, onHover, hoverIdx, drawChart], ); const handleTouchMove = useCallback( @@ -342,6 +773,7 @@ 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; @@ -360,20 +792,22 @@ 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; - draw(hoverIdx); + drawChart(hoverIdx); } }, - [points, onHover, hoverIdx, draw], + [points, onHover, hoverIdx, drawChart], ); 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(); @@ -400,8 +834,10 @@ 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(); @@ -423,51 +859,26 @@ 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; - draw(null); + drawChart(null); }, - [points, onHover, onClickPosition, onDragSelect, draw], + [points, onHover, onClickPosition, onDragSelect, drawChart], ); const setMode = useCallback((mode: string) => { - setColorMode(yjs.routeData, mode as ColorMode); + yjs.routeData.set("colorMode", mode); }, [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 = { @@ -491,14 +902,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%"} @@ -569,12 +980,10 @@ 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); +} function download(gpx: string, filename: string) { const blob = new Blob([gpx], { type: "application/gpx+xml" }); @@ -34,63 +59,108 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { }, [open]); const handleExportRoute = useCallback(() => { - download(buildRouteGpx(yjs), "route.gpx"); + const tracks = getTracks(yjs); + const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks }); + download(gpx, "route.gpx"); setOpen(false); }, [yjs]); const handleExportPlan = useCallback(() => { - download(buildPlanGpx(yjs), "route-plan.gpx"); + 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"); setOpen(false); }, [yjs]); const handleExportDays = useCallback(() => { - for (const file of buildDayGpxFiles(yjs)) { - download(file.gpx, file.filename); + 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); } setOpen(false); }, [yjs]); - 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"; + const hasMultipleDays = (() => { + const waypoints = getWaypoints(yjs); + return waypoints.some((w) => w.isDayBreak); + })(); return (
    {open && ( -
    - - {hasMultipleDays && ( - )}
    diff --git a/apps/planner/app/components/MapHelpers.tsx b/apps/planner/app/components/MapHelpers.tsx deleted file mode 100644 index 4443cc6..0000000 --- a/apps/planner/app/components/MapHelpers.tsx +++ /dev/null @@ -1,271 +0,0 @@ -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 deleted file mode 100644 index 0dc8167..0000000 --- a/apps/planner/app/components/NearbyPoiMarkers.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 ea66aa8..b6508ee 100644 --- a/apps/planner/app/components/NoGoAreaLayer.tsx +++ b/apps/planner/app/components/NoGoAreaLayer.tsx @@ -12,11 +12,10 @@ interface NoGoAreaLayerProps { onToggle: () => void; } -// --color-danger (the no-go hue at full strength) const NO_GO_STYLE: L.PathOptions = { - color: "#a03c3c", - fillColor: "#a03c3c", - fillOpacity: 0.15, + color: "#ef4444", + fillColor: "#ef4444", + fillOpacity: 0.2, weight: 2, }; @@ -46,7 +45,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.originalEvent); + L.DomEvent.preventDefault(e as unknown as Event); 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 9954733..179d9ac 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 deleted file mode 100644 index d821d05..0000000 --- a/apps/planner/app/components/ParticipantAvatars.tsx +++ /dev/null @@ -1,87 +0,0 @@ -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 new file mode 100644 index 0000000..154c3d9 --- /dev/null +++ b/apps/planner/app/components/ParticipantList.tsx @@ -0,0 +1,136 @@ +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 bef0afe..bb64215 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -1,56 +1,56 @@ -import { useState, useCallback, useRef, memo, type RefObject } from "react"; -import { MapContainer, TileLayer, LayersControl, Marker, Polyline, Tooltip } from "react-leaflet"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { MapContainer, TileLayer, LayersControl, Marker, Polyline, useMapEvents, useMap } 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-core"; +import { baseLayers, overlayLayers } from "@trails-cool/map"; +import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; +import { isOvernight } from "~/lib/overnight"; 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 { Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core"; +import { snapToPoi } from "~/lib/poi-snap"; +import { Z_CURSOR, Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core"; import { NoGoAreaLayer } from "./NoGoAreaLayer"; -import { ColoredRoute } from "./ColoredRoute"; +import { ColoredRoute, findSegmentForPoint, type ColorMode } 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"; -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"; +/** 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"; const scale = highlighted ? "scale(1.17)" : "scale(1)"; - const noteIndicator = hasNote - ? `` - : ""; return L.divIcon({ className: "", - html: `
    -
    ${overnight ? "☾" : index + 1}
    - ${noteIndicator} -
    `, + html: `
    ${overnight ? "☾" : index + 1}
    `, iconSize: [0, 0], }); } @@ -60,7 +60,7 @@ function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon { className: "", html: `
    { 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} - - - )} - - ); -}); +function getWaypointsFromYjs(waypoints: Y.Array>): 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), + })); +} interface PlannerMapProps { yjs: YjsState; sessionId: string; - onRouteRequest?: (waypoints: { lat: number; lon: number; name?: string; overnight: boolean }[]) => void; + onRouteRequest?: (waypoints: WaypointData[]) => void; onImportError?: (message: string) => void; highlightPosition?: [number, number] | null; highlightedWaypoint?: number | null; - selectedWaypointIndex?: number | null; onRouteHover?: (distance: number | null) => void; days?: DayStage[]; } -export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, selectedWaypointIndex, onRouteHover, onImportError, days }: PlannerMapProps) { +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) { 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); - const { - waypoints, - routeCoordinates, - segmentBoundaries, - surfaces, - highways, - maxspeeds, - smoothnesses, - tracktypes, - cycleways, - bikeroutes, - colorMode, - addWaypoint, - handleRouteInsert, - moveWaypoint, - deleteWaypoint, - handleRoutePolylineHover, - } = useWaypointManager(yjs, poiState, suppressMapClickRef, onRouteRequest); + // Sync waypoints from Yjs + useEffect(() => { + const update = () => { + const wps = getWaypointsFromYjs(yjs.waypoints); + setWaypoints(wps); + if (wps.length >= 2 && onRouteRequest) { + onRouteRequest(wps); + } + }; - const { draggingOver, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } = useGpxDrop(yjs, onImportError); + yjs.waypoints.observeDeep(update); + update(); - const selectedWp = selectedWaypointIndex != null ? waypoints[selectedWaypointIndex] : undefined; - const nearbyPoisState = useNearbyPois(selectedWp?.lat, selectedWp?.lon, sessionId); + return () => { + yjs.waypoints.unobserveDeep(update); + }; + }, [yjs.waypoints, onRouteRequest]); - 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]); + // 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 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) => ( - - - - ))} - {overlayLayers.map((layer) => ( - - - - ))} - - - - - - {} : addWaypoint} suppressRef={suppressMapClickRef} /> - - - - - - - {selectedWaypointIndex != null && nearbyPoisState.pois.length > 0 && ( - - )} - - {waypoints.map((wp, i) => ( - + + + {baseLayers.map((layer) => ( + + + ))} + {overlayLayers.map((layer) => ( + + + + ))} + - {days && days.length > 1 && days.map((day) => { - const wp = waypoints[day.endWaypointIndex]; - if (!wp || day.dayNumber === days.length) return null; - return ( - - ); - })} + + + + {} : addWaypoint} suppressRef={suppressMapClickRef} /> + + + + + + - {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, - }} - /> - )} - - )} + {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 }); + }, + }} + /> + ))} - {highlightPosition && ( + {/* 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 (
    ', - iconSize: [0, 0], - })} /> - )} - + ); + })} + + {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, + }} + /> + )} + + )} + + {highlightPosition && ( +
    ', + iconSize: [0, 0], + })} + /> + )} + {contextMenu && ( { if (!mounted) return; - // After import, L.markerClusterGroup is available (typed via - // module augmentation in app/types/global.d.ts). - const cluster = L.markerClusterGroup({ + // After import, L.markerClusterGroup is available + const cluster = (L as unknown as { markerClusterGroup: (opts?: object) => L.LayerGroup }).markerClusterGroup({ maxClusterRadius: 40, disableClusteringAtZoom: 15, showCoverageOnHover: false, diff --git a/apps/planner/app/components/ProfileSelector.tsx b/apps/planner/app/components/ProfileSelector.tsx index 0131486..1c6c5b2 100644 --- a/apps/planner/app/components/ProfileSelector.tsx +++ b/apps/planner/app/components/ProfileSelector.tsx @@ -1,8 +1,6 @@ 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; @@ -12,11 +10,11 @@ interface ProfileSelectorProps { export function ProfileSelector({ yjs }: ProfileSelectorProps) { const { t } = useTranslation("planner"); - const [profile, setProfile] = useState(DEFAULT_PROFILE); + const [profile, setProfile] = useState("fastbike"); useEffect(() => { const update = () => { - const p = getProfile(yjs.routeData); + const p = yjs.routeData.get("profile") as string | undefined; if (p) setProfile(p); }; yjs.routeData.observe(update); @@ -28,28 +26,28 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) { (e: React.ChangeEvent) => { const value = e.target.value; setProfile(value); - setYjsProfile(yjs.routeData, value); + yjs.routeData.set("profile", value); }, [yjs.routeData], ); return (
    -
    ); } diff --git a/apps/planner/app/components/RouteInteraction.tsx b/apps/planner/app/components/RouteInteraction.tsx index 802d402..36375f9 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.originalEvent); + L.DomEvent.stop(e as unknown as Event); }); map.on("mousemove", onMouseMove); diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index 217d6e7..a2e178a 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 { Button } from "@trails-cool/ui"; -import { computeSurfaceBreakdown } from "@trails-cool/map-core"; +import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; -import { buildPlanGpx } from "~/lib/gpx-export"; -import { readComputedRoute } from "~/lib/route-data"; +import { generateGpx } from "@trails-cool/gpx"; +import type { TrackPoint, NoGoArea } from "@trails-cool/gpx"; interface SaveToJournalButtonProps { yjs: YjsState; - sessionId: string; + callbackUrl: string; + callbackToken: string; returnUrl?: string; } -export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournalButtonProps) { +export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl }: SaveToJournalButtonProps) { const { t } = useTranslation("planner"); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); @@ -23,31 +23,42 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal setError(null); try { - // Full plan GPX (track + waypoints + no-go areas + notes) so the - // route round-trips correctly through the journal. - const gpx = buildPlanGpx(yjs); + // 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 */ } + } - // 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 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); - // 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", { + 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, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionId, gpx, ...(surfaceBreakdown ? { surfaceBreakdown } : {}) }), + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${callbackToken}`, + }, + body: JSON.stringify({ gpx }), }); if (!response.ok) { @@ -61,20 +72,21 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal } finally { setSaving(false); } - }, [yjs, sessionId]); + }, [yjs, callbackUrl, callbackToken]); 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 87f65ce..98783ee 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -1,19 +1,18 @@ 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 { Topbar } from "~/components/Topbar"; -import { useParticipants } from "~/lib/use-participants"; +import { ParticipantList } from "~/components/ParticipantList"; import { NotesPanel } from "~/components/NotesPanel"; const PlannerMap = lazy(() => @@ -114,22 +113,22 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction, addToast: (messa } -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 }) { +function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState; routeStats: ReturnType["routeStats"]; days: ReturnType; onWaypointHover: (index: number | null) => void }) { const { t } = useTranslation("planner"); const [tab, setTab] = useState<"waypoints" | "notes">("waypoints"); return ( -