From 83be759c497ffd3f9aa7a522d39b7371008e5402 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 23 Sep 2026 21:41:05 +0200 Subject: [PATCH] fix(dayplan): the delete question asks inside the day dialog, and its buttons stay small Deleting a day no longer stacks a second dialog over the reorder dialog. The dialog swaps its list for the question at the same size and in the same place; Cancel and Escape bring the list back with the focus on the row asked about. The foot is one row of normal sized buttons with the hint line above it, the undated day is labelled "Without date" in every language, and the dialog is a step wider so no label breaks. The list of what goes with a day is quieter: a cancelled stay tints its whole row, a day with nothing on it is one plain line. On the phone the add buttons are two pills under the scrolling list, and the confirm sheet moves a long label to its own row instead of breaking it. --- .../src/components/Planner/DayAddFooter.tsx | 74 ++--- .../src/components/Planner/DayPlanSidebar.tsx | 8 +- .../Planner/DayPlanSidebarToolbar.test.tsx | 17 +- .../Planner/DayPlanSidebarToolbar.tsx | 6 +- .../Planner/DayReorderPopup.test.tsx | 108 ++++++- .../components/Planner/DayReorderPopup.tsx | 291 ++++++++++++------ .../src/components/Trips/TripDateReview.tsx | 6 +- .../components/Trips/TripFormModal.test.tsx | 3 +- client/src/components/Trips/TripFormModal.tsx | 2 +- .../components/shared/DayImpactList.test.tsx | 36 ++- .../src/components/shared/DayImpactList.tsx | 48 ++- .../src/mobile/components/MDayImpactList.tsx | 17 +- .../mobile/screens/settings/MConfirmSheet.tsx | 9 +- .../mobile/screens/trip/sheets/MDaysSheet.tsx | 124 +++----- client/src/pages/TripPlannerPage.test.tsx | 20 +- client/src/pages/TripPlannerPage.tsx | 18 +- client/src/pages/tripPlanner/useDayDelete.ts | 14 +- .../pages/tripPlanner/useTripPlanner.test.tsx | 23 +- client/src/utils/dayImpactLines.ts | 12 + .../mobile/settings/MConfirmSheet.test.tsx | 9 +- .../unit/mobile/trip/MDaysSheet.test.tsx | 49 ++- shared/src/i18n/ar/dayplan.ts | 2 +- shared/src/i18n/br/dayplan.ts | 2 +- shared/src/i18n/ca/dayplan.ts | 2 +- shared/src/i18n/cs/dayplan.ts | 2 +- shared/src/i18n/de/dayplan.ts | 2 +- shared/src/i18n/en/dayplan.ts | 2 +- shared/src/i18n/es/dayplan.ts | 2 +- shared/src/i18n/fr/dayplan.ts | 2 +- shared/src/i18n/gr/dayplan.ts | 2 +- shared/src/i18n/hu/dayplan.ts | 2 +- shared/src/i18n/id/dayplan.ts | 2 +- shared/src/i18n/it/dayplan.ts | 2 +- shared/src/i18n/ja/dayplan.ts | 2 +- shared/src/i18n/ko/dayplan.ts | 2 +- shared/src/i18n/nl/dayplan.ts | 2 +- shared/src/i18n/pl/dayplan.ts | 2 +- shared/src/i18n/ru/dayplan.ts | 2 +- shared/src/i18n/sv/dayplan.ts | 2 +- shared/src/i18n/tr/dayplan.ts | 2 +- shared/src/i18n/uk/dayplan.ts | 2 +- shared/src/i18n/vi/dayplan.ts | 2 +- shared/src/i18n/zh-TW/dayplan.ts | 2 +- shared/src/i18n/zh/dayplan.ts | 2 +- 44 files changed, 625 insertions(+), 315 deletions(-) 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} 为止。',