feat(planner): surface and edit day-assignment notes across desktop, mobile and pdf (#2163)

This commit is contained in:
Maurice
2026-09-01 15:26:30 +02:00
committed by Maurice
parent d1aa573167
commit c21e86e3bd
68 changed files with 567 additions and 21 deletions
+3 -1
View File
@@ -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.
+1
View File
@@ -442,6 +442,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
</div>
</div>`
}).join('')
@@ -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(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />)
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' })
@@ -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
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
)}
{assignment.notes && (
// Day-specific note on this stop (#2163) — one muted
// caption line so the timeline shows the note exists
// without swallowing the row.
<div title={t('places.assignmentNotes')} style={{ marginTop: 2, display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden' }}>
<StickyNote size={9} strokeWidth={2} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>{assignment.notes}</span>
</div>
)}
{(() => {
const res = reservations.find(r => r.assignment_id === assignment.id)
if (!res) return null
@@ -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 {
@@ -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) && (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.assignmentNotes')}</label>
<textarea
value={form.assignment_notes ?? ''}
onChange={e => handleChange('assignment_notes', e.target.value)}
rows={2}
placeholder={t('places.assignmentNotesPlaceholder')}
className="form-input" style={{ resize: 'vertical' }}
/>
</div>
)}
{/* Website */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formWebsite')}</label>
@@ -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(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
/>
);
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(
<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={99} assignments={{ '1': assignmentInDay }} />
);
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(
<PlaceInspector {...defaultProps} selectedDayId={null} assignments={{ '1': [{ ...assignmentInDay[0], notes: 'hidden' }] }} />
);
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(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
reservations={[reservation]}
onEditReservation={vi.fn()}
/>
);
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
});
});
@@ -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({
</div>
)}
{/* 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 && (
<div className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, padding: '8px 12px' }}>
<div className="text-content-faint" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 3 }}>
<StickyNote size={10} /> {t('places.assignmentNotes')}
</div>
<div className="collab-note-md text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{assignmentInDay.notes}</Markdown>
</div>
</div>
)}
{/* Reservation + Participants — trip-only (collections have no days) */}
{mode === 'trip' && (
<PlaceReservationParticipants selectedAssignmentId={selectedAssignmentId} reservations={reservations}
@@ -847,6 +861,10 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
return (
<div
role={open ? 'button' : undefined}
// No press-scale on the composite strip — shrinking it
// mid-click slides the links inside out from under the
// pointer (#2158).
data-no-press
aria-label={open ? t('inspector.editRes') : undefined}
tabIndex={open ? 0 : undefined}
onClick={open}
@@ -1,4 +1,4 @@
import { BedDouble, Car, ChevronDown, ChevronUp, Clock, Footprints, Pencil, Route, Ticket, X, Zap } from 'lucide-react'
import { BedDouble, Car, ChevronDown, ChevronUp, Clock, Footprints, Pencil, Route, StickyNote, Ticket, X, Zap } from 'lucide-react'
import type { ReactNode, MouseEvent, CSSProperties } from 'react'
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
@@ -188,6 +188,15 @@ export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, dr
{sub && <span className="min-w-0 truncate font-geist text-[0.71875rem] text-m-muted">{sub}</span>}
</div>
)}
{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.
<div className="mt-[2px] flex min-w-0 items-center gap-1">
<StickyNote size={10} strokeWidth={2.2} className="flex-none text-m-faint" />
<span className="min-w-0 truncate font-geist text-[0.65625rem] text-m-faint">{assignment.notes}</span>
</div>
)}
</div>
{chrome.editing && (
<span className="flex flex-none items-center gap-1.5">
@@ -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
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('places.formCategory')}</Eyebrow>
<PlCategoryPicker planner={planner} value={form.category_id} onChange={id => 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 && (
<PlTimeFields
planner={planner}
startTime={form.place_time}
endTime={form.end_time}
onChange={handleChange}
assignmentId={sheetAssignmentId}
dayAssignments={dayAssignments}
hasTimeError={hasTimeError}
/>
<>
<PlTimeFields
planner={planner}
startTime={form.place_time}
endTime={form.end_time}
onChange={handleChange}
assignmentId={sheetAssignmentId}
dayAssignments={dayAssignments}
hasTimeError={hasTimeError}
/>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('places.assignmentNotes')}</Eyebrow>
<textarea
value={form.assignment_notes ?? ''}
onChange={e => handleChange('assignment_notes', e.target.value)}
rows={2}
placeholder={t('places.assignmentNotesPlaceholder')}
className={FIELD_AREA_CLS}
/>
</>
)}
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('places.formWebsite')}</Eyebrow>
@@ -432,6 +432,26 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
</div>
)}
{/* ── Day-specific notes on the assignments (#2163) — the day label
only appears when the place sits on more than one day. ── */}
{placeAssignments.some(r => r.assignment.notes) && (
<>
<Eyebrow className="mb-[6px] mt-3">{t('mobileTrip.assignmentNotes')}</Eyebrow>
<div className="flex flex-col gap-[6px]">
{placeAssignments.filter(r => r.assignment.notes).map(({ day, assignment }) => (
<div key={assignment.id} className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
{placeAssignments.length > 1 && (
<div className="mb-[2px] font-geist text-[0.625rem] font-semibold text-m-faint">
{day.title || t('planner.dayN', { n: (day.day_number ?? planner.days.indexOf(day) + 1) || '?' })}
</div>
)}
<div className="whitespace-pre-wrap font-geist text-[0.75rem] leading-[1.5] text-m-muted">{assignment.notes}</div>
</div>
))}
</div>
</>
)}
{/* ── The booking attached to this stop (#2012) ── */}
{linkedRes && (
<>
@@ -193,6 +193,7 @@ beforeEach(() => {
vi.spyOn(tripsApi, 'getMembers').mockResolvedValue({ owner: null, members: [] })
vi.spyOn(accommodationsApi, 'list').mockResolvedValue({ accommodations: [] })
vi.spyOn(assignmentsApi, 'updateTime').mockResolvedValue({})
vi.spyOn(assignmentsApi, 'updateNotes').mockResolvedValue({})
vi.spyOn(airtrailApi, 'sync').mockResolvedValue({ changed: 0 })
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: '', address: '' } as never)
vi.spyOn(mapsApi, 'search').mockResolvedValue({ places: [] } as never)
@@ -1095,6 +1096,48 @@ describe('useTripPlanner — place CRUD', () => {
expect(actions.refreshDays).toHaveBeenCalledWith(42)
})
it('FE-TP-HOOK-053b: a changed assignment note is stripped off the place and PUT per assignment (#2163)', async () => {
const place = buildPlace({ id: 1, lat: 1, lng: 2 })
seedTrip({
places: [place],
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place })] },
})
const { result } = await renderPlanner()
act(() => { result.current.openPlaceEditor(place) })
await act(async () => {
await result.current.handleSavePlace({ name: 'Nara', assignment_notes: 'Book the 10:00 entry' })
})
expect(actions.updatePlace).toHaveBeenCalledWith(42, 1, { name: 'Nara' })
expect(assignmentsApi.updateNotes).toHaveBeenCalledWith(42, 10, { notes: 'Book the 10:00 entry' })
expect(actions.refreshDays).toHaveBeenCalledWith(42)
})
it('FE-TP-HOOK-053c: without assignment_notes in the payload no notes write happens; an empty string clears (#2163)', async () => {
const place = buildPlace({ id: 1, lat: 1, lng: 2 })
seedTrip({
places: [place],
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place })] },
})
const { result } = await renderPlanner()
act(() => { result.current.openPlaceEditor(place) })
// The form strips an untouched note, so the key is simply absent.
await act(async () => {
await result.current.handleSavePlace({ name: 'Nara' })
})
expect(assignmentsApi.updateNotes).not.toHaveBeenCalled()
// An empty string is an explicit clear and goes out as null.
await act(async () => {
await result.current.handleSavePlace({ name: 'Nara', assignment_notes: '' })
})
expect(assignmentsApi.updateNotes).toHaveBeenCalledWith(42, 10, { notes: null })
})
it('FE-TP-HOOK-054: editing an unassigned place skips the per-assignment time write', async () => {
const place = buildPlace({ id: 1, lat: 1, lng: 2 })
seedTrip({ places: [place] })
@@ -580,12 +580,19 @@ export function useTripPlanner() {
const pendingFiles = data._pendingFiles
delete data._pendingFiles
if (editingPlace) {
// Always strip time fields from place update — time is per-assignment only
const { place_time, end_time, ...placeData } = data
// Always strip time fields from place update — time is per-assignment only.
// Same for the day-specific note (#2163): it belongs to the assignment,
// never to the pool place.
const { place_time, end_time, assignment_notes, ...placeData } = data
await tripActions.updatePlace(tripId, editingPlace.id, placeData)
// If editing from assignment context, save time per-assignment
if (editingAssignmentId) {
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
// The form only includes assignment_notes when the user changed it, so
// an untouched note never produces a PUT (#2163). '' clears like null.
if (assignment_notes !== undefined) {
await assignmentsApi.updateNotes(tripId, editingAssignmentId, { notes: assignment_notes || null })
}
await tripActions.refreshDays(tripId)
}
// Upload pending files with place_id
@@ -17,6 +17,7 @@ import {
AssignmentReorderDto,
AssignmentMoveDto,
AssignmentTimeDto,
AssignmentNotesDto,
AssignmentTransportDto,
AssignmentParticipantsDto,
} from './assignments.dto';
@@ -171,6 +172,26 @@ export class AssignmentOpsController {
return { assignment };
}
// #2163: the per-assignment note was write-once (create bodies, MCP, plugin
// RPC) with no edit path anywhere. Same guard shape and 404 body as its
// neighbours; no reconcile — the note doesn't touch the journey skeleton.
@RequirePermission('day_edit')
@Put(':id/notes')
notes(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: AssignmentNotesDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = this.assignments.updateNotes(id, body.notes);
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
return { assignment };
}
@RequirePermission('day_edit')
@Put(':id/transport')
transport(
@@ -4,6 +4,7 @@ import {
assignmentReorderRequestSchema,
assignmentMoveRequestSchema,
assignmentTimeRequestSchema,
assignmentNotesRequestSchema,
assignmentTransportRequestSchema,
assignmentParticipantsRequestSchema,
} from '@trek/shared';
@@ -19,5 +20,6 @@ export class AssignmentCreateDto extends createZodDto(assignmentCreateRequestSch
export class AssignmentReorderDto extends createZodDto(assignmentReorderRequestSchema) {}
export class AssignmentMoveDto extends createZodDto(assignmentMoveRequestSchema) {}
export class AssignmentTimeDto extends createZodDto(assignmentTimeRequestSchema) {}
export class AssignmentNotesDto extends createZodDto(assignmentNotesRequestSchema) {}
export class AssignmentTransportDto extends createZodDto(assignmentTransportRequestSchema) {}
export class AssignmentParticipantsDto extends createZodDto(assignmentParticipantsRequestSchema) {}
@@ -115,6 +115,33 @@ export class AssignmentsMcp {
return ok({ assignment });
}
@Tool({
name: 'update_assignment_notes',
description: 'Set or clear the day-specific note on a place assignment (the note assign_place_to_day and create_and_assign_place accept at creation). Pass null or an empty string to clear it.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
// Mirrors the REST contract (assignmentNotesRequestSchema): uncapped on
// purpose, unlike the create tools' max(500) — a longer note written
// through REST or the plugin RPC must stay editable here (#2163).
notes: z.string().nullable().describe('The note text, or null to clear'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'places', mode: 'write' },
})
async updateAssignmentNotes(
{ tripId, assignmentId, notes }: { tripId: number; assignmentId: number; notes: string | null },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const assignment = this.assignments.updateNotes(assignmentId, notes);
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
@Tool({
name: 'set_leg_transport_mode',
description: 'Set the travel mode of a route leg for a place assignment. Use direction "outgoing" (default) for the common case: the leg leaving this stop toward the next. Use direction "incoming" ONLY when this stop\'s arriving leg originates from something that is not itself a place assignment (e.g. a flight/train booking arrival, or a morning hotel departure) – setting "incoming" on an ordinary place-to-place leg is stored but has no effect on route rendering, because it targets a column that is only read for non-place origins. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:<pluginId>/<profileId>". Any other value is stored but drawn as a driving route. null clears it so the leg inherits the day default.',
@@ -261,6 +261,19 @@ export class AssignmentsService {
return this.getAssignmentWithPlace(Number(id));
}
/**
* Edit the per-assignment note after creation (#2163) — until now the note
* was write-once via the create paths (REST body, MCP tools, plugin RPC) and
* invisible in the app. Falsy notes ('' or null) clear the column, the same
* `notes || null` normalisation createAssignment applies. No auto-sort and no
* journey reconcile: the note affects neither the day order nor the skeleton
* mirror (same as the transport-mode writes).
*/
updateNotes(id: string | number, notes: string | null | undefined) {
this.dbs.run('UPDATE day_assignments SET notes = ? WHERE id = ?', notes || null, id);
return this.getAssignmentWithPlace(Number(id));
}
/**
* Set the travel mode of the leg leaving this stop (#1281). null clears the
* override so the leg falls back to the day's default_transport_mode. This is
+59
View File
@@ -155,6 +155,44 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined);
});
it('200 notes roundtrip: create with note, PUT edits it, GET list shows the new value (#2163)', async () => {
const create = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1))
.send({ place_id: 2, notes: 'Book the 10:00 timed entry' });
expect(create.status).toBe(201);
expect(create.body.assignment.notes).toBe('Book the 10:00 timed entry');
const id = create.body.assignment.id;
const put = await request(server)
.put(`/api/trips/5/assignments/${id}/notes`)
.set('Cookie', sessionCookie(1))
.send({ notes: 'Arrive 15 minutes early' });
expect(put.status).toBe(200);
expect(put.body.assignment).toMatchObject({ id, notes: 'Arrive 15 minutes early' });
expect(db.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(id)).toEqual({ notes: 'Arrive 15 minutes early' });
const list = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1));
expect(list.status).toBe(200);
expect(list.body.assignments.find((a: { id: number }) => a.id === id).notes).toBe('Arrive 15 minutes early');
});
it('200 notes clear: null and empty string both null the column (#2163)', async () => {
const id = seedAssignment();
db.prepare('UPDATE day_assignments SET notes = ? WHERE id = ?').run('old note', id);
const cleared = await request(server).put(`/api/trips/5/assignments/${id}/notes`).set('Cookie', sessionCookie(1)).send({ notes: null });
expect(cleared.status).toBe(200);
expect(cleared.body.assignment.notes).toBeNull();
db.prepare('UPDATE day_assignments SET notes = ? WHERE id = ?').run('old note', id);
const emptied = await request(server).put(`/api/trips/5/assignments/${id}/notes`).set('Cookie', sessionCookie(1)).send({ notes: '' });
expect(emptied.status).toBe(200);
expect(db.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(id)).toEqual({ notes: null });
});
it('400 notes body without the notes key is rejected by the Zod pipe (#2163)', async () => {
const id = seedAssignment();
const res = await request(server).put(`/api/trips/5/assignments/${id}/notes`).set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(400);
});
it('200 update time reconciles journey skeletons', async () => {
reconcileTripSkeletons.mockClear();
const id = seedAssignment();
@@ -275,6 +313,16 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
expect(db.prepare('SELECT COUNT(*) AS n FROM assignment_participants WHERE assignment_id = ?').get(id)).toEqual({ n: 0 });
});
it('404s a notes change on a foreign assignment instead of editing it (#2163)', async () => {
const id = seedForeignAssignment();
const res = await request(server)
.put(`/api/trips/${FOREIGN_TRIP}/assignments/${id}/notes`)
.set('Cookie', sessionCookie(1))
.send({ notes: 'hijacked' });
expect(res.status).toBe(404);
expect(db.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(id)).toEqual({ notes: null });
});
it('403s when the caller is on the trip but lacks day_edit', async () => {
const id = seedAssignment();
checkPermission.mockReturnValue(false);
@@ -284,5 +332,16 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
.send({ place_time: '09:00', end_time: null });
expect(res.status).toBe(403);
});
it('403s a notes change without day_edit (#2163)', async () => {
const id = seedAssignment();
checkPermission.mockReturnValue(false);
const res = await request(server)
.put(`/api/trips/5/assignments/${id}/notes`)
.set('Cookie', sessionCookie(1))
.send({ notes: 'nope' });
expect(res.status).toBe(403);
expect(db.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(id)).toEqual({ notes: null });
});
});
});
@@ -1,6 +1,6 @@
/**
* Unit tests for MCP assignment tools: assign_place_to_day, unassign_place,
* reorder_day_assignments, update_assignment_time.
* reorder_day_assignments, update_assignment_time, update_assignment_notes.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -408,3 +408,85 @@ describe('Tool: update_assignment_time', () => {
});
});
});
// ---------------------------------------------------------------------------
// update_assignment_notes (#2163)
// ---------------------------------------------------------------------------
describe('Tool: update_assignment_notes', () => {
it('sets the day-specific note on an assignment', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_assignment_notes',
arguments: { tripId: trip.id, assignmentId: assignment.id, notes: 'Book the 10:00 timed entry and arrive 15 minutes early' },
});
const data = parseToolResult(result) as any;
expect(data.assignment.notes).toBe('Book the 10:00 timed entry and arrive 15 minutes early');
expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(assignment.id)).toEqual({ notes: 'Book the 10:00 timed entry and arrive 15 minutes early' });
});
});
it('clears the note with null and with an empty string', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET notes = ? WHERE id = ?').run('old', assignment.id);
await withHarness(user.id, async (h) => {
const cleared = parseToolResult(await h.client.callTool({
name: 'update_assignment_notes',
arguments: { tripId: trip.id, assignmentId: assignment.id, notes: null },
})) as any;
expect(cleared.assignment.notes).toBeNull();
testDb.prepare('UPDATE day_assignments SET notes = ? WHERE id = ?').run('old', assignment.id);
const emptied = parseToolResult(await h.client.callTool({
name: 'update_assignment_notes',
arguments: { tripId: trip.id, assignmentId: assignment.id, notes: '' },
})) as any;
expect(emptied.assignment.notes).toBeNull();
});
});
it('broadcasts assignment:updated event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_assignment_notes', arguments: { tripId: trip.id, assignmentId: assignment.id, notes: 'n' } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object));
});
});
it('returns error when assignment not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_notes', arguments: { tripId: trip.id, assignmentId: 99999, notes: 'n' } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_notes', arguments: { tripId: trip.id, assignmentId: assignment.id, notes: 'n' } });
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(assignment.id)).toEqual({ notes: null });
});
});
});
@@ -94,6 +94,17 @@ describe('AssignmentOpsController (parity with the per-assignment op routes)', (
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
it('PUT /:id/notes 404 missing, else updates + broadcasts (#2163)', () => {
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial<AssignmentsService>)).notes(user, '5', '9', { notes: 'n' }))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
const updateNotes = vi.fn().mockReturnValue({ id: 9, notes: 'Book the 10:00 entry' }); const broadcast = vi.fn(); const reconcile = vi.fn();
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), updateNotes, broadcast, reconcile } as Partial<AssignmentsService>);
expect(new AssignmentOpsController(s).notes(user, '5', '9', { notes: 'Book the 10:00 entry' }, 'sock')).toEqual({ assignment: { id: 9, notes: 'Book the 10:00 entry' } });
expect(updateNotes).toHaveBeenCalledWith('9', 'Book the 10:00 entry');
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:updated', { assignment: { id: 9, notes: 'Book the 10:00 entry' } }, 'sock');
// The note touches neither day order nor the journey skeleton — no reconcile.
expect(reconcile).not.toHaveBeenCalled();
});
it('PUT /:id/participants 404 on a foreign assignment, else sets + broadcasts (non-array bodies are the Zod pipe\'s 400, covered in e2e)', () => {
const setParticipants = vi.fn().mockReturnValue([{ user_id: 2 }]); const broadcast = vi.fn();
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined), setParticipants } as Partial<AssignmentsService>)).setParticipants(user, '5', '9', { user_ids: [2] })))
@@ -407,6 +407,35 @@ describe('updateTime', () => {
});
});
describe('updateNotes (#2163)', () => {
it('ASG-SVC-030: persists the note and re-selects the nested shape', () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
const updated = svc.updateNotes(a.id, 'Book the 10:00 timed entry');
expect(updated).toMatchObject({ id: a.id, notes: 'Book the 10:00 timed entry' });
expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ notes: 'Book the 10:00 timed entry' });
});
it('ASG-SVC-031: empty string and null both clear the column (same normalisation as createAssignment)', () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
svc.updateNotes(a.id, 'gone soon');
expect(svc.updateNotes(a.id, '')!.notes).toBeNull();
svc.updateNotes(a.id, 'gone again');
expect(svc.updateNotes(a.id, null)!.notes).toBeNull();
expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ notes: null });
});
it('ASG-SVC-032: leaves order and times untouched', () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 });
svc.updateTime(a.id, '09:00', '10:00');
const updated = svc.updateNotes(a.id, 'note');
expect(updated).toMatchObject({ assignment_time: '09:00', assignment_end_time: '10:00' });
expect((testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a.id) as { order_index: number }).order_index).toBe(0);
});
});
describe('setLegTransportMode', () => {
it('ASG-SVC-025: sets and clears the leg override (sticky per #1281)', () => {
const { day, place } = fixture();
@@ -78,6 +78,19 @@ export const assignmentTimeRequestSchema = z.object({
});
export type AssignmentTimeRequest = z.infer<typeof assignmentTimeRequestSchema>;
/**
* PUT /:id/notes (#2163) — edit the per-assignment note after creation.
* Deliberately uncapped like the create contract above: the REST create body
* and the plugin RPC accept notes of any length, so a length cap here would
* strand an existing longer note as uneditable (the unchanged textarea would
* be rejected on save). Falsy values ('' or null) clear the column, the same
* `notes || null` normalisation createAssignment applies on the way in.
*/
export const assignmentNotesRequestSchema = z.object({
notes: z.string().nullable(),
});
export type AssignmentNotesRequest = z.infer<typeof assignmentNotesRequestSchema>;
/** Set the leg's travel mode (a RouteProfileKey, or null to inherit the day default). */
export const assignmentTransportRequestSchema = z.object({
// The legacy route read `body.transport_mode ?? null`, so an absent key
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'إضافة إلى يوم؟',
'mobileTrip.addTransportShort': 'النقل',
'mobileTrip.assignedDays': 'الأيام المخصصة',
'mobileTrip.assignmentNotes': 'ملاحظات خاصة باليوم',
'mobileTrip.bookingsEmpty': 'لا توجد حجوزات بعد',
'mobileTrip.collabFeatureDisabled': 'هذه الميزة معطّلة لهذه الرحلة',
'mobileTrip.compactView': 'عرض مضغوط',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'الموقع الإلكتروني',
'places.formNotes': 'ملاحظات',
'places.formNotesPlaceholder': 'ملاحظات شخصية...',
'places.assignmentNotes': 'ملاحظات لهذا اليوم',
'places.assignmentNotesPlaceholder': 'ملاحظات لهذا المكان في هذا اليوم فقط...',
'places.formReservation': 'حجز',
'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...',
'places.mapsSearchPlaceholder': 'ابحث عن أماكن...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Adicionar a um dia?',
'mobileTrip.addTransportShort': 'Transporte',
'mobileTrip.assignedDays': 'Dias atribuídos',
'mobileTrip.assignmentNotes': 'Notas específicas do dia',
'mobileTrip.bookingsEmpty': 'Nenhuma reserva ainda',
'mobileTrip.collabFeatureDisabled': 'Esse recurso está desativado para esta viagem',
'mobileTrip.compactView': 'Visualização compacta',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Site',
'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas pessoais...',
'places.assignmentNotes': 'Notas para este dia',
'places.assignmentNotesPlaceholder': 'Notas para este lugar apenas neste dia...',
'places.formReservation': 'Reserva',
'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...',
'places.mapsSearchPlaceholder': 'Buscar lugares...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Afegeix a un dia?',
'mobileTrip.addTransportShort': 'Transport',
'mobileTrip.assignedDays': 'Dies assignats',
'mobileTrip.assignmentNotes': 'Notes específiques del dia',
'mobileTrip.bookingsEmpty': 'Encara no hi ha reserves',
'mobileTrip.collabFeatureDisabled': 'Aquesta funció està desactivada per a aquest viatge',
'mobileTrip.compactView': 'Vista compacta',
+2
View File
@@ -75,6 +75,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Lloc web',
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Notes personals...',
'places.assignmentNotes': 'Notes per a aquest dia',
'places.assignmentNotesPlaceholder': 'Notes per a aquest lloc només aquest dia...',
'places.formReservation': 'Reserva',
'places.reservationNotesPlaceholder': 'Notes de reserva, número de confirmació...',
'places.mapsSearchPlaceholder': 'Cerca llocs...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Přidat do dne?',
'mobileTrip.addTransportShort': 'Doprava',
'mobileTrip.assignedDays': 'Přiřazené dny',
'mobileTrip.assignmentNotes': 'Poznámky ke dni',
'mobileTrip.bookingsEmpty': 'Zatím žádné rezervace',
'mobileTrip.collabFeatureDisabled': 'Tato funkce je pro tento výlet vypnuta',
'mobileTrip.compactView': 'Kompaktní zobrazení',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Webové stránky',
'places.formNotes': 'Poznámky',
'places.formNotesPlaceholder': 'Osobní poznámky...',
'places.assignmentNotes': 'Poznámky pro tento den',
'places.assignmentNotesPlaceholder': 'Poznámky k tomuto místu jen pro tento den...',
'places.formReservation': 'Rezervace',
'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...',
'places.mapsSearchPlaceholder': 'Hledat místa...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Zu einem Tag hinzufügen?',
'mobileTrip.addTransportShort': 'Transport',
'mobileTrip.assignedDays': 'Zugewiesene Tage',
'mobileTrip.assignmentNotes': 'Tagesspezifische Notizen',
'mobileTrip.bookingsEmpty': 'Noch keine Buchungen',
'mobileTrip.collabFeatureDisabled': 'Diese Funktion ist für diese Reise deaktiviert',
'mobileTrip.compactView': 'Kompakte Ansicht',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Website',
'places.formNotes': 'Notizen',
'places.formNotesPlaceholder': 'Persönliche Notizen...',
'places.assignmentNotes': 'Notizen für diesen Tag',
'places.assignmentNotesPlaceholder': 'Notizen für diesen Ort nur an diesem Tag...',
'places.formReservation': 'Reservierung',
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
'places.mapsSearchPlaceholder': 'Ortssuche...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Add to a day?',
'mobileTrip.addTransportShort': 'Transport',
'mobileTrip.assignedDays': 'Assigned Days',
'mobileTrip.assignmentNotes': 'Day-specific notes',
'mobileTrip.bookingsEmpty': 'No bookings yet',
'mobileTrip.collabFeatureDisabled': 'This feature is disabled for this trip',
'mobileTrip.compactView': 'Compact view',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Website',
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Personal notes...',
'places.assignmentNotes': 'Notes for this day',
'places.assignmentNotesPlaceholder': 'Notes for this place on this day only...',
'places.formReservation': 'Reservation',
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
'places.mapsSearchPlaceholder': 'Search places...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': '¿Añadir a un día?',
'mobileTrip.addTransportShort': 'Transporte',
'mobileTrip.assignedDays': 'Días asignados',
'mobileTrip.assignmentNotes': 'Notas específicas del día',
'mobileTrip.bookingsEmpty': 'Aún no hay reservas',
'mobileTrip.collabFeatureDisabled': 'Esta función está desactivada para este viaje',
'mobileTrip.compactView': 'Vista compacta',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Página web',
'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas personales...',
'places.assignmentNotes': 'Notas para este día',
'places.assignmentNotesPlaceholder': 'Notas para este lugar solo en este día...',
'places.formReservation': 'Reserva',
'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...',
'places.mapsSearchPlaceholder': 'Buscar lugares...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Ajouter à un jour ?',
'mobileTrip.addTransportShort': 'Transport',
'mobileTrip.assignedDays': 'Jours assignés',
'mobileTrip.assignmentNotes': 'Notes propres au jour',
'mobileTrip.bookingsEmpty': 'Aucune réservation',
'mobileTrip.collabFeatureDisabled': 'Cette fonctionnalité est désactivée pour ce voyage',
'mobileTrip.compactView': 'Vue compacte',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Site web',
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Notes personnelles…',
'places.assignmentNotes': 'Notes pour ce jour',
'places.assignmentNotesPlaceholder': 'Notes pour ce lieu uniquement ce jour-là...',
'places.formReservation': 'Réservation',
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…',
'places.mapsSearchPlaceholder': 'Rechercher des lieux…',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Προσθήκη σε ημέρα;',
'mobileTrip.addTransportShort': 'Μεταφορά',
'mobileTrip.assignedDays': 'Ανατεθειμένες ημέρες',
'mobileTrip.assignmentNotes': 'Σημειώσεις ημέρας',
'mobileTrip.bookingsEmpty': 'Δεν υπάρχουν κρατήσεις ακόμη',
'mobileTrip.collabFeatureDisabled': 'Αυτή η λειτουργία είναι απενεργοποιημένη για αυτό το ταξίδι',
'mobileTrip.compactView': 'Συμπαγής προβολή',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Ιστοσελίδα',
'places.formNotes': 'Σημειώσεις',
'places.formNotesPlaceholder': 'Προσωπικές σημειώσεις...',
'places.assignmentNotes': 'Σημειώσεις για αυτή την ημέρα',
'places.assignmentNotesPlaceholder': 'Σημειώσεις για αυτό το μέρος μόνο για αυτή την ημέρα...',
'places.formReservation': 'Κράτηση',
'places.reservationNotesPlaceholder': 'Σημειώσεις κράτησης, αριθμός επιβεβαίωσης...',
'places.mapsSearchPlaceholder': 'Αναζήτηση μερών...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Hozzáadás egy naphoz?',
'mobileTrip.addTransportShort': 'Közlekedés',
'mobileTrip.assignedDays': 'Hozzárendelt napok',
'mobileTrip.assignmentNotes': 'Napra szóló jegyzetek',
'mobileTrip.bookingsEmpty': 'Még nincsenek foglalások',
'mobileTrip.collabFeatureDisabled': 'Ez a funkció ki van kapcsolva ennél az utazásnál',
'mobileTrip.compactView': 'Kompakt nézet',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Weboldal',
'places.formNotes': 'Jegyzetek',
'places.formNotesPlaceholder': 'Személyes jegyzetek...',
'places.assignmentNotes': 'Jegyzetek erre a napra',
'places.assignmentNotesPlaceholder': 'Jegyzetek ehhez a helyhez csak erre a napra...',
'places.formReservation': 'Foglalás',
'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...',
'places.mapsSearchPlaceholder': 'Helyek keresése...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Tambahkan ke hari?',
'mobileTrip.addTransportShort': 'Transportasi',
'mobileTrip.assignedDays': 'Hari yang ditetapkan',
'mobileTrip.assignmentNotes': 'Catatan khusus hari',
'mobileTrip.bookingsEmpty': 'Belum ada reservasi',
'mobileTrip.collabFeatureDisabled': 'Fitur ini dinonaktifkan untuk perjalanan ini',
'mobileTrip.compactView': 'Tampilan ringkas',
+2
View File
@@ -76,6 +76,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Situs web',
'places.formNotes': 'Catatan',
'places.formNotesPlaceholder': 'Catatan pribadi...',
'places.assignmentNotes': 'Catatan untuk hari ini',
'places.assignmentNotesPlaceholder': 'Catatan untuk tempat ini khusus hari ini...',
'places.formReservation': 'Reservasi',
'places.reservationNotesPlaceholder': 'Catatan reservasi, nomor konfirmasi...',
'places.mapsSearchPlaceholder': 'Cari tempat...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Aggiungere a un giorno?',
'mobileTrip.addTransportShort': 'Trasporto',
'mobileTrip.assignedDays': 'Giorni assegnati',
'mobileTrip.assignmentNotes': 'Note specifiche del giorno',
'mobileTrip.bookingsEmpty': 'Ancora nessuna prenotazione',
'mobileTrip.collabFeatureDisabled': 'Questa funzione è disattivata per questo viaggio',
'mobileTrip.compactView': 'Vista compatta',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Sito web',
'places.formNotes': 'Note',
'places.formNotesPlaceholder': 'Note personali...',
'places.assignmentNotes': 'Note per questo giorno',
'places.assignmentNotesPlaceholder': 'Note per questo luogo solo in questo giorno...',
'places.formReservation': 'Prenotazione',
'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...',
'places.mapsSearchPlaceholder': 'Cerca luoghi...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': '日に追加しますか?',
'mobileTrip.addTransportShort': '移動',
'mobileTrip.assignedDays': '割り当てられた日',
'mobileTrip.assignmentNotes': '日別メモ',
'mobileTrip.bookingsEmpty': '予約はまだありません',
'mobileTrip.collabFeatureDisabled': 'この機能はこの旅行では無効になっています',
'mobileTrip.compactView': 'コンパクト表示',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'ウェブサイト',
'places.formNotes': 'メモ',
'places.formNotesPlaceholder': '個人的なメモ…',
'places.assignmentNotes': 'この日のメモ',
'places.assignmentNotesPlaceholder': 'この日だけのこの場所に関するメモ…',
'places.formReservation': '予約',
'places.reservationNotesPlaceholder': '予約メモ、確認番号など…',
'places.mapsSearchPlaceholder': '場所を検索…',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': '하루에 추가할까요?',
'mobileTrip.addTransportShort': '교통',
'mobileTrip.assignedDays': '배정된 날짜',
'mobileTrip.assignmentNotes': '날짜별 메모',
'mobileTrip.bookingsEmpty': '아직 예약이 없습니다',
'mobileTrip.collabFeatureDisabled': '이 여행에서는 이 기능이 비활성화되어 있습니다',
'mobileTrip.compactView': '간략히 보기',
+2
View File
@@ -76,6 +76,8 @@ const places: TranslationStrings = {
'places.formWebsite': '웹사이트',
'places.formNotes': '메모',
'places.formNotesPlaceholder': '개인 메모...',
'places.assignmentNotes': '이 날의 메모',
'places.assignmentNotesPlaceholder': '이 날에만 적용되는 이 장소의 메모...',
'places.formReservation': '예약',
'places.reservationNotesPlaceholder': '예약 메모, 확인 번호...',
'places.mapsSearchPlaceholder': '장소 검색...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Toevoegen aan een dag?',
'mobileTrip.addTransportShort': 'Transport',
'mobileTrip.assignedDays': 'Toegewezen dagen',
'mobileTrip.assignmentNotes': 'Dagspecifieke notities',
'mobileTrip.bookingsEmpty': 'Nog geen boekingen',
'mobileTrip.collabFeatureDisabled': 'Deze functie is uitgeschakeld voor deze reis',
'mobileTrip.compactView': 'Compacte weergave',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Website',
'places.formNotes': 'Notities',
'places.formNotesPlaceholder': 'Persoonlijke notities...',
'places.assignmentNotes': 'Notities voor deze dag',
'places.assignmentNotesPlaceholder': 'Notities voor deze plek alleen op deze dag...',
'places.formReservation': 'Reservering',
'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...',
'places.mapsSearchPlaceholder': 'Plaatsen zoeken...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Dodać do dnia?',
'mobileTrip.addTransportShort': 'Transport',
'mobileTrip.assignedDays': 'Przypisane dni',
'mobileTrip.assignmentNotes': 'Notatki dla dnia',
'mobileTrip.bookingsEmpty': 'Brak rezerwacji',
'mobileTrip.collabFeatureDisabled': 'Ta funkcja jest wyłączona dla tej podróży',
'mobileTrip.compactView': 'Widok kompaktowy',
+2
View File
@@ -68,6 +68,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Strona internetowa',
'places.formNotes': 'Notatki',
'places.formNotesPlaceholder': 'Osobiste notatki...',
'places.assignmentNotes': 'Notatki na ten dzień',
'places.assignmentNotesPlaceholder': 'Notatki dla tego miejsca tylko na ten dzień...',
'places.formReservation': 'Rezerwacja',
'places.reservationNotesPlaceholder': 'Notatki z rezerwacji, numer potwierdzenia...',
'places.mapsSearchPlaceholder': 'Szukaj miejsc...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Добавить в день?',
'mobileTrip.addTransportShort': 'Транспорт',
'mobileTrip.assignedDays': 'Назначенные дни',
'mobileTrip.assignmentNotes': 'Заметки на день',
'mobileTrip.bookingsEmpty': 'Пока нет бронирований',
'mobileTrip.collabFeatureDisabled': 'Эта функция отключена для этой поездки',
'mobileTrip.compactView': 'Компактный вид',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Сайт',
'places.formNotes': 'Заметки',
'places.formNotesPlaceholder': 'Личные заметки...',
'places.assignmentNotes': 'Заметки на этот день',
'places.assignmentNotesPlaceholder': 'Заметки для этого места только на этот день...',
'places.formReservation': 'Бронирование',
'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...',
'places.mapsSearchPlaceholder': 'Поиск мест...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Lägg till på en dag?',
'mobileTrip.addTransportShort': 'Transport',
'mobileTrip.assignedDays': 'Tilldelade dagar',
'mobileTrip.assignmentNotes': 'Dagsspecifika anteckningar',
'mobileTrip.bookingsEmpty': 'Inga bokningar än',
'mobileTrip.collabFeatureDisabled': 'Den här funktionen är inaktiverad för den här resan',
'mobileTrip.compactView': 'Kompakt vy',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Hemsida',
'places.formNotes': 'Noteringar',
'places.formNotesPlaceholder': 'Personliga anteckningar...',
'places.assignmentNotes': 'Anteckningar för denna dag',
'places.assignmentNotesPlaceholder': 'Anteckningar för denna plats endast denna dag...',
'places.formReservation': 'Bokning',
'places.reservationNotesPlaceholder': 'Bokningsinformation, bekräftelsenummer...',
'places.mapsSearchPlaceholder': 'Sök efter platser...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Bir güne eklensin mi?',
'mobileTrip.addTransportShort': 'Ulaşım',
'mobileTrip.assignedDays': 'Atanan Günler',
'mobileTrip.assignmentNotes': 'Güne özel notlar',
'mobileTrip.bookingsEmpty': 'Henüz rezervasyon yok',
'mobileTrip.collabFeatureDisabled': 'Bu özellik bu seyahat için devre dışı',
'mobileTrip.compactView': 'Kompakt görünüm',
+2
View File
@@ -79,6 +79,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Web sitesi',
'places.formNotes': 'Notlar',
'places.formNotesPlaceholder': 'Kişisel notlar...',
'places.assignmentNotes': 'Bu gün için notlar',
'places.assignmentNotesPlaceholder': 'Bu yer için yalnızca bu güne özel notlar...',
'places.formReservation': 'Rezervasyon',
'places.reservationNotesPlaceholder': 'Rezervasyon notları, onay numarası...',
'places.mapsSearchPlaceholder': 'Yerleri ara...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Додати до дня?',
'mobileTrip.addTransportShort': 'Транспорт',
'mobileTrip.assignedDays': 'Призначені дні',
'mobileTrip.assignmentNotes': 'Нотатки на день',
'mobileTrip.bookingsEmpty': 'Поки немає бронювань',
'mobileTrip.collabFeatureDisabled': 'Цю функцію вимкнено для цієї подорожі',
'mobileTrip.compactView': 'Компактний вигляд',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Веб-сайт',
'places.formNotes': 'Нотатки',
'places.formNotesPlaceholder': 'Особисті нотатки...',
'places.assignmentNotes': 'Нотатки на цей день',
'places.assignmentNotesPlaceholder': 'Нотатки для цього місця лише на цей день...',
'places.formReservation': 'Бронювання',
'places.reservationNotesPlaceholder': 'Нотатки про бронювання, номер підтвердження...',
'places.mapsSearchPlaceholder': 'Пошук місць...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': 'Thêm vào một ngày?',
'mobileTrip.addTransportShort': 'Di chuyển',
'mobileTrip.assignedDays': 'Ngày được gán',
'mobileTrip.assignmentNotes': 'Ghi chú theo ngày',
'mobileTrip.bookingsEmpty': 'Chưa có đặt chỗ nào',
'mobileTrip.collabFeatureDisabled': 'Tính năng này bị tắt cho chuyến đi này',
'mobileTrip.compactView': 'Chế độ xem gọn',
+2
View File
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
'places.formWebsite': 'Trang web',
'places.formNotes': 'Ghi chú',
'places.formNotesPlaceholder': 'Ghi chú cá nhân...',
'places.assignmentNotes': 'Ghi chú cho ngày này',
'places.assignmentNotesPlaceholder': 'Ghi chú cho địa điểm này chỉ trong ngày này...',
'places.formReservation': 'Sự đặt chỗ',
'places.reservationNotesPlaceholder': 'Phiếu đặt chỗ, số xác nhận...',
'places.mapsSearchPlaceholder': 'Tìm kiếm địa điểm...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': '新增到某一天?',
'mobileTrip.addTransportShort': '交通',
'mobileTrip.assignedDays': '已分配的天數',
'mobileTrip.assignmentNotes': '按天備註',
'mobileTrip.bookingsEmpty': '暫無預訂',
'mobileTrip.collabFeatureDisabled': '此功能已針對本次行程停用',
'mobileTrip.compactView': '精簡檢視',
+2
View File
@@ -76,6 +76,8 @@ const places: TranslationStrings = {
'places.formWebsite': '網站',
'places.formNotes': '備註',
'places.formNotesPlaceholder': '個人備註...',
'places.assignmentNotes': '當天備註',
'places.assignmentNotesPlaceholder': '僅針對這一天此地點的備註...',
'places.formReservation': '預訂',
'places.reservationNotesPlaceholder': '預訂備註、確認號...',
'places.mapsSearchPlaceholder': '搜尋地點...',
+1
View File
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.addToDayQuestion': '添加到某一天?',
'mobileTrip.addTransportShort': '交通',
'mobileTrip.assignedDays': '已分配的天数',
'mobileTrip.assignmentNotes': '按天备注',
'mobileTrip.bookingsEmpty': '暂无预订',
'mobileTrip.collabFeatureDisabled': '此功能已针对本次行程停用',
'mobileTrip.compactView': '紧凑视图',
+2
View File
@@ -76,6 +76,8 @@ const places: TranslationStrings = {
'places.formWebsite': '网站',
'places.formNotes': '备注',
'places.formNotesPlaceholder': '个人备注...',
'places.assignmentNotes': '当天备注',
'places.assignmentNotesPlaceholder': '仅针对这一天此地点的备注...',
'places.formReservation': '预订',
'places.reservationNotesPlaceholder': '预订备注、确认号...',
'places.mapsSearchPlaceholder': '搜索地点...',