From 0c52725aac5d10c2c4462109fcbdaa78b2ca7690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 02:48:27 +0200 Subject: [PATCH 001/572] Add Export Day Segments option for multi-day routes When a route has overnight waypoints, the export dropdown shows a third option: "Export Day Segments" which downloads one GPX file per day. Each file is named day-N-start-name.gpx with the day's track segment. Only visible when the route has multiple days. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ExportButton.tsx | 58 +++++++++++++++++++- packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index 94e757c..2b42b34 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -2,7 +2,7 @@ import { useCallback, useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; -import { generateGpx } from "@trails-cool/gpx"; +import { generateGpx, computeDays } from "@trails-cool/gpx"; import type { TrackPoint, NoGoArea } from "@trails-cool/gpx"; function getTracks(yjs: YjsState): TrackPoint[][] { @@ -74,6 +74,53 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { setOpen(false); }, [yjs]); + const handleExportDays = useCallback(() => { + const tracks = getTracks(yjs); + const waypoints = getWaypoints(yjs); + const allPoints = tracks.flat(); + if (allPoints.length === 0 || waypoints.length === 0) return; + + const days = computeDays(waypoints, tracks); + if (days.length <= 1) { + // Single day — just export the full route + const gpx = generateGpx({ name: "trails.cool route", tracks }); + download(gpx, "route.gpx"); + setOpen(false); + return; + } + + // Find closest track index for each waypoint + const wpTrackIndices = waypoints.map((wp) => { + let bestIdx = 0; + let bestDist = Infinity; + for (let i = 0; i < allPoints.length; i++) { + const dx = allPoints[i]!.lat - wp.lat; + const dy = allPoints[i]!.lon - wp.lon; + const d = dx * dx + dy * dy; + if (d < bestDist) { bestDist = d; bestIdx = i; } + } + return bestIdx; + }); + + for (const day of days) { + const startIdx = wpTrackIndices[day.startWaypointIndex]!; + const endIdx = wpTrackIndices[day.endWaypointIndex]!; + const dayPoints = allPoints.slice(startIdx, endIdx + 1); + const dayName = day.startName && day.endName + ? `Day ${day.dayNumber}: ${day.startName} - ${day.endName}` + : `Day ${day.dayNumber}`; + const gpx = generateGpx({ name: dayName, tracks: [dayPoints] }); + const filename = `day-${day.dayNumber}${day.startName ? `-${day.startName.toLowerCase().replace(/\s+/g, "-")}` : ""}.gpx`; + download(gpx, filename); + } + setOpen(false); + }, [yjs]); + + const hasMultipleDays = (() => { + const waypoints = getWaypoints(yjs); + return waypoints.some((w) => w.isDayBreak); + })(); + return (
@@ -106,6 +153,15 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { {t("exportPlan")} {t("exportPlanDesc")} + {hasMultipleDays && ( + + )}
)}
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 6b23c6d..615af15 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -22,6 +22,8 @@ export default { exportRouteDesc: "GPX-Track für jede App", exportPlan: "Plan exportieren", exportPlanDesc: "Mit Wegpunkten und Sperrzonen", + exportDays: "Tagesetappen exportieren", + exportDaysDesc: "Eine GPX-Datei pro Tag", "undo.tooltip": "Rückgängig (Strg+Z)", "redo.tooltip": "Wiederholen (Strg+Umschalt+Z)", importGpx: "GPX importieren", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index caca9b4..5957029 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -22,6 +22,8 @@ export default { exportRouteDesc: "Clean GPX track for any app", exportPlan: "Export Plan", exportPlanDesc: "Includes waypoints and no-go areas", + exportDays: "Export Day Segments", + exportDaysDesc: "One GPX file per day", "undo.tooltip": "Undo (Ctrl+Z)", "redo.tooltip": "Redo (Ctrl+Shift+Z)", importGpx: "Import GPX", From 3a0c40d9fd0be00fd6b79d8b70a6e7c109d4dbb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 02:48:27 +0200 Subject: [PATCH 002/572] Add Export Day Segments option for multi-day routes When a route has overnight waypoints, the export dropdown shows a third option: "Export Day Segments" which downloads one GPX file per day. Each file is named day-N-start-name.gpx with the day's track segment. Only visible when the route has multiple days. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ExportButton.tsx | 58 +++++++++++++++++++- packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index 94e757c..2b42b34 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -2,7 +2,7 @@ import { useCallback, useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; -import { generateGpx } from "@trails-cool/gpx"; +import { generateGpx, computeDays } from "@trails-cool/gpx"; import type { TrackPoint, NoGoArea } from "@trails-cool/gpx"; function getTracks(yjs: YjsState): TrackPoint[][] { @@ -74,6 +74,53 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { setOpen(false); }, [yjs]); + const handleExportDays = useCallback(() => { + const tracks = getTracks(yjs); + const waypoints = getWaypoints(yjs); + const allPoints = tracks.flat(); + if (allPoints.length === 0 || waypoints.length === 0) return; + + const days = computeDays(waypoints, tracks); + if (days.length <= 1) { + // Single day — just export the full route + const gpx = generateGpx({ name: "trails.cool route", tracks }); + download(gpx, "route.gpx"); + setOpen(false); + return; + } + + // Find closest track index for each waypoint + const wpTrackIndices = waypoints.map((wp) => { + let bestIdx = 0; + let bestDist = Infinity; + for (let i = 0; i < allPoints.length; i++) { + const dx = allPoints[i]!.lat - wp.lat; + const dy = allPoints[i]!.lon - wp.lon; + const d = dx * dx + dy * dy; + if (d < bestDist) { bestDist = d; bestIdx = i; } + } + return bestIdx; + }); + + for (const day of days) { + const startIdx = wpTrackIndices[day.startWaypointIndex]!; + const endIdx = wpTrackIndices[day.endWaypointIndex]!; + const dayPoints = allPoints.slice(startIdx, endIdx + 1); + const dayName = day.startName && day.endName + ? `Day ${day.dayNumber}: ${day.startName} - ${day.endName}` + : `Day ${day.dayNumber}`; + const gpx = generateGpx({ name: dayName, tracks: [dayPoints] }); + const filename = `day-${day.dayNumber}${day.startName ? `-${day.startName.toLowerCase().replace(/\s+/g, "-")}` : ""}.gpx`; + download(gpx, filename); + } + setOpen(false); + }, [yjs]); + + const hasMultipleDays = (() => { + const waypoints = getWaypoints(yjs); + return waypoints.some((w) => w.isDayBreak); + })(); + return (
@@ -106,6 +153,15 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { {t("exportPlan")} {t("exportPlanDesc")} + {hasMultipleDays && ( + + )}
)}
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 6b23c6d..615af15 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -22,6 +22,8 @@ export default { exportRouteDesc: "GPX-Track für jede App", exportPlan: "Plan exportieren", exportPlanDesc: "Mit Wegpunkten und Sperrzonen", + exportDays: "Tagesetappen exportieren", + exportDaysDesc: "Eine GPX-Datei pro Tag", "undo.tooltip": "Rückgängig (Strg+Z)", "redo.tooltip": "Wiederholen (Strg+Umschalt+Z)", importGpx: "GPX importieren", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index caca9b4..5957029 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -22,6 +22,8 @@ export default { exportRouteDesc: "Clean GPX track for any app", exportPlan: "Export Plan", exportPlanDesc: "Includes waypoints and no-go areas", + exportDays: "Export Day Segments", + exportDaysDesc: "One GPX file per day", "undo.tooltip": "Undo (Ctrl+Z)", "redo.tooltip": "Redo (Ctrl+Shift+Z)", importGpx: "Import GPX", From 5f21564a194b1410e5786e862972972e01d501a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 02:55:06 +0200 Subject: [PATCH 003/572] Replace notes textarea with collaborative CodeMirror editor - CodeMirror 6 + y-codemirror.next for proper character-level Yjs sync (replaces delete-all/insert-all textarea binding) - Remote cursor awareness: colored carets with participant names - Line wrapping, placeholder text, history (undo/redo) - Awareness user fields include colorLight for cursor selection tint Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/NotesPanel.tsx | 132 +++++++++++------- apps/planner/package.json | 6 + pnpm-lock.yaml | 151 +++++++++++++++++++++ 3 files changed, 243 insertions(+), 46 deletions(-) diff --git a/apps/planner/app/components/NotesPanel.tsx b/apps/planner/app/components/NotesPanel.tsx index caaf9bb..4b91620 100644 --- a/apps/planner/app/components/NotesPanel.tsx +++ b/apps/planner/app/components/NotesPanel.tsx @@ -1,66 +1,106 @@ -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { EditorView, keymap, placeholder } from "@codemirror/view"; +import { EditorState } from "@codemirror/state"; +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language"; +import { yCollab } from "y-codemirror.next"; import type { YjsState } from "~/lib/use-yjs"; interface NotesPanelProps { yjs: YjsState; } +const theme = EditorView.theme({ + "&": { + height: "100%", + fontSize: "13px", + }, + ".cm-editor": { + height: "100%", + }, + ".cm-scroller": { + overflow: "auto", + fontFamily: "inherit", + }, + ".cm-content": { + padding: "12px", + caretColor: "#1f2937", + }, + ".cm-line": { + lineHeight: "1.5", + }, + "&.cm-focused": { + outline: "none", + }, + // Remote cursor styling + ".cm-ySelectionInfo": { + fontSize: "10px", + fontFamily: "system-ui, sans-serif", + padding: "1px 4px", + borderRadius: "3px", + opacity: "0.9", + fontWeight: "500", + position: "absolute", + top: "-1.2em", + left: "-1px", + whiteSpace: "nowrap", + }, +}); + export function NotesPanel({ yjs }: NotesPanelProps) { - const textareaRef = useRef(null); - const isLocalChange = useRef(false); + const { t } = useTranslation("planner"); + const containerRef = useRef(null); + const viewRef = useRef(null); - // Sync Y.Text → textarea useEffect(() => { - const textarea = textareaRef.current; - if (!textarea) return; + if (!containerRef.current) return; - // Set initial value - textarea.value = yjs.notes.toString(); + // Set awareness user fields for cursor display + const localState = yjs.awareness.getLocalState() as Record | null; + const user = localState?.user as { name: string; color: string } | undefined; + if (user) { + yjs.awareness.setLocalStateField("user", { + ...user, + colorLight: user.color + "30", + }); + } - const observer = () => { - if (isLocalChange.current) return; - const pos = textarea.selectionStart; - textarea.value = yjs.notes.toString(); - textarea.selectionStart = pos; - textarea.selectionEnd = pos; + const state = EditorState.create({ + extensions: [ + keymap.of([...defaultKeymap, ...historyKeymap]), + history(), + syntaxHighlighting(defaultHighlightStyle), + EditorView.lineWrapping, + placeholder(t("notes.placeholder")), + theme, + yCollab(yjs.notes, yjs.awareness, { + undoManager: yjs.undoManager, + }), + ], + }); + + const view = new EditorView({ + state, + parent: containerRef.current, + }); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; }; - - yjs.notes.observe(observer); - return () => yjs.notes.unobserve(observer); - }, [yjs.notes]); - - // textarea → Y.Text - const handleInput = useCallback( - (e: React.FormEvent) => { - const textarea = e.currentTarget; - const newValue = textarea.value; - const currentValue = yjs.notes.toString(); - - if (newValue === currentValue) return; - - isLocalChange.current = true; - yjs.doc.transact(() => { - yjs.notes.delete(0, yjs.notes.length); - yjs.notes.insert(0, newValue); - }, "local"); - isLocalChange.current = false; - }, - [yjs.notes, yjs.doc], - ); + }, [yjs]); return (
-

Notes

-
-
-