diff --git a/client/src/components/Planner/DayAddFooter.tsx b/client/src/components/Planner/DayAddFooter.tsx index f05f0ff1b..5d925bd1d 100644 --- a/client/src/components/Planner/DayAddFooter.tsx +++ b/client/src/components/Planner/DayAddFooter.tsx @@ -12,19 +12,22 @@ interface DayAddFooterProps { locale: string } -const outlineBtn = - 'flex items-center justify-center gap-1.5 rounded-lg border border-edge py-2 text-body font-medium transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent' -const primaryBtn = - 'flex items-center justify-center gap-1.5 rounded-lg bg-accent py-2 text-body font-medium text-accent-text transition-colors hover:bg-accent-hover disabled:cursor-default disabled:opacity-40 disabled:hover:bg-accent' +/** One button size for the whole foot of the reorder dialog, the delete question's included. */ +export const FOOTER_BTN = + 'inline-flex min-h-9 flex-shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5 text-body font-medium transition-colors disabled:cursor-default disabled:opacity-40' +const SECONDARY_BTN = `${FOOTER_BTN} border border-edge text-content-secondary hover:bg-surface-hover disabled:hover:bg-transparent` +const CLOSE_BTN = `${FOOTER_BTN} ml-auto border border-edge text-content-muted hover:bg-surface-hover hover:text-content` +/** The add that matters most, tinted from the accent instead of filled with it. */ +const MAIN_BTN = `${FOOTER_BTN} border border-[color:color-mix(in_srgb,var(--accent)_30%,transparent)] bg-[color:color-mix(in_srgb,var(--accent)_10%,transparent)] font-semibold text-accent-on hover:bg-[color:color-mix(in_srgb,var(--accent)_16%,transparent)] disabled:hover:bg-[color:color-mix(in_srgb,var(--accent)_10%,transparent)]` /** * The foot of the reorder dialog: close it, or add a day. * - * On a trip with dates there are two ways to add one, side by side. A day - * without a date goes to the end and leaves the trip dates alone; the next - * calendar day, named on its button, extends the trip by one. The line under - * them says what the button in view does, and switches while the pointer or the - * focus rests on the other one. When no day can be added it says why instead. + * On a trip with dates there are two ways to add one, in one row with Close. The + * next calendar day, named on its button, extends the trip by one; a day without + * a date goes to the end and leaves the trip dates alone. The line above them + * says what the button in view does, and switches while the pointer or the focus + * rests on the other one. When no day can be added it says why instead. */ export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFooterProps) { const [aboutUndated, setAboutUndated] = useState(false) @@ -34,21 +37,21 @@ export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFoo const addOff = busy || !!blocked const closeButton = ( - ) if (!dayAdd?.nextDate) { return ( -
- {closeButton} -
- {blocked && {blocked}} - + {closeButton}
) @@ -65,34 +68,33 @@ export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFoo } return ( -
-
- +
+

+ {hint} +

+ {/* Should a language run long, the row wraps between buttons, never inside one. */} +
-
-
-

- {hint} -

+ {closeButton}
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index de6ee51dc..085f5768c 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -57,6 +57,7 @@ import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDet import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import type { DayAddControls } from '../../utils/dayAdd' +import type { DayDeleteQuestion } from '../../utils/dayImpactLines' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' import { getNavigationTargets, openNavigationTarget } from './placeNavigation' @@ -81,6 +82,8 @@ interface DayPlanSidebarProps { dayAdd?: DayAddControls /** Asks to delete a day from the reorder dialog; the planner owns the question. */ onDeleteDay?: (dayId: number) => void + /** The open delete question, which the reorder dialog asks in place of its list. */ + deleteDayQuestion?: DayDeleteQuestion | null /** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */ onUpdateDayTitle: (dayId: number, title: string) => void /** The day route is computed by the planner page itself; kept for the existing call sites. */ @@ -152,7 +155,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations = [], - onReorder, onReorderDays, onAddDay, dayAdd, onDeleteDay, + onReorder, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion, onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations = [], visibleConnectionIds = [], @@ -1137,6 +1140,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { onAddDay, dayAdd, onDeleteDay, + deleteDayQuestion, onAssignToDay, onRemoveAssignment, onEditPlace, @@ -1341,6 +1345,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onAddDay, dayAdd, onDeleteDay, + deleteDayQuestion, onAssignToDay, onRemoveAssignment, onEditPlace, @@ -1602,6 +1607,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onAddDay={onAddDay} dayAdd={dayAdd} onDeleteDay={onDeleteDay} + deleteDayQuestion={deleteDayQuestion} /> {/* Tagesliste */} diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx index f1f0fe47c..ab237c3e6 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-025 +// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026 import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { downloadTripPDF } from '../PDF/TripPDF' @@ -243,6 +243,21 @@ describe('DayPlanSidebarToolbar', () => { expect(onDeleteDay).toHaveBeenCalledWith(11) }) + it('FE-PLANNER-DPTOOLBAR-026: the planner question reaches the popup, which asks it in place of its list', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })] + const onCancel = vi.fn() + const question = { dayId: 11, title: 'Delete Day 2?', lines: [], onCancel, onConfirm: vi.fn() } + const props = makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay: vi.fn() }) + const { rerender } = render() + await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' })) + rerender() + expect(screen.getByRole('region', { name: 'Delete Day 2?' })).toBeInTheDocument() + expect(screen.queryByText('dayplan.reorderHint')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.cancel' })) + expect(onCancel).toHaveBeenCalled() + }) + it('FE-PLANNER-DPTOOLBAR-025: the popup gets the planner add controls, and its dated button adds the next date', async () => { const user = userEvent.setup() const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })] diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx index 3b06f3d14..011cbdd7e 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx @@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast' import { TripExportModal } from './TripExportModal' import { isRoutableReservation } from '../../utils/reservationRoutes' import type { DayAddControls } from '../../utils/dayAdd' +import type { DayDeleteQuestion } from '../../utils/dayImpactLines' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' interface DayPlanSidebarToolbarProps { @@ -42,6 +43,8 @@ interface DayPlanSidebarToolbarProps { dayAdd?: DayAddControls /** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */ onDeleteDay?: (dayId: number) => void + /** The open delete question; the dialog asks it in place of its day list. */ + deleteDayQuestion?: DayDeleteQuestion | null } export function DayPlanSidebarToolbar({ @@ -49,7 +52,7 @@ export function DayPlanSidebarToolbar({ allConnectionsShown = false, onToggleAllConnections, t, locale, toast, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, - canEditDays, canManageShare = true, onReorderDays, onAddDay, dayAdd, onDeleteDay, + canEditDays, canManageShare = true, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion, }: DayPlanSidebarToolbarProps) { const [reorderOpen, setReorderOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false) @@ -204,6 +207,7 @@ export function DayPlanSidebarToolbar({ onAddDay={() => onAddDay()} dayAdd={dayAdd} onDeleteDay={onDeleteDay} + deleteQuestion={deleteDayQuestion} onClose={() => setReorderOpen(false)} />
diff --git a/client/src/components/Planner/DayReorderPopup.test.tsx b/client/src/components/Planner/DayReorderPopup.test.tsx index 5088f8a84..4fe383e2f 100644 --- a/client/src/components/Planner/DayReorderPopup.test.tsx +++ b/client/src/components/Planner/DayReorderPopup.test.tsx @@ -1,10 +1,12 @@ -// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-021 +// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-028 import { render, screen, fireEvent } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' +import { MapPin } from 'lucide-react' import { buildDay } from '../../../tests/helpers/factories' import { DayReorderPopup } from './DayReorderPopup' import { setForcedOffline } from '../../sync/networkMode' import type { DayAddControls } from '../../utils/dayAdd' +import type { DayDeleteQuestion } from '../../utils/dayImpactLines' import type { Day } from '../../types' // The component takes `t` as a prop, so returning the key keeps assertions exact. @@ -123,9 +125,9 @@ describe('DayReorderPopup', () => { fireEvent.dragStart(first) fireEvent.dragOver(second) // The hovered row is highlighted while a drag is in flight. - expect(second.style.outline).toContain('dashed') + expect(second).toHaveClass('outline-dashed') fireEvent.dragEnd(first) - expect(second.style.outline).toBe('none') + expect(second).not.toHaveClass('outline-dashed') expect(onReorder).not.toHaveBeenCalled() }) @@ -260,4 +262,104 @@ describe('DayReorderPopup', () => { expect(undated).toHaveAttribute('aria-describedby', hintId) }) }) + + describe('the delete question, asked in place of the list', () => { + const question = (overrides: Partial = {}): DayDeleteQuestion => ({ + dayId: 7, + title: 'Delete Lyon?', + lines: [{ key: 'places', icon: MapPin, tone: 'neutral', text: 'Planned places: 2', hint: 'They stay in the place list.' }], + onCancel: vi.fn(), + onConfirm: vi.fn(), + ...overrides, + }) + + it('FE-PLANNER-DAYREORDER-022: the question takes the place of the rows and the add buttons, in the same dialog', () => { + const dayAdd: DayAddControls = { nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn() } + render() + expect(rows()).toHaveLength(0) + expect(screen.queryByRole('button', { name: 'dayplan.addUndatedDay' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.close' })).not.toBeInTheDocument() + // Still the reorder dialog: its own title stays, the question sits under it. + expect(screen.getByText('dayplan.reorderTitle')).toBeInTheDocument() + const section = screen.getByRole('region', { name: 'Delete Lyon?' }) + expect(section).toHaveAccessibleDescription('dayplan.deleteDayBody') + expect(screen.getByRole('list', { name: 'Delete Lyon?' })).toHaveTextContent('Planned places: 2') + expect(screen.getByRole('button', { name: 'common.cancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dayplan.deleteDay' })).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYREORDER-023: Cancel takes the question back, the delete button answers it', async () => { + const user = userEvent.setup() + const q = question() + const onClose = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'common.cancel' })) + expect(q.onCancel).toHaveBeenCalledTimes(1) + expect(onClose).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'dayplan.deleteDay' })) + expect(q.onConfirm).toHaveBeenCalledTimes(1) + }) + + it('FE-PLANNER-DAYREORDER-024: Escape takes back only the question, not the dialog', async () => { + const user = userEvent.setup() + const q = question() + const onClose = vi.fn() + render() + await user.keyboard('{Escape}') + expect(q.onCancel).toHaveBeenCalledTimes(1) + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYREORDER-025: closing the dialog also drops an open question', async () => { + const user = userEvent.setup() + const q = question() + const onClose = vi.fn() + const { rerender } = render() + const closeX = document.querySelector('h2 + button') + expect(closeX).not.toBeNull() + await user.click(closeX as HTMLButtonElement) + expect(q.onCancel).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + + // A dialog closed from outside takes its question with it as well. + const again = question() + rerender() + rerender() + expect(again.onCancel).toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYREORDER-026: a question about a day that is no longer there is not asked', () => { + render() + expect(rows()).toHaveLength(3) + expect(screen.queryByRole('region', { name: 'Delete Lyon?' })).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DAYREORDER-027: the focus goes to Cancel, and back to the row asked about when the list returns', async () => { + const user = userEvent.setup() + const days = threeDays() + const onDeleteDay = vi.fn() + const { rerender } = render() + await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1]) + expect(onDeleteDay).toHaveBeenCalledWith(7) + + rerender() + expect(screen.getByRole('button', { name: 'common.cancel' })).toHaveFocus() + + rerender() + expect(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1]).toHaveFocus() + }) + + it('FE-PLANNER-DAYREORDER-028: after the day went, the focus lands on the row that took its place', async () => { + const user = userEvent.setup() + const days = threeDays() + const { rerender } = render() + await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1]) + rerender() + const left = days.filter(d => d.id !== 7) + rerender() + const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' }) + expect(buttons).toHaveLength(2) + expect(buttons[1]).toHaveFocus() + }) + }) }) diff --git a/client/src/components/Planner/DayReorderPopup.tsx b/client/src/components/Planner/DayReorderPopup.tsx index 63bbb86c0..1292de1bb 100644 --- a/client/src/components/Planner/DayReorderPopup.tsx +++ b/client/src/components/Planner/DayReorderPopup.tsx @@ -1,12 +1,14 @@ -import { useState } from 'react' -import { GripVertical, ArrowUp, ArrowDown, Trash2 } from 'lucide-react' +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { GripVertical, ArrowUp, ArrowDown, Trash2, AlertTriangle } from 'lucide-react' import Modal from '../shared/Modal' import Tooltip from '../shared/Tooltip' -import { DayAddFooter } from './DayAddFooter' +import DayImpactList from '../shared/DayImpactList' +import { DayAddFooter, FOOTER_BTN } from './DayAddFooter' import { useNetworkMode } from '../../hooks/useNetworkMode' import { dayLabel } from '../../utils/dayLabel' import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact' import type { DayAddControls } from '../../utils/dayAdd' +import type { DayDeleteQuestion } from '../../utils/dayImpactLines' import type { Day } from '../../types' interface DayReorderPopupProps { @@ -23,24 +25,48 @@ interface DayReorderPopupProps { dayAdd?: DayAddControls /** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */ onDeleteDay?: (dayId: number) => void + /** The open delete question, asked in place of the day list until it is answered. */ + deleteQuestion?: DayDeleteQuestion | null onClose: () => void } +/** Where the list stood when the question opened, to come back to it. */ +interface ListSpot { + dayId: number + index: number + scrollTop: number + panelHeight: number +} + +const ICON_BTN = + 'grid h-7 w-7 flex-shrink-0 place-items-center rounded-md border border-edge-faint text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted' +const DELETE_BTN = + 'grid h-7 w-7 place-items-center rounded-md text-content-faint transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-content-faint' + /** * Modal for moving whole days around: drag a row by its grip or use the up/down * arrows, add a day, or delete one. Day headers stay untouched, so this is the * single surface for ordering. Reorders are applied optimistically by the store, - * so the list reflects each move immediately. A delete only asks: the planner - * shows what goes with the day before anything happens. Adding a day on a trip - * with dates offers both kinds, a day without a date or the next date. + * so the list reflects each move immediately. + * + * A delete only asks, and it asks right here: the dialog swaps its list for the + * question, what goes with the day and the two ways out, at the same size and in + * the same place. Cancel and Escape bring the list back where it was. */ -export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, onClose }: DayReorderPopupProps) { +export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, deleteQuestion, onClose }: DayReorderPopupProps) { const [dragIndex, setDragIndex] = useState(null) const [overIndex, setOverIndex] = useState(null) const { offline } = useNetworkMode() + const bodyRef = useRef(null) + const cancelRef = useRef(null) + const spot = useRef(null) + const titleId = useId() + const bodyId = useId() const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) const deleteBlocked = deleteDayBlockedReason(ordered.length, offline, t) + // A question about a day that is gone (deleted elsewhere) is no question. + const asking = isOpen && deleteQuestion && ordered.some(d => d.id === deleteQuestion.dayId) ? deleteQuestion : null const move = (from: number, to: number) => { if (to < 0 || to >= ordered.length || from === to) return @@ -50,99 +76,178 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onReorder(ids) } - const cellBtn = { - display: 'grid', placeItems: 'center', width: 28, height: 28, - border: '1px solid var(--border-faint)', borderRadius: 7, - background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0, - } as const + const ask = (dayId: number, index: number) => { + const body = bodyRef.current + spot.current = { + dayId, + index, + scrollTop: body?.parentElement?.scrollTop ?? 0, + panelHeight: body?.closest('[role="presentation"]')?.offsetHeight ?? 0, + } + onDeleteDay?.(dayId) + } + + // Closing the dialog also drops a question left open in it; so does the + // dialog going away with the sidebar that holds it. + const questionRef = useRef(deleteQuestion) + questionRef.current = deleteQuestion + const close = () => { questionRef.current?.onCancel(); onClose() } + useEffect(() => { if (!isOpen) questionRef.current?.onCancel() }, [isOpen]) + useEffect(() => () => questionRef.current?.onCancel(), []) + + // Escape takes back the question, not the dialog. Captured and stopped + // before the dialog's own listener, which would close everything. + useEffect(() => { + if (!asking) return + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + e.stopPropagation() + asking.onCancel() + } + document.addEventListener('keydown', onKey, true) + return () => document.removeEventListener('keydown', onKey, true) + }, [asking]) + + // The question keeps the dialog at the height the list had, so nothing moves + // under the pointer; a longer one grows it and scrolls inside. Back at the + // list, the scroll position and the focus return to the row asked about. + const askedDayId = asking?.dayId ?? null + const lineCount = asking?.lines.length ?? 0 + const shownDayId = useRef(null) + useLayoutEffect(() => { + const body = bodyRef.current + if (!body) return + const scroller = body.parentElement + const turned = shownDayId.current !== askedDayId + shownDayId.current = askedDayId + body.style.minHeight = '' + if (askedDayId != null) { + const target = spot.current?.panelHeight ?? 0 + const panel = body.closest('[role="presentation"]') + const short = target - (panel?.offsetHeight ?? target) + if (short > 0) body.style.minHeight = `${body.offsetHeight + short}px` + if (turned) { + if (scroller) scroller.scrollTop = 0 + cancelRef.current?.focus() + } + return + } + const from = spot.current + if (!from || !turned) return + spot.current = null + if (scroller) scroller.scrollTop = from.scrollTop + const buttons = body.querySelectorAll('[data-delete-day]') + const again = body.querySelector(`[data-delete-day="${from.dayId}"]`) + ?? buttons[Math.min(from.index, buttons.length - 1)] + again?.focus() + }, [askedDayId, lineCount]) + + const footer = asking ? ( +
+ + +
+ ) : ( + + ) return ( - } - > -

- {t('dayplan.reorderHint')} -

+ +
+ {asking ? ( +
+
+ + +
+

{asking.title}

+

{t('dayplan.deleteDayBody')}

+
+
+ +
+ ) : ( + <> +

{t('dayplan.reorderHint')}

- {/* The popup is a modal, so it portals out of the planner and has to opt - into the long-press drag itself (#1616). Without this a finger only - selects the row text. */} -
- {ordered.map((day, index) => ( -
setDragIndex(index)} - onDragEnd={() => { setDragIndex(null); setOverIndex(null) }} - onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }} - onDrop={e => { - e.preventDefault() - if (dragIndex !== null && dragIndex !== index) move(dragIndex, index) - setDragIndex(null); setOverIndex(null) - }} - style={{ - display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', - borderRadius: 9, - border: '1px solid var(--border-faint)', - background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)', - opacity: dragIndex === index ? 0.5 : 1, - outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none', - outlineOffset: -2, - }} - > - - - {index + 1} - - - {dayLabel(day, index, t, locale)} - - - - {onDeleteDay && ( - <> -
+ + )}
) diff --git a/client/src/components/Trips/TripDateReview.tsx b/client/src/components/Trips/TripDateReview.tsx index 69bb73c3d..05e1ffdf9 100644 --- a/client/src/components/Trips/TripDateReview.tsx +++ b/client/src/components/Trips/TripDateReview.tsx @@ -38,12 +38,12 @@ export default function TripDateReview({ askShift, shiftMode, onShiftMode, remov

{t('dashboard.dateShiftIntro')}

{modes.map(({ mode, label, desc }) => ( ))} diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index c53579d6f..ba4fb65b9 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -1340,7 +1340,8 @@ describe('TripFormModal', () => { expect(within(list).getByText('Planned places: 2')).toBeInTheDocument(); // No start moved, so no shift question. expect(screen.queryByText('Keep bookings on their dates')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger'); + // The longest confirm label of any language stays on one line. + expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger', 'whitespace-nowrap'); expect(onSave).not.toHaveBeenCalled(); }); diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index e92b8e7ae..fb9a1e3fc 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -393,7 +393,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp const labelCls = "flex items-center gap-1 text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2" const ghostBtnCls = "px-4 py-2.5 text-body font-medium text-content-secondary hover:text-content border border-edge rounded-xl hover:bg-surface-hover transition-colors" const primaryBtnCls = "px-5 py-2.5 text-body font-medium bg-accent hover:bg-accent-hover disabled:opacity-50 text-accent-text rounded-xl shadow-card transition-colors flex items-center gap-2" - const dangerBtnCls = "px-5 py-2.5 text-body font-medium bg-danger hover:opacity-90 disabled:opacity-50 text-white rounded-xl shadow-card transition-opacity flex items-center gap-2" + const dangerBtnCls = "px-5 py-2.5 text-body font-medium whitespace-nowrap bg-danger hover:opacity-90 disabled:opacity-50 text-white rounded-xl shadow-card transition-opacity flex items-center gap-2" /* Two columns once there is room for them: the form had grown to eight stacked blocks and the create button sat a full screen below the title. Left is what the trip looks like, right is when and with whom. One column below md. */ diff --git a/client/src/components/shared/DayImpactList.test.tsx b/client/src/components/shared/DayImpactList.test.tsx index 182b6dc3f..8914c7373 100644 --- a/client/src/components/shared/DayImpactList.test.tsx +++ b/client/src/components/shared/DayImpactList.test.tsx @@ -1,6 +1,6 @@ -// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-004 +// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-006 import { describe, it, expect } from 'vitest' -import { BedDouble, MapPin } from 'lucide-react' +import { BedDouble, CalendarCheck, MapPin } from 'lucide-react' import { render, screen } from '../../../tests/helpers/render' import DayImpactList from './DayImpactList' import MDayImpactList from '../../mobile/components/MDayImpactList' @@ -23,12 +23,16 @@ describe('DayImpactList', () => { expect(rows[1]).toHaveTextContent('Planned places: 3') }) - it('FE-COMP-DAYIMPACT-002: the tone reaches the row: money in the danger tokens, the rest in the content tokens', () => { + it('FE-COMP-DAYIMPACT-002: the tone reaches the row: money tints the whole row in the danger tokens, the rest stays in the content tokens', () => { render() const [stay, places] = screen.getAllByRole('listitem') expect(stay).toHaveAttribute('data-tone', 'danger') + expect(stay).toHaveClass('bg-danger-soft') expect(screen.getByText('Stay at Harbour Hotel')).toHaveClass('text-danger') - expect(stay.querySelector('span')).toHaveClass('bg-danger-soft') + // The chip stands out on the tinted row instead of melting into it. + expect(stay.querySelector('span')).toHaveClass('text-danger') + expect(stay.querySelector('span')).not.toHaveClass('bg-danger-soft') + expect(places).not.toHaveClass('bg-danger-soft') expect(screen.getByText('Planned places: 3')).toHaveClass('text-content') expect(places.querySelectorAll('p')).toHaveLength(1) }) @@ -63,4 +67,28 @@ describe('DayImpactList', () => { // The first content row sits under the day chips, so it gets the hairline. expect(phoneRows[1].className).toContain('border-t') }) + it('FE-COMP-DAYIMPACT-005: a day with nothing on it is one plain line, without the card and its chips', () => { + const empty: ImpactLine[] = [{ key: 'empty', icon: CalendarCheck, tone: 'muted', text: 'Nothing is planned on this day.' }] + render() + const list = screen.getByRole('list', { name: 'desk' }) + const rows = list.querySelectorAll('li') + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveAttribute('data-kind', 'empty') + expect(rows[0]).toHaveTextContent('Nothing is planned on this day.') + expect(list).not.toHaveClass('divide-y') + expect(rows[0].querySelectorAll('p')).toHaveLength(0) + + // The same quiet line on the phone, and a muted line among others keeps its row. + render() + expect(screen.getByRole('list', { name: 'phone' }).querySelector('li')?.className).toContain('var(--m-inner)') + render() + expect(screen.getByRole('list', { name: 'mixed' }).querySelectorAll('li')).toHaveLength(3) + }) + + it('FE-COMP-DAYIMPACT-006: on the phone the cancelled stay is tinted as a whole row too', () => { + render() + const [stay, places] = Array.from(screen.getByRole('list', { name: 'phone' }).querySelectorAll('li')) + expect(stay.className).toContain('var(--m-st-danger)') + expect(places.className).not.toContain('var(--m-st-danger)') + }) }) diff --git a/client/src/components/shared/DayImpactList.tsx b/client/src/components/shared/DayImpactList.tsx index f00f92240..02ac20eab 100644 --- a/client/src/components/shared/DayImpactList.tsx +++ b/client/src/components/shared/DayImpactList.tsx @@ -15,24 +15,33 @@ export interface ImpactListSkin { hint: string chipTone: Record textTone: Record + /** The whole row per tone: a cancelled stay is tinted from edge to edge, not only in its text. */ + rowTone: Record + /** A day with nothing on it: one quiet line instead of a card. */ + plain: string + plainIcon: string } const DESKTOP: ImpactListSkin = { - list: 'mt-4 overflow-hidden rounded-xl border border-edge-faint bg-surface-secondary divide-y divide-edge-faint', - row: 'flex items-start gap-3 px-3 py-2.5', + list: 'mt-3 overflow-hidden rounded-xl border border-edge-faint divide-y divide-edge-faint', + row: 'flex items-start gap-2.5 px-3 py-2', divider: '', - chip: 'mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg', - dayChip: 'rounded-full border border-edge-faint bg-surface px-2.5 py-0.5 text-caption font-medium text-content-secondary', - iconSize: 15, - text: 'text-body font-medium leading-snug', - hint: 'mt-0.5 text-caption text-content-muted', + chip: 'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg', + dayChip: 'rounded-full border border-edge-faint bg-surface-secondary px-2 py-px text-caption font-medium text-content-secondary', + iconSize: 14, + text: 'text-body font-medium leading-snug text-pretty', + hint: 'mt-px text-caption leading-snug text-content-muted text-pretty', chipTone: { - neutral: 'bg-surface text-content-muted', - muted: 'bg-surface text-content-faint', + neutral: 'bg-surface-secondary text-content-muted', + muted: 'bg-surface-secondary text-content-faint', warning: 'bg-warning-soft text-warning', - danger: 'bg-danger-soft text-danger', + // The row itself is tinted already; a card-coloured chip stands out on it in both themes. + danger: 'bg-surface-card text-danger', }, textTone: { neutral: 'text-content', muted: 'text-content-muted', warning: 'text-content', danger: 'text-danger' }, + rowTone: { neutral: '', muted: '', warning: '', danger: 'bg-danger-soft' }, + plain: 'mt-3 flex items-center gap-2 rounded-xl bg-surface-secondary px-3 py-2.5 text-body text-content-muted', + plainIcon: 'flex-shrink-0 text-content-faint', } interface DayImpactListProps { @@ -53,11 +62,22 @@ interface DayImpactListProps { * chip in the row's tone, the count, and a caption saying what happens to it. * The rows come ready made from utils/dayImpactLines, and the look comes from a * skin, so the desktop dialog and the phone sheet share this markup and differ - * only in their tokens. + * only in their tokens. A day with nothing on it gets one plain line. */ export default function DayImpactList({ lines, days = [], label, skin = DESKTOP }: DayImpactListProps) { if (lines.length === 0) return null const offset = days.length > 0 ? 1 : 0 + if (offset === 0 && lines.length === 1 && lines[0].tone === 'muted') { + const { key, icon: Icon, text } = lines[0] + return ( +
    +
  • +
  • +
+ ) + } return (
    {offset > 0 && ( @@ -65,17 +85,17 @@ export default function DayImpactList({ lines, days = [], label, skin = DESKTOP -
    +
    {days.map((name, i) => {name})}
    )} {lines.map(({ key, icon: Icon, text, hint, tone }, i) => ( -
  • 0 ? skin.divider : ''}`.trim()}> +
  • 0 ? skin.divider : ''}`.trim()}> -
    +

    {text}

    {hint &&

    {hint}

    }
    diff --git a/client/src/mobile/components/MDayImpactList.tsx b/client/src/mobile/components/MDayImpactList.tsx index 3b58ee385..999c24dec 100644 --- a/client/src/mobile/components/MDayImpactList.tsx +++ b/client/src/mobile/components/MDayImpactList.tsx @@ -1,21 +1,23 @@ import DayImpactList, { type ImpactListSkin } from '../../components/shared/DayImpactList' import type { ImpactLine } from '../../utils/dayImpactLines' +const DANGER_TINT = 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_9%,transparent)]' + /** The mobile tokens for the same rows: an inset card with hairlines between rows. */ const PHONE: ImpactListSkin = { list: 'mt-3 overflow-hidden rounded-[13px] border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)]', - row: 'flex items-start gap-[10px] px-[11px] py-[9px]', + row: 'flex items-start gap-[10px] px-[11px] py-[8px]', divider: 'border-t border-[color:var(--m-rowbr)]', - chip: 'mt-[1px] flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full', - dayChip: 'rounded-full bg-[color:var(--m-ic)] px-[9px] py-[2px] text-[0.6875rem] font-semibold text-m-ink', + chip: 'flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full', + dayChip: 'rounded-full bg-[color:var(--m-ic)] px-[8px] py-[1px] text-[0.6875rem] font-semibold text-m-ink', iconSize: 13, - text: 'text-[0.78125rem] font-semibold leading-snug', - hint: 'mt-[2px] text-[0.6875rem] leading-snug text-m-muted', + text: 'text-[0.78125rem] font-semibold leading-snug text-pretty', + hint: 'mt-[1px] text-[0.6875rem] leading-snug text-m-muted text-pretty', chipTone: { neutral: 'bg-[color:var(--m-ic)] text-m-muted', muted: 'bg-[color:var(--m-ic)] text-m-faint', warning: 'bg-[color:color-mix(in_srgb,var(--m-st-pending)_14%,transparent)] text-[color:var(--m-st-pending)]', - danger: 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)]', + danger: 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_14%,transparent)] text-[color:var(--m-st-danger)]', }, textTone: { neutral: 'text-m-ink', @@ -23,6 +25,9 @@ const PHONE: ImpactListSkin = { warning: 'text-m-ink', danger: 'text-[color:var(--m-st-danger)]', }, + rowTone: { neutral: '', muted: '', warning: '', danger: DANGER_TINT }, + plain: 'mt-3 flex items-center gap-[8px] rounded-[13px] border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-[11px] py-[10px] text-[0.75rem] text-m-muted', + plainIcon: 'flex-none text-m-faint', } /** The phone's day impact list: DayImpactList drawn in the mobile tokens. */ diff --git a/client/src/mobile/screens/settings/MConfirmSheet.tsx b/client/src/mobile/screens/settings/MConfirmSheet.tsx index b565c1691..2e536ad8b 100644 --- a/client/src/mobile/screens/settings/MConfirmSheet.tsx +++ b/client/src/mobile/screens/settings/MConfirmSheet.tsx @@ -38,15 +38,16 @@ export default function MConfirmSheet({
    {title}
    -

    {message}

    +

    {message}

    {children}
    -
    - + {/* A long label moves its button to a row of its own instead of wrapping inside it. */} +
    + {cancelLabel} {onConfirm && confirmLabel && ( - + {confirmLabel} )} diff --git a/client/src/mobile/screens/trip/sheets/MDaysSheet.tsx b/client/src/mobile/screens/trip/sheets/MDaysSheet.tsx index ea11fb6dc..1e72f39d5 100644 --- a/client/src/mobile/screens/trip/sheets/MDaysSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MDaysSheet.tsx @@ -5,67 +5,23 @@ import { INNER_CLS, TileHeader } from './MTripSheetUi' import { useTranslation } from '../../../../i18n' import { dayLabel } from '../../../../utils/dayLabel' import { formatDate } from '../../../../utils/formatters' -import type { DayAddControls } from '../../../../utils/dayAdd' import type { MTripSheetsProps } from '../MTripShell' -const TILE = 'flex w-full items-center gap-[10px] rounded-[13px] px-[11px] text-left disabled:opacity-40' -const TILE_ICON = 'flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full' -const TILE_TITLE = 'block text-[0.8125rem] font-semibold leading-tight' -const TILE_HINT = 'mt-[2px] block text-[0.6875rem] leading-snug' - -/** - * The two ways to add a day on a trip with dates, as tiles: the next date on - * top in the action colour, since it is the one that moves the trip's end, and - * a day without a date below it in the dashed look the single button had. - */ -function DayAddTiles({ dayAdd, date, onAddUndated, t }: { - dayAdd: DayAddControls - date: string - onAddUndated: () => void - t: (key: string, params?: Record) => string -}) { - const addOff = dayAdd.busy || !!dayAdd.blocked - return ( -
    - - -
    - ) -} +/** A 44px pill, the tap size of the sheet's other actions; the label never wraps, the row does. */ +const PILL = 'inline-flex h-11 flex-1 basis-auto items-center justify-center gap-[6px] whitespace-nowrap rounded-full px-4 text-[0.8125rem] font-semibold disabled:opacity-40' +const PILL_MAIN = `${PILL} bg-m-act text-m-actfg` +const PILL_QUIET = `${PILL} border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink` /** * Day management sheet ('days'): move whole days up/down, add a day or delete * one, the mobile counterpart of the desktop DayReorderPopup and button-based * like the rest of the touch reordering (#1432). A day's places, notes and * bookings move with it (store handles that optimistically). Delete only asks; - * the confirm sheet with what goes with the day is MTripSheets'. On a trip with - * dates a day can be added with the next date as well as without one. + * the confirm sheet with what goes with the day is MTripSheets'. + * + * Adding sits under the list and stays in reach while the list scrolls: on a + * trip with dates the next date, which extends the trip, and a day without a + * date, with one line above them saying what the dated one does. */ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) { const { t, locale } = useTranslation() @@ -74,8 +30,13 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) { const ordered = [...planner.days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) const deleteBlocked = planner.deleteDayBlocked const { dayAdd } = planner - // Offline, both say the same sentence; it is shown once. - const notices = [deleteBlocked, dayAdd.blocked].filter((n, i, all): n is string => !!n && all.indexOf(n) === i) + const addOff = dayAdd.busy || !!dayAdd.blocked + const date = dayAdd.nextDate ? formatDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate : null + // The lines above the buttons: why a day cannot be deleted, and what the + // dated button does or why adding is off. Offline both say the same + // sentence; it is shown once. + const addLine = dayAdd.blocked ?? (date ? dayAdd.datedBlocked ?? t('dayplan.addDatedDayHint', { date }) : null) + const captions = [deleteBlocked, addLine].filter((n, i, all): n is string => !!n && all.indexOf(n) === i) const move = (from: number, to: number) => { if (to < 0 || to >= ordered.length || from === to) return @@ -98,7 +59,7 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) { />
    -
    +
    {ordered.map((day, i) => (
    @@ -122,7 +83,7 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) { disabled={!!deleteBlocked} aria-label={t('dayplan.deleteDay')} title={deleteBlocked ?? undefined} - className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)] disabled:text-m-faint disabled:opacity-40" + className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted active:text-[color:var(--m-st-danger)] disabled:opacity-30" > @@ -130,29 +91,34 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
    ))}
    - {canEditDays && dayAdd.nextDate && ( - planner.handleAddDay()} - t={t} - /> - )} - {canEditDays && !dayAdd.nextDate && ( - - )} - {canEditDays && notices.map(notice => ( -

    {notice}

    - ))}
    + + {canEditDays && ( +
    + {captions.map(line => ( +

    {line}

    + ))} +
    + {date ? ( + <> + + + + ) : ( + + )} +
    +
    + )} ) } diff --git a/client/src/pages/TripPlannerPage.test.tsx b/client/src/pages/TripPlannerPage.test.tsx index 3fc392390..4d0eb6045 100644 --- a/client/src/pages/TripPlannerPage.test.tsx +++ b/client/src/pages/TripPlannerPage.test.tsx @@ -1771,24 +1771,26 @@ describe('TripPlannerPage', () => { expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument(); }); - // The sidebar hands the reorder dialog its delete action; nothing opens until it is used. + // The sidebar hands the reorder dialog its delete action; no question is open until it is used. expect(typeof capturedDayPlanSidebarProps.current.onDeleteDay).toBe('function'); - expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + expect(capturedDayPlanSidebarProps.current.deleteDayQuestion).toBeNull(); await act(async () => { capturedDayPlanSidebarProps.current.onDeleteDay(harbour.id); }); + // The question goes to the reorder dialog, which asks it in place of its + // list; the page stacks no second dialog on top of it. await waitFor(() => { - expect(capturedConfirmDialogsByTitle.current['Delete Harbour day?']?.isOpen).toBe(true); + expect(capturedDayPlanSidebarProps.current.deleteDayQuestion?.dayId).toBe(harbour.id); }); - const dialog = capturedConfirmDialogsByTitle.current['Delete Harbour day?']; - expect(dialog.message).toBe('The day is removed from the trip. This cannot be undone.'); - expect(dialog.confirmLabel).toBe('Delete day'); - expect(screen.getByRole('list', { name: 'Delete Harbour day?' })).toHaveTextContent('Day titles and descriptions: 1'); + expect(capturedConfirmDialogsByTitle.current['Delete Harbour day?']).toBeUndefined(); + const question = capturedDayPlanSidebarProps.current.deleteDayQuestion; + expect(question.title).toBe('Delete Harbour day?'); + expect(question.lines.map((l: { text: string }) => l.text)).toContain('Day titles and descriptions: 1'); - await act(async () => { await dialog.onConfirm?.(); }); + await act(async () => { question.onConfirm(); }); expect(deleteDay).toHaveBeenCalledWith(42, harbour.id); await waitFor(() => { - expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + expect(capturedDayPlanSidebarProps.current.deleteDayQuestion).toBeNull(); }); }); }); diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index daaf1d946..59deb9195 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -42,7 +42,6 @@ import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsAp import { accommodationRepo } from '../repo/accommodationRepo' import { useAuthStore } from '../store/authStore' import ConfirmDialog from '../components/shared/ConfirmDialog' -import DayImpactList from '../components/shared/DayImpactList' import { useTripWebSocket } from '../hooks/useTripWebSocket' import { useRouteCalculation } from '../hooks/useRouteCalculation' import { usePlaceSelection } from '../hooks/usePlaceSelection' @@ -305,7 +304,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick, handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory, handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, dayAdd, handleUpdateDayTitle, - deleteDayId, setDeleteDayId, deleteDayTitle, deleteDayLines, handleDeleteDay, confirmDeleteDay, + deleteDayQuestion, handleDeleteDay, handleSaveReservation, handleSaveTransport, handleDeleteReservation, selectedPlace, dayOrderMap, dayPlaces, mapTileUrl, fontStyle, splashDone, @@ -660,6 +659,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} + deleteDayQuestion={deleteDayQuestion} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} @@ -952,7 +952,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
    {mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile /> : handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
    @@ -1180,18 +1180,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null { ? `${t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} ${deletePlacesNote}` : t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} /> - {/* Deleting a day from the reorder dialog: the question lists what goes - with the day, and a booked stay in it shows in the danger tone. */} - setDeleteDayId(null)} - onConfirm={confirmDeleteDay} - title={deleteDayTitle} - message={t('dayplan.deleteDayBody')} - confirmLabel={t('dayplan.deleteDay')} - > - - setStayRelease(null)} diff --git a/client/src/pages/tripPlanner/useDayDelete.ts b/client/src/pages/tripPlanner/useDayDelete.ts index 40344368d..39dad5f9d 100644 --- a/client/src/pages/tripPlanner/useDayDelete.ts +++ b/client/src/pages/tripPlanner/useDayDelete.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react' import { useTripStore } from '../../store/tripStore' import { useNetworkMode } from '../../hooks/useNetworkMode' import { dayDeleteImpact, deleteDayBlockedReason } from '../../utils/dayDeleteImpact' -import { deleteDayLines as buildDeleteDayLines, type ImpactLine } from '../../utils/dayImpactLines' +import { deleteDayLines as buildDeleteDayLines, type DayDeleteQuestion, type ImpactLine } from '../../utils/dayImpactLines' import { dayLabel } from '../../utils/dayLabel' import { formatDate } from '../../utils/formatters' import type { Accommodation, Day, Place, Reservation, Trip } from '../../types' @@ -39,6 +39,8 @@ export interface DayDelete { deleteDayBlocked: string | null handleDeleteDay: (dayId: number) => void confirmDeleteDay: () => Promise + /** The same question as one object, for the reorder dialog to ask in place; null while none is open. */ + deleteDayQuestion: DayDeleteQuestion | null } const byNumber = (a: Day, b: Day): number => (a.day_number ?? 0) - (b.day_number ?? 0) @@ -100,5 +102,13 @@ export function useDayDelete(options: DayDeleteOptions): DayDelete { toast.success(t('dayplan.deleteDaySuccess')) }, [deleteDayId, tripId, toast, t, onDeleted]) - return { deleteDayId, setDeleteDayId, deleteDayTitle, deleteDayLines, deleteDayBlocked, handleDeleteDay, confirmDeleteDay } + const cancelDeleteDay = useCallback(() => setDeleteDayId(null), []) + const deleteDayQuestion = useMemo( + () => (target + ? { dayId: target.id, title: deleteDayTitle, lines: deleteDayLines, onCancel: cancelDeleteDay, onConfirm: () => { void confirmDeleteDay() } } + : null), + [target, deleteDayTitle, deleteDayLines, cancelDeleteDay, confirmDeleteDay], + ) + + return { deleteDayId, setDeleteDayId, deleteDayTitle, deleteDayLines, deleteDayBlocked, handleDeleteDay, confirmDeleteDay, deleteDayQuestion } } diff --git a/client/src/pages/tripPlanner/useTripPlanner.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.test.tsx index fb745df1c..6d7a53b08 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.test.tsx @@ -1,4 +1,4 @@ -// FE-TP-HOOK-001 to FE-TP-HOOK-134 +// FE-TP-HOOK-001 to FE-TP-HOOK-135 import React from 'react' import { renderHook, act, waitFor } from '@testing-library/react' import { TranslationProvider } from '../../i18n/TranslationContext' @@ -2338,6 +2338,27 @@ describe('useTripPlanner: deleting a day', () => { expect(result.current.deleteDayLines.find(l => l.key === 'places')?.text).toBe('Planned places: 1') }) + it('FE-TP-HOOK-135: the open question comes as one object for the reorder dialog, whose answers close it', async () => { + seedDays() + const { result } = await renderPlanner() + expect(result.current.deleteDayQuestion).toBeNull() + + act(() => { result.current.handleDeleteDay(2) }) + const asked = result.current.deleteDayQuestion + expect(asked?.dayId).toBe(2) + expect(asked?.title).toBe('Delete Harbour day?') + expect(asked?.lines).toBe(result.current.deleteDayLines) + + act(() => { asked?.onCancel() }) + expect(result.current.deleteDayQuestion).toBeNull() + expect(actions.deleteDay).not.toHaveBeenCalled() + + act(() => { result.current.handleDeleteDay(2) }) + await act(async () => { result.current.deleteDayQuestion?.onConfirm() }) + await waitFor(() => expect(actions.deleteDay).toHaveBeenCalledWith(42, 2)) + expect(result.current.deleteDayQuestion).toBeNull() + }) + it('FE-TP-HOOK-123: confirming deletes through the store, closes the question and reloads the stays', async () => { seedDays() const { result } = await renderPlanner() diff --git a/client/src/utils/dayImpactLines.ts b/client/src/utils/dayImpactLines.ts index 4625188ea..c1dacfbec 100644 --- a/client/src/utils/dayImpactLines.ts +++ b/client/src/utils/dayImpactLines.ts @@ -22,6 +22,18 @@ export interface ImpactLine { tone: ImpactTone } +/** + * The delete question while it is open. The reorder dialog asks it in place of + * its day list: which day, the title and the list, and the two ways out. + */ +export interface DayDeleteQuestion { + dayId: number + title: string + lines: ImpactLine[] + onCancel: () => void + onConfirm: () => void +} + /** * The question the list answers. Deleting a day cancels a stay cleanly, with * its booking and expense. Shortening a trip removes the stay through the day diff --git a/client/tests/unit/mobile/settings/MConfirmSheet.test.tsx b/client/tests/unit/mobile/settings/MConfirmSheet.test.tsx index fe9a08ce1..8a4a958f2 100644 --- a/client/tests/unit/mobile/settings/MConfirmSheet.test.tsx +++ b/client/tests/unit/mobile/settings/MConfirmSheet.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import MConfirmSheet from '../../../../src/mobile/screens/settings/MConfirmSheet' -// FE-MOB-CONFIRM-001 to FE-MOB-CONFIRM-003 +// FE-MOB-CONFIRM-001 to FE-MOB-CONFIRM-004 describe('MConfirmSheet', () => { it('FE-MOB-CONFIRM-001: a long list scrolls between the title and the buttons, which stay in reach', () => { @@ -43,4 +43,11 @@ describe('MConfirmSheet', () => { render() expect(screen.getAllByRole('button').map(b => b.textContent)).toEqual(['OK']) }) + it('FE-MOB-CONFIRM-004: a long label never wraps inside its button; the row makes room instead', () => { + render() + const confirm = screen.getByRole('button', { name: 'Retirer les jours et enregistrer' }) + expect(confirm).toHaveClass('whitespace-nowrap') + expect(screen.getByRole('button', { name: 'Annuler' })).toHaveClass('whitespace-nowrap') + expect(confirm.parentElement).toHaveClass('flex-wrap') + }) }) diff --git a/client/tests/unit/mobile/trip/MDaysSheet.test.tsx b/client/tests/unit/mobile/trip/MDaysSheet.test.tsx index 428e1db27..02a4a6822 100644 --- a/client/tests/unit/mobile/trip/MDaysSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MDaysSheet.test.tsx @@ -6,7 +6,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { resetAllStores } from '../../../helpers/store' import { cleanup, fireEvent, render, screen } from '../../../helpers/render' -// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-019 +// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-020 // // The sheet reads its copy from the real TranslationProvider (useTranslation), // not from planner.t — assertions therefore go against the English strings. @@ -148,46 +148,61 @@ describe('MDaysSheet', () => { const dayAdd = (overrides: Partial = {}): TripPlanner['dayAdd'] => ({ nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn(), ...overrides, }) - const datedTile = () => screen.getByRole('button', { name: /^Add .*Oct 13/ }) - const undatedTile = () => screen.getByRole('button', { name: /^Add day without date/ }) + const datedButton = () => screen.getByRole('button', { name: /^Add .*Oct 13/ }) + const undatedButton = () => screen.getByRole('button', { name: 'Without date' }) - it('FE-MOB-DAYSS-016: two tiles, the next date and a day without one, each with what it does', () => { + it('FE-MOB-DAYSS-016: two buttons, the next date and a day without one, under a line saying what the dated one does', () => { const { planner } = renderSheet({ dayAdd: dayAdd() }) // formatDate adds the year once the date lies outside the current year. - expect(datedTile()).toHaveTextContent(/Extends the trip by one day, until .*Oct 13(, \d{4})?\./) - expect(undatedTile()).toHaveTextContent('The trip dates stay as they are.') + expect(screen.getByText(/^Extends the trip by one day, until .*Oct 13(, \d{4})?\.$/)).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Add day' })).not.toBeInTheDocument() - fireEvent.click(datedTile()) + fireEvent.click(datedButton()) expect(planner.dayAdd.onAddDated).toHaveBeenCalledTimes(1) expect(planner.handleAddDay).not.toHaveBeenCalled() - fireEvent.click(undatedTile()) + fireEvent.click(undatedButton()) expect(planner.handleAddDay).toHaveBeenCalledTimes(1) }) - it('FE-MOB-DAYSS-017: a read-only member gets neither tile', () => { + it('FE-MOB-DAYSS-017: a read-only member gets neither button', () => { renderSheet({ can: vi.fn(() => false), dayAdd: dayAdd() }) expect(screen.queryByRole('button', { name: /^Add/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Without date' })).not.toBeInTheDocument() }) it('FE-MOB-DAYSS-018: busy or offline turns both off, and the offline sentence is said once', () => { renderSheet({ dayAdd: dayAdd({ busy: true }) }) - expect(datedTile()).toBeDisabled() - expect(undatedTile()).toBeDisabled() + expect(datedButton()).toBeDisabled() + expect(undatedButton()).toBeDisabled() cleanup() const offline = 'Changing days needs a connection' renderSheet({ deleteDayBlocked: offline, dayAdd: dayAdd({ blocked: offline }) }) - expect(datedTile()).toBeDisabled() - expect(undatedTile()).toBeDisabled() + expect(datedButton()).toBeDisabled() + expect(undatedButton()).toBeDisabled() expect(screen.getAllByText(offline)).toHaveLength(1) + // The offline sentence stands in for the dated line, not beside it. + expect(screen.queryByText(/^Extends the trip/)).not.toBeInTheDocument() }) - it('FE-MOB-DAYSS-019: a trip at the day limit keeps the undated tile and says why the dated one is off', () => { + it('FE-MOB-DAYSS-019: a trip at the day limit keeps the undated button and says why the dated one is off', () => { renderSheet({ dayAdd: dayAdd({ datedBlocked: 'A trip can span at most 999 days' }) }) - expect(datedTile()).toBeDisabled() - expect(datedTile()).toHaveTextContent('A trip can span at most 999 days') - expect(undatedTile()).toBeEnabled() + expect(datedButton()).toBeDisabled() + expect(screen.getByText('A trip can span at most 999 days')).toBeInTheDocument() + expect(undatedButton()).toBeEnabled() + }) + + it('FE-MOB-DAYSS-020: the add buttons stay in reach below the list, which scrolls on its own', () => { + renderSheet({ dayAdd: dayAdd() }) + const list = screen.getByText('Old Town').closest('.overflow-y-auto') + expect(list).not.toBeNull() + expect(list).not.toContainElement(datedButton()) + expect(list).not.toContainElement(undatedButton()) + // One tap size for both, and a label that never wraps inside its button. + for (const button of [datedButton(), undatedButton()]) { + expect(button).toHaveClass('h-11') + expect(button).toHaveClass('whitespace-nowrap') + } }) }) }) diff --git a/shared/src/i18n/ar/dayplan.ts b/shared/src/i18n/ar/dayplan.ts index 2c3fda81b..4b48bb798 100644 --- a/shared/src/i18n/ar/dayplan.ts +++ b/shared/src/i18n/ar/dayplan.ts @@ -121,7 +121,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'تغيير الأيام يتطلب اتصالًا', 'dayplan.deleteDaySuccess': 'تم حذف اليوم', 'dayplan.deleteDayError': 'تعذّر حذف اليوم', - 'dayplan.addUndatedDay': 'إضافة يوم بلا تاريخ', + 'dayplan.addUndatedDay': 'بلا تاريخ', 'dayplan.addUndatedDayHint': 'يضيف يومًا بلا تاريخ في النهاية. تبقى تواريخ الرحلة كما هي.', 'dayplan.addDatedDay': 'إضافة {date}', 'dayplan.addDatedDayHint': 'يمدّد الرحلة يومًا واحدًا حتى {date}.', diff --git a/shared/src/i18n/br/dayplan.ts b/shared/src/i18n/br/dayplan.ts index 142159bd9..8043c7ccf 100644 --- a/shared/src/i18n/br/dayplan.ts +++ b/shared/src/i18n/br/dayplan.ts @@ -122,7 +122,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Alterar os dias exige conexão', 'dayplan.deleteDaySuccess': 'Dia excluído', 'dayplan.deleteDayError': 'Falha ao excluir o dia', - 'dayplan.addUndatedDay': 'Adicionar dia sem data', + 'dayplan.addUndatedDay': 'Sem data', 'dayplan.addUndatedDayHint': 'Adiciona um dia sem data no final. As datas da viagem continuam as mesmas.', 'dayplan.addDatedDay': 'Adicionar {date}', 'dayplan.addDatedDayHint': 'Estende a viagem em um dia, até {date}.', diff --git a/shared/src/i18n/ca/dayplan.ts b/shared/src/i18n/ca/dayplan.ts index e9dbab809..925fed236 100644 --- a/shared/src/i18n/ca/dayplan.ts +++ b/shared/src/i18n/ca/dayplan.ts @@ -122,7 +122,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Per canviar els dies cal connexió', 'dayplan.deleteDaySuccess': 'Dia eliminat', 'dayplan.deleteDayError': "No s'ha pogut eliminar el dia", - 'dayplan.addUndatedDay': 'Afegeix un dia sense data', + 'dayplan.addUndatedDay': 'Sense data', 'dayplan.addUndatedDayHint': 'Afegeix un dia sense data al final. Les dates del viatge no canvien.', 'dayplan.addDatedDay': 'Afegeix {date}', 'dayplan.addDatedDayHint': 'Allarga el viatge un dia, fins al {date}.', diff --git a/shared/src/i18n/cs/dayplan.ts b/shared/src/i18n/cs/dayplan.ts index 4c21f6222..96d41caf9 100644 --- a/shared/src/i18n/cs/dayplan.ts +++ b/shared/src/i18n/cs/dayplan.ts @@ -123,7 +123,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Změna dnů vyžaduje připojení', 'dayplan.deleteDaySuccess': 'Den smazán', 'dayplan.deleteDayError': 'Den se nepodařilo smazat', - 'dayplan.addUndatedDay': 'Přidat den bez data', + 'dayplan.addUndatedDay': 'Bez data', 'dayplan.addUndatedDayHint': 'Přidá na konec den bez data. Termín cesty zůstane stejný.', 'dayplan.addDatedDay': 'Přidat {date}', 'dayplan.addDatedDayHint': 'Prodlouží cestu o jeden den, do {date}.', diff --git a/shared/src/i18n/de/dayplan.ts b/shared/src/i18n/de/dayplan.ts index 093319f20..de6a4a8d0 100644 --- a/shared/src/i18n/de/dayplan.ts +++ b/shared/src/i18n/de/dayplan.ts @@ -123,7 +123,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Tage ändern geht nur mit Verbindung', 'dayplan.deleteDaySuccess': 'Tag gelöscht', 'dayplan.deleteDayError': 'Tag konnte nicht gelöscht werden', - 'dayplan.addUndatedDay': 'Tag ohne Datum hinzufügen', + 'dayplan.addUndatedDay': 'Ohne Datum', 'dayplan.addUndatedDayHint': 'Hängt hinten einen Tag ohne Datum an. Die Reisedaten bleiben, wie sie sind.', 'dayplan.addDatedDay': '{date} hinzufügen', 'dayplan.addDatedDayHint': 'Verlängert die Reise um einen Tag, bis {date}.', diff --git a/shared/src/i18n/en/dayplan.ts b/shared/src/i18n/en/dayplan.ts index 3783c581e..d0f1830c0 100644 --- a/shared/src/i18n/en/dayplan.ts +++ b/shared/src/i18n/en/dayplan.ts @@ -122,7 +122,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Changing days needs a connection', 'dayplan.deleteDaySuccess': 'Day deleted', 'dayplan.deleteDayError': 'Failed to delete day', - 'dayplan.addUndatedDay': 'Add day without date', + 'dayplan.addUndatedDay': 'Without date', 'dayplan.addUndatedDayHint': 'Adds a day without a date at the end. The trip dates stay as they are.', 'dayplan.addDatedDay': 'Add {date}', 'dayplan.addDatedDayHint': 'Extends the trip by one day, until {date}.', diff --git a/shared/src/i18n/es/dayplan.ts b/shared/src/i18n/es/dayplan.ts index 7b46c53a1..36197b985 100644 --- a/shared/src/i18n/es/dayplan.ts +++ b/shared/src/i18n/es/dayplan.ts @@ -122,7 +122,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Cambiar los días requiere conexión', 'dayplan.deleteDaySuccess': 'Día eliminado', 'dayplan.deleteDayError': 'No se pudo eliminar el día', - 'dayplan.addUndatedDay': 'Añadir día sin fecha', + 'dayplan.addUndatedDay': 'Sin fecha', 'dayplan.addUndatedDayHint': 'Añade un día sin fecha al final. Las fechas del viaje no cambian.', 'dayplan.addDatedDay': 'Añadir {date}', 'dayplan.addDatedDayHint': 'Alarga el viaje un día, hasta el {date}.', diff --git a/shared/src/i18n/fr/dayplan.ts b/shared/src/i18n/fr/dayplan.ts index 0c9e1222d..23aaa3230 100644 --- a/shared/src/i18n/fr/dayplan.ts +++ b/shared/src/i18n/fr/dayplan.ts @@ -122,7 +122,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Modifier les jours nécessite une connexion', 'dayplan.deleteDaySuccess': 'Jour supprimé', 'dayplan.deleteDayError': 'Échec de la suppression du jour', - 'dayplan.addUndatedDay': 'Ajouter un jour sans date', + 'dayplan.addUndatedDay': 'Sans date', 'dayplan.addUndatedDayHint': 'Ajoute un jour sans date à la fin. Les dates du voyage ne changent pas.', 'dayplan.addDatedDay': 'Ajouter {date}', 'dayplan.addDatedDayHint': "Prolonge le voyage d'un jour, jusqu'au {date}.", diff --git a/shared/src/i18n/gr/dayplan.ts b/shared/src/i18n/gr/dayplan.ts index 443be4206..f1758bfa9 100644 --- a/shared/src/i18n/gr/dayplan.ts +++ b/shared/src/i18n/gr/dayplan.ts @@ -125,7 +125,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Η αλλαγή ημερών απαιτεί σύνδεση', 'dayplan.deleteDaySuccess': 'Η ημέρα διαγράφηκε', 'dayplan.deleteDayError': 'Η διαγραφή της ημέρας απέτυχε', - 'dayplan.addUndatedDay': 'Προσθήκη ημέρας χωρίς ημερομηνία', + 'dayplan.addUndatedDay': 'Χωρίς ημερομηνία', 'dayplan.addUndatedDayHint': 'Προσθέτει στο τέλος μια ημέρα χωρίς ημερομηνία. Οι ημερομηνίες του ταξιδιού μένουν ίδιες.', 'dayplan.addDatedDay': 'Προσθήκη {date}', diff --git a/shared/src/i18n/hu/dayplan.ts b/shared/src/i18n/hu/dayplan.ts index d186b6fc9..24b99695f 100644 --- a/shared/src/i18n/hu/dayplan.ts +++ b/shared/src/i18n/hu/dayplan.ts @@ -123,7 +123,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'A napok módosításához kapcsolat szükséges', 'dayplan.deleteDaySuccess': 'Nap törölve', 'dayplan.deleteDayError': 'Nem sikerült törölni a napot', - 'dayplan.addUndatedDay': 'Nap hozzáadása dátum nélkül', + 'dayplan.addUndatedDay': 'Dátum nélkül', 'dayplan.addUndatedDayHint': 'Egy dátum nélküli napot ad a végéhez. Az utazás dátumai nem változnak.', 'dayplan.addDatedDay': '{date} hozzáadása', 'dayplan.addDatedDayHint': 'Egy nappal meghosszabbítja az utazást. Új utolsó nap: {date}.', diff --git a/shared/src/i18n/id/dayplan.ts b/shared/src/i18n/id/dayplan.ts index bb03e9629..a8be6134f 100644 --- a/shared/src/i18n/id/dayplan.ts +++ b/shared/src/i18n/id/dayplan.ts @@ -124,7 +124,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Mengubah hari memerlukan koneksi', 'dayplan.deleteDaySuccess': 'Hari dihapus', 'dayplan.deleteDayError': 'Gagal menghapus hari', - 'dayplan.addUndatedDay': 'Tambah hari tanpa tanggal', + 'dayplan.addUndatedDay': 'Tanpa tanggal', 'dayplan.addUndatedDayHint': 'Menambahkan hari tanpa tanggal di akhir. Tanggal perjalanan tetap sama.', 'dayplan.addDatedDay': 'Tambah {date}', 'dayplan.addDatedDayHint': 'Memperpanjang perjalanan satu hari, hingga {date}.', diff --git a/shared/src/i18n/it/dayplan.ts b/shared/src/i18n/it/dayplan.ts index 4d4b48978..06b3706cb 100644 --- a/shared/src/i18n/it/dayplan.ts +++ b/shared/src/i18n/it/dayplan.ts @@ -124,7 +124,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Per modificare i giorni serve una connessione', 'dayplan.deleteDaySuccess': 'Giorno eliminato', 'dayplan.deleteDayError': 'Eliminazione del giorno non riuscita', - 'dayplan.addUndatedDay': 'Aggiungi giorno senza data', + 'dayplan.addUndatedDay': 'Senza data', 'dayplan.addUndatedDayHint': 'Aggiunge un giorno senza data alla fine. Le date del viaggio restano invariate.', 'dayplan.addDatedDay': 'Aggiungi {date}', 'dayplan.addDatedDayHint': 'Allunga il viaggio di un giorno, fino a {date}.', diff --git a/shared/src/i18n/ja/dayplan.ts b/shared/src/i18n/ja/dayplan.ts index a16904572..e2e3acb88 100644 --- a/shared/src/i18n/ja/dayplan.ts +++ b/shared/src/i18n/ja/dayplan.ts @@ -122,7 +122,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': '日の変更には接続が必要です', 'dayplan.deleteDaySuccess': '日を削除しました', 'dayplan.deleteDayError': '日の削除に失敗しました', - 'dayplan.addUndatedDay': '日付なしの日を追加', + 'dayplan.addUndatedDay': '日付なし', 'dayplan.addUndatedDayHint': '日付のない日を最後に追加します。旅行の日程は変わりません。', 'dayplan.addDatedDay': '{date}を追加', 'dayplan.addDatedDayHint': '旅行を1日延長し、{date}までにします。', diff --git a/shared/src/i18n/ko/dayplan.ts b/shared/src/i18n/ko/dayplan.ts index fe8ba4a4b..c9cb45a21 100644 --- a/shared/src/i18n/ko/dayplan.ts +++ b/shared/src/i18n/ko/dayplan.ts @@ -121,7 +121,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': '날짜를 변경하려면 연결이 필요합니다', 'dayplan.deleteDaySuccess': '날짜가 삭제되었습니다', 'dayplan.deleteDayError': '날짜를 삭제하지 못했습니다', - 'dayplan.addUndatedDay': '날짜 없는 날 추가', + 'dayplan.addUndatedDay': '날짜 없이', 'dayplan.addUndatedDayHint': '날짜 없는 날을 마지막에 추가합니다. 여행 날짜는 그대로입니다.', 'dayplan.addDatedDay': '{date} 추가', 'dayplan.addDatedDayHint': '여행을 하루 늘려 {date}까지로 만듭니다.', diff --git a/shared/src/i18n/nl/dayplan.ts b/shared/src/i18n/nl/dayplan.ts index 708257405..2a0c84fad 100644 --- a/shared/src/i18n/nl/dayplan.ts +++ b/shared/src/i18n/nl/dayplan.ts @@ -122,7 +122,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Dagen wijzigen vraagt een verbinding', 'dayplan.deleteDaySuccess': 'Dag verwijderd', 'dayplan.deleteDayError': 'Dag verwijderen mislukt', - 'dayplan.addUndatedDay': 'Dag zonder datum toevoegen', + 'dayplan.addUndatedDay': 'Zonder datum', 'dayplan.addUndatedDayHint': 'Voegt aan het eind een dag zonder datum toe. De reisdata blijven zoals ze zijn.', 'dayplan.addDatedDay': '{date} toevoegen', 'dayplan.addDatedDayHint': 'Verlengt de reis met één dag, tot {date}.', diff --git a/shared/src/i18n/pl/dayplan.ts b/shared/src/i18n/pl/dayplan.ts index 6254bb487..f88d90321 100644 --- a/shared/src/i18n/pl/dayplan.ts +++ b/shared/src/i18n/pl/dayplan.ts @@ -124,7 +124,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Zmiana dni wymaga połączenia', 'dayplan.deleteDaySuccess': 'Dzień usunięty', 'dayplan.deleteDayError': 'Nie udało się usunąć dnia', - 'dayplan.addUndatedDay': 'Dodaj dzień bez daty', + 'dayplan.addUndatedDay': 'Bez daty', 'dayplan.addUndatedDayHint': 'Dodaje na końcu dzień bez daty. Daty podróży się nie zmieniają.', 'dayplan.addDatedDay': 'Dodaj {date}', 'dayplan.addDatedDayHint': 'Wydłuża podróż o jeden dzień, do {date}.', diff --git a/shared/src/i18n/ru/dayplan.ts b/shared/src/i18n/ru/dayplan.ts index fc9464d8d..f70d9ef5b 100644 --- a/shared/src/i18n/ru/dayplan.ts +++ b/shared/src/i18n/ru/dayplan.ts @@ -123,7 +123,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Для изменения дней нужно подключение', 'dayplan.deleteDaySuccess': 'День удалён', 'dayplan.deleteDayError': 'Не удалось удалить день', - 'dayplan.addUndatedDay': 'Добавить день без даты', + 'dayplan.addUndatedDay': 'Без даты', 'dayplan.addUndatedDayHint': 'Добавляет в конец день без даты. Даты поездки не меняются.', 'dayplan.addDatedDay': 'Добавить {date}', 'dayplan.addDatedDayHint': 'Продлевает поездку на один день, до {date}.', diff --git a/shared/src/i18n/sv/dayplan.ts b/shared/src/i18n/sv/dayplan.ts index b4bfec6de..c11f0049d 100644 --- a/shared/src/i18n/sv/dayplan.ts +++ b/shared/src/i18n/sv/dayplan.ts @@ -123,7 +123,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Att ändra dagar kräver en anslutning', 'dayplan.deleteDaySuccess': 'Dagen togs bort', 'dayplan.deleteDayError': 'Kunde inte ta bort dagen', - 'dayplan.addUndatedDay': 'Lägg till dag utan datum', + 'dayplan.addUndatedDay': 'Utan datum', 'dayplan.addUndatedDayHint': 'Lägger till en dag utan datum i slutet. Resans datum förblir desamma.', 'dayplan.addDatedDay': 'Lägg till {date}', 'dayplan.addDatedDayHint': 'Förlänger resan med en dag, till {date}.', diff --git a/shared/src/i18n/tr/dayplan.ts b/shared/src/i18n/tr/dayplan.ts index aefa0194d..8638c7286 100644 --- a/shared/src/i18n/tr/dayplan.ts +++ b/shared/src/i18n/tr/dayplan.ts @@ -122,7 +122,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Günleri değiştirmek için bağlantı gerekir', 'dayplan.deleteDaySuccess': 'Gün silindi', 'dayplan.deleteDayError': 'Gün silinemedi', - 'dayplan.addUndatedDay': 'Tarihsiz gün ekle', + 'dayplan.addUndatedDay': 'Tarihsiz', 'dayplan.addUndatedDayHint': 'Sona tarihsiz bir gün ekler. Seyahat tarihleri aynı kalır.', 'dayplan.addDatedDay': '{date} ekle', 'dayplan.addDatedDayHint': 'Seyahati bir gün uzatır. Yeni son gün: {date}.', diff --git a/shared/src/i18n/uk/dayplan.ts b/shared/src/i18n/uk/dayplan.ts index 169d78415..cf9caec4c 100644 --- a/shared/src/i18n/uk/dayplan.ts +++ b/shared/src/i18n/uk/dayplan.ts @@ -123,7 +123,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Для зміни днів потрібне з’єднання', 'dayplan.deleteDaySuccess': 'День видалено', 'dayplan.deleteDayError': 'Не вдалося видалити день', - 'dayplan.addUndatedDay': 'Додати день без дати', + 'dayplan.addUndatedDay': 'Без дати', 'dayplan.addUndatedDayHint': 'Додає в кінець день без дати. Дати подорожі не змінюються.', 'dayplan.addDatedDay': 'Додати {date}', 'dayplan.addDatedDayHint': 'Подовжує подорож на один день, до {date}.', diff --git a/shared/src/i18n/vi/dayplan.ts b/shared/src/i18n/vi/dayplan.ts index fd22569b7..26705906c 100644 --- a/shared/src/i18n/vi/dayplan.ts +++ b/shared/src/i18n/vi/dayplan.ts @@ -122,7 +122,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': 'Cần có kết nối để thay đổi ngày', 'dayplan.deleteDaySuccess': 'Đã xóa ngày', 'dayplan.deleteDayError': 'Không thể xóa ngày', - 'dayplan.addUndatedDay': 'Thêm ngày không có ngày tháng', + 'dayplan.addUndatedDay': 'Không ngày tháng', 'dayplan.addUndatedDayHint': 'Thêm một ngày không có ngày tháng vào cuối. Ngày của chuyến đi giữ nguyên.', 'dayplan.addDatedDay': 'Thêm {date}', 'dayplan.addDatedDayHint': 'Kéo dài chuyến đi thêm một ngày, đến {date}.', diff --git a/shared/src/i18n/zh-TW/dayplan.ts b/shared/src/i18n/zh-TW/dayplan.ts index 34412fc38..c21f0933f 100644 --- a/shared/src/i18n/zh-TW/dayplan.ts +++ b/shared/src/i18n/zh-TW/dayplan.ts @@ -119,7 +119,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': '變更日期需要網路連線', 'dayplan.deleteDaySuccess': '已刪除這一天', 'dayplan.deleteDayError': '刪除這一天失敗', - 'dayplan.addUndatedDay': '新增無日期的一天', + 'dayplan.addUndatedDay': '無日期', 'dayplan.addUndatedDayHint': '在最後新增一天沒有日期的行程。旅程日期不變。', 'dayplan.addDatedDay': '新增 {date}', 'dayplan.addDatedDayHint': '旅程延長一天,到 {date} 為止。', diff --git a/shared/src/i18n/zh/dayplan.ts b/shared/src/i18n/zh/dayplan.ts index 72456a657..825f2cbc7 100644 --- a/shared/src/i18n/zh/dayplan.ts +++ b/shared/src/i18n/zh/dayplan.ts @@ -119,7 +119,7 @@ const dayplan: TranslationStrings = { 'dayplan.daysOffline': '更改日期需要网络连接', 'dayplan.deleteDaySuccess': '已删除这一天', 'dayplan.deleteDayError': '删除这一天失败', - 'dayplan.addUndatedDay': '添加无日期的一天', + 'dayplan.addUndatedDay': '无日期', 'dayplan.addUndatedDayHint': '在末尾添加一天没有日期的行程。旅行日期不变。', 'dayplan.addDatedDay': '添加 {date}', 'dayplan.addDatedDayHint': '旅行延长一天,到 {date} 为止。',