mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(planner): surface and edit day-assignment notes across desktop, mobile and pdf (#2163)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.addToDayQuestion': 'إضافة إلى يوم؟',
|
||||
'mobileTrip.addTransportShort': 'النقل',
|
||||
'mobileTrip.assignedDays': 'الأيام المخصصة',
|
||||
'mobileTrip.assignmentNotes': 'ملاحظات خاصة باليوم',
|
||||
'mobileTrip.bookingsEmpty': 'لا توجد حجوزات بعد',
|
||||
'mobileTrip.collabFeatureDisabled': 'هذه الميزة معطّلة لهذه الرحلة',
|
||||
'mobileTrip.compactView': 'عرض مضغوط',
|
||||
|
||||
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
|
||||
'places.formWebsite': 'الموقع الإلكتروني',
|
||||
'places.formNotes': 'ملاحظات',
|
||||
'places.formNotesPlaceholder': 'ملاحظات شخصية...',
|
||||
'places.assignmentNotes': 'ملاحظات لهذا اليوم',
|
||||
'places.assignmentNotesPlaceholder': 'ملاحظات لهذا المكان في هذا اليوم فقط...',
|
||||
'places.formReservation': 'حجز',
|
||||
'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...',
|
||||
'places.mapsSearchPlaceholder': 'ابحث عن أماكن...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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í',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.addToDayQuestion': 'Προσθήκη σε ημέρα;',
|
||||
'mobileTrip.addTransportShort': 'Μεταφορά',
|
||||
'mobileTrip.assignedDays': 'Ανατεθειμένες ημέρες',
|
||||
'mobileTrip.assignmentNotes': 'Σημειώσεις ημέρας',
|
||||
'mobileTrip.bookingsEmpty': 'Δεν υπάρχουν κρατήσεις ακόμη',
|
||||
'mobileTrip.collabFeatureDisabled': 'Αυτή η λειτουργία είναι απενεργοποιημένη για αυτό το ταξίδι',
|
||||
'mobileTrip.compactView': 'Συμπαγής προβολή',
|
||||
|
||||
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
|
||||
'places.formWebsite': 'Ιστοσελίδα',
|
||||
'places.formNotes': 'Σημειώσεις',
|
||||
'places.formNotesPlaceholder': 'Προσωπικές σημειώσεις...',
|
||||
'places.assignmentNotes': 'Σημειώσεις για αυτή την ημέρα',
|
||||
'places.assignmentNotesPlaceholder': 'Σημειώσεις για αυτό το μέρος μόνο για αυτή την ημέρα...',
|
||||
'places.formReservation': 'Κράτηση',
|
||||
'places.reservationNotesPlaceholder': 'Σημειώσεις κράτησης, αριθμός επιβεβαίωσης...',
|
||||
'places.mapsSearchPlaceholder': 'Αναζήτηση μερών...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.addToDayQuestion': '日に追加しますか?',
|
||||
'mobileTrip.addTransportShort': '移動',
|
||||
'mobileTrip.assignedDays': '割り当てられた日',
|
||||
'mobileTrip.assignmentNotes': '日別メモ',
|
||||
'mobileTrip.bookingsEmpty': '予約はまだありません',
|
||||
'mobileTrip.collabFeatureDisabled': 'この機能はこの旅行では無効になっています',
|
||||
'mobileTrip.compactView': 'コンパクト表示',
|
||||
|
||||
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
|
||||
'places.formWebsite': 'ウェブサイト',
|
||||
'places.formNotes': 'メモ',
|
||||
'places.formNotesPlaceholder': '個人的なメモ…',
|
||||
'places.assignmentNotes': 'この日のメモ',
|
||||
'places.assignmentNotesPlaceholder': 'この日だけのこの場所に関するメモ…',
|
||||
'places.formReservation': '予約',
|
||||
'places.reservationNotesPlaceholder': '予約メモ、確認番号など…',
|
||||
'places.mapsSearchPlaceholder': '場所を検索…',
|
||||
|
||||
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.addToDayQuestion': '하루에 추가할까요?',
|
||||
'mobileTrip.addTransportShort': '교통',
|
||||
'mobileTrip.assignedDays': '배정된 날짜',
|
||||
'mobileTrip.assignmentNotes': '날짜별 메모',
|
||||
'mobileTrip.bookingsEmpty': '아직 예약이 없습니다',
|
||||
'mobileTrip.collabFeatureDisabled': '이 여행에서는 이 기능이 비활성화되어 있습니다',
|
||||
'mobileTrip.compactView': '간략히 보기',
|
||||
|
||||
@@ -76,6 +76,8 @@ const places: TranslationStrings = {
|
||||
'places.formWebsite': '웹사이트',
|
||||
'places.formNotes': '메모',
|
||||
'places.formNotesPlaceholder': '개인 메모...',
|
||||
'places.assignmentNotes': '이 날의 메모',
|
||||
'places.assignmentNotesPlaceholder': '이 날에만 적용되는 이 장소의 메모...',
|
||||
'places.formReservation': '예약',
|
||||
'places.reservationNotesPlaceholder': '예약 메모, 확인 번호...',
|
||||
'places.mapsSearchPlaceholder': '장소 검색...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.addToDayQuestion': 'Добавить в день?',
|
||||
'mobileTrip.addTransportShort': 'Транспорт',
|
||||
'mobileTrip.assignedDays': 'Назначенные дни',
|
||||
'mobileTrip.assignmentNotes': 'Заметки на день',
|
||||
'mobileTrip.bookingsEmpty': 'Пока нет бронирований',
|
||||
'mobileTrip.collabFeatureDisabled': 'Эта функция отключена для этой поездки',
|
||||
'mobileTrip.compactView': 'Компактный вид',
|
||||
|
||||
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
|
||||
'places.formWebsite': 'Сайт',
|
||||
'places.formNotes': 'Заметки',
|
||||
'places.formNotesPlaceholder': 'Личные заметки...',
|
||||
'places.assignmentNotes': 'Заметки на этот день',
|
||||
'places.assignmentNotesPlaceholder': 'Заметки для этого места только на этот день...',
|
||||
'places.formReservation': 'Бронирование',
|
||||
'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...',
|
||||
'places.mapsSearchPlaceholder': 'Поиск мест...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.addToDayQuestion': 'Додати до дня?',
|
||||
'mobileTrip.addTransportShort': 'Транспорт',
|
||||
'mobileTrip.assignedDays': 'Призначені дні',
|
||||
'mobileTrip.assignmentNotes': 'Нотатки на день',
|
||||
'mobileTrip.bookingsEmpty': 'Поки немає бронювань',
|
||||
'mobileTrip.collabFeatureDisabled': 'Цю функцію вимкнено для цієї подорожі',
|
||||
'mobileTrip.compactView': 'Компактний вигляд',
|
||||
|
||||
@@ -77,6 +77,8 @@ const places: TranslationStrings = {
|
||||
'places.formWebsite': 'Веб-сайт',
|
||||
'places.formNotes': 'Нотатки',
|
||||
'places.formNotesPlaceholder': 'Особисті нотатки...',
|
||||
'places.assignmentNotes': 'Нотатки на цей день',
|
||||
'places.assignmentNotesPlaceholder': 'Нотатки для цього місця лише на цей день...',
|
||||
'places.formReservation': 'Бронювання',
|
||||
'places.reservationNotesPlaceholder': 'Нотатки про бронювання, номер підтвердження...',
|
||||
'places.mapsSearchPlaceholder': 'Пошук місць...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.addToDayQuestion': '新增到某一天?',
|
||||
'mobileTrip.addTransportShort': '交通',
|
||||
'mobileTrip.assignedDays': '已分配的天數',
|
||||
'mobileTrip.assignmentNotes': '按天備註',
|
||||
'mobileTrip.bookingsEmpty': '暫無預訂',
|
||||
'mobileTrip.collabFeatureDisabled': '此功能已針對本次行程停用',
|
||||
'mobileTrip.compactView': '精簡檢視',
|
||||
|
||||
@@ -76,6 +76,8 @@ const places: TranslationStrings = {
|
||||
'places.formWebsite': '網站',
|
||||
'places.formNotes': '備註',
|
||||
'places.formNotesPlaceholder': '個人備註...',
|
||||
'places.assignmentNotes': '當天備註',
|
||||
'places.assignmentNotesPlaceholder': '僅針對這一天此地點的備註...',
|
||||
'places.formReservation': '預訂',
|
||||
'places.reservationNotesPlaceholder': '預訂備註、確認號...',
|
||||
'places.mapsSearchPlaceholder': '搜尋地點...',
|
||||
|
||||
@@ -7,6 +7,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.addToDayQuestion': '添加到某一天?',
|
||||
'mobileTrip.addTransportShort': '交通',
|
||||
'mobileTrip.assignedDays': '已分配的天数',
|
||||
'mobileTrip.assignmentNotes': '按天备注',
|
||||
'mobileTrip.bookingsEmpty': '暂无预订',
|
||||
'mobileTrip.collabFeatureDisabled': '此功能已针对本次行程停用',
|
||||
'mobileTrip.compactView': '紧凑视图',
|
||||
|
||||
@@ -76,6 +76,8 @@ const places: TranslationStrings = {
|
||||
'places.formWebsite': '网站',
|
||||
'places.formNotes': '备注',
|
||||
'places.formNotesPlaceholder': '个人备注...',
|
||||
'places.assignmentNotes': '当天备注',
|
||||
'places.assignmentNotesPlaceholder': '仅针对这一天此地点的备注...',
|
||||
'places.formReservation': '预订',
|
||||
'places.reservationNotesPlaceholder': '预订备注、确认号...',
|
||||
'places.mapsSearchPlaceholder': '搜索地点...',
|
||||
|
||||
Reference in New Issue
Block a user