diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index ed3313836..4c8e9d924 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -30,7 +30,7 @@ import {
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
- type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
+ type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
@@ -471,6 +471,8 @@ export const assignmentsApi = {
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
+ // Day-specific note on an assignment (#2163) — null clears it.
+ updateNotes: (tripId: number | string, id: number, data: AssignmentNotesRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/notes`, data).then(r => r.data),
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
// direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case
// and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape.
diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx
index 053a86e0d..6058cdd24 100644
--- a/client/src/components/PDF/TripPDF.tsx
+++ b/client/src/components/PDF/TripPDF.tsx
@@ -442,6 +442,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
${place.description ? `
${escHtml(place.description)}
` : ''}
${chips ? `
${chips}
` : ''}
${place.notes ? `
${escHtml(place.notes)}
` : ''}
+ ${item.data.notes ? `
${escHtml(item.data.notes)}
` : ''}
`
}).join('')
diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx
index e6aba3be1..61d7d5594 100644
--- a/client/src/components/Planner/DayPlanSidebar.test.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.test.tsx
@@ -405,6 +405,14 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText(/10:00/)).toBeInTheDocument()
})
+ it('FE-PLANNER-DAYPLAN-012b: the day-specific assignment note shows as a caption line in the row (#2163)', () => {
+ const place = buildPlace({ name: 'Louvre Museum' })
+ const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
+ const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place, notes: 'Book the 10:00 timed entry' })
+ render()
+ expect(screen.getByText('Book the 10:00 timed entry')).toBeInTheDocument()
+ })
+
it('FE-PLANNER-DAYPLAN-013: clicking a place calls onPlaceClick', async () => {
const user = userEvent.setup()
const place = buildPlace({ id: 42, name: 'Louvre Museum' })
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 7fd3038fa..836c938e1 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { safeHttpUrl } from '../../utils/safeUrl'
-import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
+import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, StickyNote, TramFront, Zap } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
@@ -2132,6 +2132,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{place.description || place.address || cat?.name || ''}
)}
+ {assignment.notes && (
+ // Day-specific note on this stop (#2163) — one muted
+ // caption line so the timeline shows the note exists
+ // without swallowing the row.
+
+
+ {assignment.notes}
+
+ )}
{(() => {
const res = reservations.find(r => r.assignment_id === assignment.id)
if (!res) return null
diff --git a/client/src/components/Planner/PlaceFormModal.helpers.ts b/client/src/components/Planner/PlaceFormModal.helpers.ts
index 28861ecff..dea1f9907 100644
--- a/client/src/components/Planner/PlaceFormModal.helpers.ts
+++ b/client/src/components/Planner/PlaceFormModal.helpers.ts
@@ -19,6 +19,11 @@ export interface PlaceFormData {
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
// it, and places.service already writes image_url through on create/update.
image_url?: string
+ // Day-specific note on the in-context assignment (#2163). Only hydrated when
+ // the form opened with an assignment in context; both forms drop it from the
+ // submit payload when unchanged, and useTripPlanner strips it off the place
+ // update and PUTs it per assignment instead.
+ assignment_notes?: string
}
export function isGoogleMapsUrl(input: string): boolean {
diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx
index 9d1889d91..d5d9b6f7e 100644
--- a/client/src/components/Planner/PlaceFormModal.tsx
+++ b/client/src/components/Planner/PlaceFormModal.tsx
@@ -143,6 +143,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
+ // The day-specific note rides only with an assignment in context (#2163);
+ // otherwise the key stays absent so submit never sends a notes write.
+ ...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
})
} else if (prefillCoords) {
setForm({
@@ -471,13 +474,22 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
setIsSaving(true)
try {
- const saved = await onSave({
+ const payload = {
...form,
lat: form.lat ? Number.parseFloat(form.lat) : null,
lng: form.lng ? Number.parseFloat(form.lng) : null,
category_id: form.category_id || null,
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
- })
+ }
+ // #2163: the per-assignment note only travels when an assignment is in
+ // context AND the value actually changed — an untouched note must not
+ // produce a PUT (a legacy note longer than the textarea would otherwise
+ // be re-sent on every unrelated save).
+ const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
+ if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) {
+ delete payload.assignment_notes
+ }
+ const saved = await onSave(payload)
// Open the Costs editor for the saved place when the user asked to
// create/edit its linked expense — gated on an id, so a create that the
// server refused never opens an editor pointing at nothing (#1298).
@@ -924,6 +936,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
/>
)}
+ {/* Day-specific note — like the times, it lives on the assignment, not
+ the pool place (#2163): only editable with one in context. */}
+ {!!(place && assignmentId) && (
+
+
+
+ )}
+
{/* Website */}
diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx
index da54804c1..57c01818c 100644
--- a/client/src/components/Planner/PlaceInspector.test.tsx
+++ b/client/src/components/Planner/PlaceInspector.test.tsx
@@ -1402,4 +1402,53 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
});
+ // ── Day-specific assignment note (#2163) ─────────────────────────────────────
+
+ it('FE-PLANNER-INSPECTOR-099: the assignment note renders as markdown under its own eyebrow', () => {
+ const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: 'Book the **10:00** timed entry' }];
+ render(
+
+ );
+ expect(screen.getByText('Notes for this day')).toBeTruthy();
+ const strong = Array.from(document.querySelectorAll('strong')).find(el => el.textContent === '10:00');
+ expect(strong).toBeTruthy();
+ });
+
+ it('FE-PLANNER-INSPECTOR-100: without a note (or without a day in context) the block stays away', () => {
+ const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
+ const { rerender } = render(
+
+ );
+ expect(screen.queryByText('Notes for this day')).toBeNull();
+ // A note exists but no day is selected — nothing to attribute it to, so no block.
+ rerender(
+
+ );
+ expect(screen.queryByText('Notes for this day')).toBeNull();
+ });
+
+ it('FE-PLANNER-INSPECTOR-101: the reservation strip opts out of the press-scale (#2158)', () => {
+ // jsdom cannot replay the browser mechanics: the :active scale on the
+ // composite strip slid the links inside out from under the pointer, so the
+ // click landed elsewhere. The data-no-press attribute is the pin.
+ const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
+ const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
+ render(
+
+ );
+ expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
+ });
+
});
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx
index b3768fafb..81639752d 100644
--- a/client/src/components/Planner/PlaceInspector.tsx
+++ b/client/src/components/Planner/PlaceInspector.tsx
@@ -6,7 +6,7 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
-import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route } from 'lucide-react'
+import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route, StickyNote } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
import PlaceRating from '../shared/StarRating'
@@ -428,6 +428,20 @@ export default function PlaceInspector({
)}
+ {/* Day-specific assignment note (#2163) — written via MCP/API or the
+ edit form; distinct from the pool-wide place.notes above, so it
+ carries an eyebrow saying which day-scope it belongs to. */}
+ {assignmentInDay?.notes && (
+
+
+ {t('places.assignmentNotes')}
+
+
+ {assignmentInDay.notes}
+
+
+ )}
+
{/* Reservation + Participants — trip-only (collections have no days) */}
{mode === 'trip' && (
{sub}}
)}
+ {assignment.notes && (
+ // Day-specific note on this stop (#2163) — mirror of the desktop
+ // timeline's caption line, so the phone shows the note exists
+ // without opening the place sheet.
+
+
+ {assignment.notes}
+
+ )}
{chrome.editing && (
diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx
index bc549e7f4..7d6d3a32f 100644
--- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx
@@ -111,6 +111,9 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
notes: editingPlace.notes || '',
transport_mode: editingPlace.transport_mode || 'walking',
website: editingPlace.website || '',
+ // The day-specific note rides only with an assignment in context (#2163);
+ // otherwise the key stays absent so submit never sends a notes write.
+ ...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
})
} else if (prefillCoords) {
setForm({
@@ -237,13 +240,21 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
expenseIntentRef.current = false
setIsSaving(true)
try {
- const saved = await handleSavePlace({
+ const payload = {
...form,
lat: form.lat ? Number.parseFloat(form.lat) : null,
lng: form.lng ? Number.parseFloat(form.lng) : null,
category_id: form.category_id || null,
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
- })
+ }
+ // #2163: the per-assignment note only travels when an assignment is in
+ // context AND the value actually changed — same dirty-check as the
+ // desktop form, so an untouched note never produces a PUT.
+ const ctxAssignment = sheetAssignmentId ? dayAssignments.find(a => a.id === sheetAssignmentId) : null
+ if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) {
+ delete payload.assignment_notes
+ }
+ const saved = await handleSavePlace(payload)
const savedId = saved?.id ?? sheetPlace?.id ?? null
if (withExpense && savedId) {
onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } })
@@ -348,17 +359,28 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
{t('places.formCategory')} handleChange('category_id', id)} />
- {/* Times live per day-assignment — only editable when one is in context. */}
+ {/* Times live per day-assignment — only editable when one is in context.
+ Same for the day-specific note (#2163). */}
{sheetPlace && sheetAssignmentId && (
-
+ <>
+
+ {t('places.assignmentNotes')}
+