diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx
index 3e3118c91..0cb1d8e4a 100644
--- a/client/src/components/Planner/PlacesSidebar.test.tsx
+++ b/client/src/components/Planner/PlacesSidebar.test.tsx
@@ -650,3 +650,23 @@ describe('touch device at desktop width (#1432)', () => {
expect(screen.getByText('Drop to import')).toBeInTheDocument();
});
});
+
+// The map draws no legend of its own, so the stroke in the row is the only
+// thing tying a coloured line back to a place (#776).
+describe('track colour legend (#776)', () => {
+ it('FE-PLANNER-SIDEBAR-049: a track row carries a stroke in the colour the map draws', () => {
+ const track = buildPlace({ id: 11, name: 'Coloured Track', route_geometry: '[[48.0,2.0],[49.0,3.0]]', route_color: '#e11d48' });
+ render();
+ const row = screen.getByText('Coloured Track').closest('div[draggable]')!;
+ const strokes = Array.from(row.querySelectorAll('span')).filter(el => (el as HTMLElement).style.borderRadius === '999px');
+ expect(strokes.some(el => (el as HTMLElement).style.background.includes('225, 29, 72') || (el as HTMLElement).style.background.includes('#e11d48'))).toBe(true);
+ });
+
+ it('FE-PLANNER-SIDEBAR-050: a place without geometry gets no stroke at all', () => {
+ const plain = buildPlace({ id: 12, name: 'Plain Place' });
+ render();
+ const row = screen.getByText('Plain Place').closest('div[draggable]')!;
+ const strokes = Array.from(row.querySelectorAll('span')).filter(el => (el as HTMLElement).style.borderRadius === '999px');
+ expect(strokes).toHaveLength(0);
+ });
+});
diff --git a/client/src/components/Planner/PlacesSidebarRow.tsx b/client/src/components/Planner/PlacesSidebarRow.tsx
index f047ff7d4..936c3dac0 100644
--- a/client/src/components/Planner/PlacesSidebarRow.tsx
+++ b/client/src/components/Planner/PlacesSidebarRow.tsx
@@ -1,7 +1,8 @@
import React from 'react'
-import { Plus, Check, Route, Star } from 'lucide-react'
+import { Plus, Check, Star } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
+import { resolveTrackColor } from '../Map/trackColors'
import type { Place, Category } from '../../types'
interface MemoPlaceRowProps {
@@ -81,7 +82,15 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
- {hasGeometry && }
+ {/* A stroke of the colour the track is drawn in — the map has no legend
+ of its own, so this is what tells you which line is this row (#776).
+ A line rather than another 11px icon, so it doesn't read as a second
+ category glyph next to the one below. */}
+ {hasGeometry && (
+
+
+
+ )}
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return
diff --git a/client/src/components/shared/TrackColorPicker.test.tsx b/client/src/components/shared/TrackColorPicker.test.tsx
new file mode 100644
index 000000000..92b2123d8
--- /dev/null
+++ b/client/src/components/shared/TrackColorPicker.test.tsx
@@ -0,0 +1,56 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { render, screen } from '../../../tests/helpers/render'
+import { fireEvent } from '@testing-library/react'
+import { resetAllStores } from '../../../tests/helpers/store'
+import TrackColorPicker from './TrackColorPicker'
+
+afterEach(() => {
+ vi.clearAllMocks()
+ resetAllStores()
+})
+
+function setup(props: Partial> = {}) {
+ const onChange = vi.fn()
+ render()
+ return { onChange }
+}
+
+describe('TrackColorPicker (#776)', () => {
+ it('FE-COMP-TRACKCOLOR-001: a swatch commits its colour', () => {
+ const { onChange } = setup()
+ fireEvent.click(screen.getByRole('button', { name: '#059669' }))
+ expect(onChange).toHaveBeenCalledWith('#059669')
+ })
+
+ it('FE-COMP-TRACKCOLOR-002: the auto cell commits null, not undefined', () => {
+ const { onChange } = setup({ value: '#059669' })
+ fireEvent.click(screen.getByRole('button', { name: 'Automatic color' }))
+ expect(onChange).toHaveBeenCalledWith(null)
+ })
+
+ it('FE-COMP-TRACKCOLOR-003: the auto cell previews what clearing would give, not the current colour', () => {
+ setup({ value: '#e11d48' })
+ const auto = screen.getByRole('button', { name: 'Automatic color' })
+ // jsdom rewrites the hex to rgb(). #16a34a is the inherited colour; showing
+ // the picked #e11d48 here would promise the opposite of what the button does.
+ expect(auto.getAttribute('style')).toContain('rgb(22, 163, 74)')
+ expect(auto.getAttribute('style')).not.toContain('rgb(225, 29, 72)')
+ })
+
+ it('FE-COMP-TRACKCOLOR-004: dragging inside the native picker does not commit', () => {
+ const { onChange } = setup()
+ const input = document.querySelector('input[type="color"]') as HTMLInputElement
+ expect(input).toBeTruthy()
+
+ // React maps onChange on a colour input to the native `input` event, which
+ // fires on every drag — each one would be a PUT and a 409 candidate.
+ input.value = '#123456'
+ fireEvent.input(input)
+ expect(onChange).not.toHaveBeenCalled()
+
+ // The native `change` fires once, when the picker closes.
+ fireEvent.change(input)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith('#123456')
+ })
+})
diff --git a/client/src/components/shared/TrackColorPicker.tsx b/client/src/components/shared/TrackColorPicker.tsx
new file mode 100644
index 000000000..fa7518761
--- /dev/null
+++ b/client/src/components/shared/TrackColorPicker.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useRef } from 'react'
+import { Pipette, RotateCcw } from 'lucide-react'
+import { TRACK_COLORS } from '@trek/shared'
+import { useTranslation } from '../../i18n'
+
+interface TrackColorPickerProps {
+ /** The picked colour, or null while the track still inherits one. */
+ value: string | null
+ /** What the track is drawn in when nothing is picked — shown in the auto cell. */
+ inheritedColor: string
+ onChange: (color: string | null) => void
+}
+
+/**
+ * Swatch row for a GPX track's line colour (#776).
+ *
+ * First cell resets to the inherited colour, then the shared palette, then a
+ * native picker for anything else. Selection is drawn with `--accent` so the
+ * row follows whatever accent the user runs rather than pinning one hue.
+ */
+export default function TrackColorPicker({ value, inheritedColor, onChange }: TrackColorPickerProps) {
+ const { t } = useTranslation()
+ const customInputRef = useRef(null)
+ const isPreset = value !== null && (TRACK_COLORS as readonly string[]).includes(value)
+ const selected = 'outline outline-2 outline-[var(--accent)] outline-offset-2 scale-110'
+
+ const onChangeRef = useRef(onChange)
+ onChangeRef.current = onChange
+
+ // Native `change`, deliberately not React's onChange — see the input below.
+ useEffect(() => {
+ const input = customInputRef.current
+ if (!input) return
+ const commit = () => onChangeRef.current(input.value)
+ input.addEventListener('change', commit)
+ return () => input.removeEventListener('change', commit)
+ }, [])
+
+ return (
+
+
+
+ {TRACK_COLORS.map(color => (
+
+ )
+}
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 485244edc..ff0a356ce 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -565,6 +565,9 @@ export function useTripPlanner() {
address: capturedPlace.address,
category_id: capturedPlace.category_id,
price: capturedPlace.price,
+ // An undone track has to come back as a track, not a bare point.
+ route_geometry: capturedPlace.route_geometry,
+ route_color: capturedPlace.route_color,
})
for (const { dayId, orderIndex } of capturedAssignments) {
await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex)
@@ -595,6 +598,7 @@ export function useTripPlanner() {
name: place.name, description: place.description,
lat: place.lat, lng: place.lng, address: place.address,
category_id: place.category_id, price: place.price,
+ route_geometry: place.route_geometry, route_color: place.route_color,
})
for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)