fix(dashboard): fix the upcoming widget dropping bookings, and give it the stays back

Three things, all in the same widget.

reservation_time holds two shapes. The booking form writes the datetime-local
YYYY-MM-DDTHH:MM; day-anchored rows, the demo seed among them, hold a bare
HH:MM. The query compared the column against an ISO timestamp as a string,
where ":" sorts above "2": every booking from 20:00 on passed the filter and
everything before it silently vanished, and the order ran off the same column.
On the demo that is why the Broadway show shows and the ferry does not. The
query now separates the date from the time before it filters, so a value with
no date falls back to the day it hangs off instead of being read as one, and
the order is by day and clock. The demo seed writes full timestamps, which is
the shape every real write path already produces.

Hotels came out of this list because a stay covers a range and a week in one
would hold a slot for the whole week. Arriving and leaving do not: they are
moments, and they are the two worth reminding someone of. Check-in and
check-out now appear as their own entries, named after the stay place and
falling back to the linked booking. The stay itself stays out. The two carry
the accommodation id, so the list key had to take the type with it.

An unconfirmed booking looked exactly like a confirmed one. The status was
already in the payload and is now a badge, on both the desktop tool and the
phone widget.
@
This commit is contained in:
Maurice
2026-08-27 15:38:45 +02:00
parent 99387812a1
commit f15a124016
9 changed files with 287 additions and 39 deletions
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import {
ArrowRight, ArrowRightLeft, Bookmark, Calendar, ChevronDown, ChevronRight,
Clock, Hotel, MapPin, Plane, Plus, RefreshCw, Ticket, Utensils, X,
Clock, Hotel, LogIn, LogOut, MapPin, Plane, Plus, RefreshCw, Ticket, Utensils, X,
} from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
@@ -12,14 +12,21 @@ import { entityGradient } from '../../../utils/gradients'
import { CURRENCIES } from '../../../components/Budget/BudgetPanel.constants'
import { formatTime, splitReservationDateTime } from '../../../utils/formatters'
import { normalizeAppearance, MOBILE_DASH_TOKENS, type MobileDashToken, type Collection } from '@trek/shared'
import type { UpcomingReservation } from '../../../pages/dashboard/dashboardModel'
import { upcomingKey, type UpcomingReservation } from '../../../pages/dashboard/dashboardModel'
const RES_ICON: Record<string, React.ReactElement> = {
flight: <Plane size={14} strokeWidth={2} />,
hotel: <Hotel size={14} strokeWidth={2} />,
restaurant: <Utensils size={14} strokeWidth={2} />,
// A stay's two moments (#1934) — the stay itself covers a range and stays out
// of a list of what happens next; arriving and leaving are points in time.
checkin: <LogIn size={14} strokeWidth={2} />,
checkout: <LogOut size={14} strokeWidth={2} />,
}
/** The label a stay's moment carries in place of a location. */
const MOMENT_LABEL: Record<string, string> = { checkin: 'day.checkIn', checkout: 'day.checkOut' }
/**
* Inline dashboard widget panels (currency, collections, timezones, upcoming
* reservations) for the mobile dashboard. The blocks are rendered individually
@@ -409,8 +416,8 @@ function MUpcomingWidget({ items }: { items: UpcomingReservation[] }): React.Rea
? date.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
const place = r.location || r.place_name || r.trip_title || null
return [dateStr, timeStr, place].filter(Boolean).join(' · ')
const tail = MOMENT_LABEL[r.type] ? t(MOMENT_LABEL[r.type]) : (r.location || r.place_name || r.trip_title || null)
return [dateStr, timeStr, tail].filter(Boolean).join(' · ')
}
return (
@@ -420,7 +427,7 @@ function MUpcomingWidget({ items }: { items: UpcomingReservation[] }): React.Rea
) : (
items.map(r => (
<button
key={r.id}
key={upcomingKey(r)}
type="button"
onClick={() => openReservation(r.trip_id)}
className="flex w-full items-center gap-[11px] border-b border-[color:var(--m-rowbr)] py-[9px] text-left"
@@ -429,7 +436,14 @@ function MUpcomingWidget({ items }: { items: UpcomingReservation[] }): React.Rea
{RES_ICON[r.type] || <Ticket size={14} strokeWidth={2} />}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[0.8125rem] font-semibold">{r.title}</div>
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[0.8125rem] font-semibold">{r.title}</span>
{r.status === 'pending' && (
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-[6px] py-px font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-muted">
{t('reservations.pending')}
</span>
)}
</div>
<div className="truncate font-geist text-[0.625rem] text-m-muted">{subFor(r)}</div>
</div>
<ChevronRight size={14} strokeWidth={2} className="flex-none text-m-faint" />
@@ -180,6 +180,32 @@ describe('DashboardPage (desktop)', () => {
expect(screen.getByText('Hotel Ibis')).toBeInTheDocument();
});
// #1934 — a stay covers a range and stays out of a list of what happens next,
// but arriving and leaving are moments, and they carry the same id.
it('FE-PAGE-DESKDASH-027: a stay renders as two moments and an unconfirmed booking says so', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) });
server.use(http.get('/api/reservations/upcoming', () => HttpResponse.json({
reservations: [
{ id: 7, trip_id: 101, title: 'The Plaza', type: 'checkin', status: 'confirmed', reservation_time: '2026-09-18T15:00', day_date: '2026-09-18' },
{ id: 3, trip_id: 101, title: 'Broadway Show', type: 'activity', status: 'pending', reservation_time: '2026-09-18T20:00', location: 'Richard Rodgers' },
{ id: 7, trip_id: 101, title: 'The Plaza', type: 'checkout', status: 'confirmed', reservation_time: '2026-09-22T11:00', day_date: '2026-09-22' },
],
})));
const { container } = render(<DashboardPage />);
// Both moments render despite sharing id 7 — the list key carries the type.
expect(await screen.findAllByText('The Plaza')).toHaveLength(2);
// The label shares its line with the time, so match the row, not a bare node.
expect(screen.getByText(/Check-in/)).toBeInTheDocument();
expect(screen.getByText(/Check-out/)).toBeInTheDocument();
expect(container.querySelectorAll('.upc-item')).toHaveLength(3);
// Only the unconfirmed one is marked.
const pending = container.querySelectorAll('.upc-pending');
expect(pending).toHaveLength(1);
expect(pending[0]).toHaveTextContent('Pending');
});
it('FE-PAGE-DESKDASH-009: the currency tool converts and swaps the pair', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_fx_from: 'EUR', dashboard_fx_to: 'USD' }) });
const { container } = render(<DashboardPage />);
+17 -6
View File
@@ -12,12 +12,12 @@ import MobileTopBar from '../components/Layout/MobileTopBar'
import { useDashboard } from './dashboard/useDashboard'
import {
type DashboardTrip, type HeroBundle, type TravelStats, type UpcomingReservation,
MS_PER_DAY, daysUntil, getTripStatus,
MS_PER_DAY, daysUntil, getTripStatus, upcomingKey,
} from './dashboard/dashboardModel'
import {
Plus, Edit2, Trash2, Archive, ArchiveRestore, Copy, ArrowRight, MapPin,
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
LayoutGrid, List, Ticket, X, CalendarPlus, ParkingSquare,
LayoutGrid, List, Ticket, X, CalendarPlus, ParkingSquare, LogIn, LogOut,
} from 'lucide-react'
import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal'
import CollectionsWidget from '../components/Dashboard/CollectionsWidget'
@@ -91,8 +91,15 @@ function initials(name: string | null | undefined): string {
const RES_ICON: Record<string, React.ReactElement> = {
flight: <Plane size={16} />, hotel: <Hotel size={16} />, restaurant: <Utensils size={16} />, parking: <ParkingSquare size={16} />,
// A stay's two moments (#1934) — the arrow says which way you are going, on
// the same green the hotel tile already uses.
checkin: <LogIn size={16} />, checkout: <LogOut size={16} />,
}
const RES_TYPE_CLASS: Record<string, string> = { flight: 'flight', hotel: 'hotel', restaurant: 'food' }
const RES_TYPE_CLASS: Record<string, string> = {
flight: 'flight', hotel: 'hotel', restaurant: 'food', checkin: 'hotel', checkout: 'hotel',
}
/** The label a stay's moment carries in place of a location. */
const MOMENT_LABEL: Record<string, string> = { checkin: 'day.checkIn', checkout: 'day.checkOut' }
export default function DashboardPage(): React.ReactElement {
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
@@ -804,16 +811,20 @@ function UpcomingTool({ items, locale, onOpen }: {
const dateStr = datePart ? splitDate(datePart, locale) : null
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
const moment = MOMENT_LABEL[r.type]
return (
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}
<div className="upc-item" key={upcomingKey(r)} onClick={() => onOpen(r.trip_id)}
role="button" tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(r.trip_id) } }}>
<div className="upc-date"><div className="d mono">{dateStr?.d ?? '–'}</div><div className="m">{dateStr?.m ?? ''}</div></div>
<div className="upc-info">
<div className="t">{r.title}</div>
<div className="t">
{r.title}
{r.status === 'pending' && <span className="upc-pending">{t('reservations.pending')}</span>}
</div>
<div className="s">
{timeStr && <><Clock size={11} /> {timeStr} · </>}
{r.location || r.place_name || r.trip_title}
{moment ? t(moment) : (r.location || r.place_name || r.trip_title)}
</div>
</div>
<div className={`upc-type ${typeClass}`}>{RES_ICON[r.type] || <Ticket size={16} />}</div>
+11 -1
View File
@@ -23,11 +23,21 @@ export interface Place {
export interface HeroBundle { members: Member[]; places: Place[] }
export interface TravelStats { totalTrips?: number; totalDays?: number; totalPlaces?: number; totalDistanceKm?: number; countries?: string[] }
export interface UpcomingReservation {
id: number; trip_id: number; title: string; type: string
/** Unique per `type`, not on its own: a stay's two moments carry the
* accommodation id, which can collide with a reservation id. */
id: number; trip_id: number; title: string
/** A reservation type, or 'checkin' / 'checkout' for a stay's two moments. */
type: string
status?: string | null
reservation_time?: string | null; day_date?: string | null
location?: string | null; place_name?: string | null; trip_title?: string | null
}
/** Stable list key — see the note on `id`. */
export function upcomingKey(r: UpcomingReservation): string {
return `${r.type}:${r.id}`
}
export const MS_PER_DAY = 86400000
/**
+3
View File
@@ -593,6 +593,9 @@
.trek-dash .upc-date .d { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; line-height: 1; }
.trek-dash .upc-date .m { font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); margin-top: 4px; font-weight: 500; }
.trek-dash .upc-info .t { font-size: 14px; font-weight: 500; margin-bottom: 2px; }
.trek-dash .upc-info .t { display: flex; align-items: center; gap: 7px; }
/* An unconfirmed booking looked exactly like a confirmed one here (#1934). */
.trek-dash .upc-pending { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; padding: 2px 6px; border-radius: 999px; background: var(--bg-2); color: var(--ink-3); }
.trek-dash .upc-info .s { font-size: 12px; color: var(--ink-3); display: flex; align-items: center; gap: 6px; }
.trek-dash .upc-info .s svg { width: 11px; height: 11px; }
.trek-dash .upc-type { width: 32px; height: 32px; border-radius: 10px; display: grid; place-items: center; }
@@ -417,4 +417,36 @@ describe('MUpcomingWidget', () => {
expect(sessionStorage.getItem('trip-tab-7')).toBe('buchungen');
});
// #1934 — the two moments of one stay share the accommodation id, so the row
// key has to carry the type as well.
it('FE-MOB-DWID-034: a stay renders as a check-in and a check-out', () => {
render(
<MobileDashWidget
id="upcomingReservations"
upcoming={[
res({ id: 9, type: 'checkin', title: 'The Plaza', day_date: '2026-09-18', location: null }),
res({ id: 9, type: 'checkout', title: 'The Plaza', day_date: '2026-09-22', location: null }),
]}
/>,
);
expect(screen.getAllByText('The Plaza')).toHaveLength(2);
expect(screen.getByText(/Check-in/)).toBeInTheDocument();
expect(screen.getByText(/Check-out/)).toBeInTheDocument();
});
it('FE-MOB-DWID-035: an unconfirmed booking is marked, a confirmed one is not', () => {
render(
<MobileDashWidget
id="upcomingReservations"
upcoming={[
res({ id: 1, title: 'Broadway Show', status: 'pending' }),
res({ id: 2, title: 'Ferry', status: 'confirmed' }),
]}
/>,
);
expect(screen.getAllByText('Pending')).toHaveLength(1);
});
});
+9 -7
View File
@@ -157,9 +157,11 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
insertBudget.run(t1, 'Food', 'Daily food budget', 52500, 2, 'Approx. 7,500 JPY/day');
insertBudget.run(t1, 'Activities', 'Temple entries & experiences', 18000, 2, null);
// Reservations
insertReservation.run(t1, t1days[0], 'Hotel Shinjuku Check-in', '15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo');
insertReservation.run(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station');
// Reservations. reservation_time carries the full date, the way the booking
// form writes it: a bare clock time is a different shape, and readers that
// compare the column against a timestamp cannot tell the two apart (#1934).
insertReservation.run(t1, t1days[0], 'Hotel Shinjuku Check-in', '2026-04-15T15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo');
insertReservation.run(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '2026-04-18T08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station');
insertMember.run(t1, demoId, adminId);
@@ -213,7 +215,7 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
insertBudget.run(t2, 'Food', 'Restaurants & tapas', 300, 2, 'Approx. 75 EUR/day');
insertBudget.run(t2, 'Activities', 'Sagrada Familia + Park Guell + Casa Batllo', 95, 2, 'Online tickets');
insertReservation.run(t2, t2days[1], 'Sagrada Familia Entry', '10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona');
insertReservation.run(t2, t2days[1], 'Sagrada Familia Entry', '2026-05-22T10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona');
insertMember.run(t2, demoId, adminId);
@@ -279,9 +281,9 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
insertBudget.run(t3, 'Activities', 'Statue of Liberty + Empire State + Top of the Rock + Met', 180, 2, 'CityPASS');
insertBudget.run(t3, 'Entertainment', 'Broadway show tickets', 300, 2, 'Hamilton or Wicked');
insertReservation.run(t3, t3days[0], 'The Plaza Hotel Check-in', '15:00', 'PZ-2026-55891', 'confirmed', 'hotel', '768 5th Ave, New York');
insertReservation.run(t3, t3days[0], 'Broadway Show', '20:00', 'BW-HAM-2026-1192', 'pending', 'activity', 'Richard Rodgers Theatre');
insertReservation.run(t3, t3days[1], 'Statue of Liberty Ferry', '08:30', 'SOL-2026-3347', 'confirmed', 'transport', 'Battery Park');
insertReservation.run(t3, t3days[0], 'The Plaza Hotel Check-in', '2026-09-18T15:00', 'PZ-2026-55891', 'confirmed', 'hotel', '768 5th Ave, New York');
insertReservation.run(t3, t3days[0], 'Broadway Show', '2026-09-18T20:00', 'BW-HAM-2026-1192', 'pending', 'activity', 'Richard Rodgers Theatre');
insertReservation.run(t3, t3days[1], 'Statue of Liberty Ferry', '2026-09-19T08:30', 'SOL-2026-3347', 'confirmed', 'transport', 'Battery Park');
insertMember.run(t3, demoId, adminId);
@@ -105,6 +105,17 @@ export interface UpdateReservationData {
needs_review?: boolean;
}
/**
* True when `reservation_time` actually carries a date and not just a bare
* `HH:MM`. Both shapes reach this column — the booking form writes the
* datetime-local `YYYY-MM-DDTHH:MM`, while day-anchored rows (the demo seed
* among them) hold only a time — and comparing the two against an ISO
* timestamp as strings silently sorts `'20:00'` above `'2026-…'` while
* dropping `'08:30'` below it, which is how half a trip's bookings vanished
* from the dashboard widget (#1934).
*/
const DATED = "r.reservation_time GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'";
type AccommodationTimesMeta = {
check_in_time?: string | null;
check_in_end_time?: string | null;
@@ -404,26 +415,77 @@ export class ReservationsService {
const now = new Date().toISOString();
const reservations = this.db.all<Record<string, unknown>>(`
SELECT r.id, r.trip_id, r.title, r.type, r.status, r.location,
r.reservation_time, r.confirmation_number,
t.title as trip_title, t.cover_image as trip_cover,
d.date as day_date, p.name as place_name, p.image_url as place_image
FROM reservations r
JOIN trips t ON t.id = r.trip_id
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
AND t.is_archived = 0
AND r.status != 'cancelled'
AND COALESCE(r.type, '') != 'hotel'
AND (
(r.reservation_time IS NOT NULL AND r.reservation_time >= ?)
OR (r.reservation_time IS NULL AND d.date IS NOT NULL AND d.date >= ?)
)
ORDER BY COALESCE(r.reservation_time, d.date) ASC
WITH visible_trips AS (
SELECT t.id, t.title, t.cover_image
FROM trips t
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL) AND t.is_archived = 0
),
-- One row per candidate with its date and time already separated, so the
-- filter and the sort below never have to guess what reservation_time holds.
entries AS (
SELECT r.id, r.trip_id, r.title, r.type, r.status, r.location,
r.reservation_time, r.confirmation_number,
tr.title as trip_title, tr.cover_image as trip_cover,
d.date as day_date, p.name as place_name, p.image_url as place_image,
CASE WHEN ${DATED} THEN substr(r.reservation_time, 1, 10) ELSE d.date END as at_date,
CASE WHEN ${DATED} THEN substr(r.reservation_time, 12) ELSE r.reservation_time END as at_time
FROM reservations r
JOIN visible_trips tr ON tr.id = r.trip_id
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE r.status != 'cancelled'
AND COALESCE(r.type, '') != 'hotel'
UNION ALL
-- Check-in and check-out as their own moments (#1934). The stay itself is
-- still left out: it covers a range, and a week in one hotel would hold a
-- slot in a six-entry widget for the whole week. Arriving and leaving are
-- points in time, which is exactly what this widget is for, and they are
-- the two the traveller needs reminding of.
SELECT a.id, a.trip_id,
COALESCE(p.name, (SELECT res.title FROM reservations res
WHERE CAST(res.accommodation_id AS INTEGER) = a.id
AND res.status != 'cancelled'
ORDER BY res.id LIMIT 1), tr.title) as title,
'checkin' as type, 'confirmed' as status, NULL as location,
CASE WHEN a.check_in IS NOT NULL THEN d.date || 'T' || a.check_in END as reservation_time,
a.confirmation as confirmation_number,
tr.title as trip_title, tr.cover_image as trip_cover,
d.date as day_date, p.name as place_name, p.image_url as place_image,
d.date as at_date, a.check_in as at_time
FROM day_accommodations a
JOIN visible_trips tr ON tr.id = a.trip_id
JOIN days d ON d.id = a.start_day_id
LEFT JOIN places p ON p.id = a.place_id
UNION ALL
SELECT a.id, a.trip_id,
COALESCE(p.name, (SELECT res.title FROM reservations res
WHERE CAST(res.accommodation_id AS INTEGER) = a.id
AND res.status != 'cancelled'
ORDER BY res.id LIMIT 1), tr.title) as title,
'checkout' as type, 'confirmed' as status, NULL as location,
CASE WHEN a.check_out IS NOT NULL THEN d.date || 'T' || a.check_out END as reservation_time,
a.confirmation as confirmation_number,
tr.title as trip_title, tr.cover_image as trip_cover,
d.date as day_date, p.name as place_name, p.image_url as place_image,
d.date as at_date, a.check_out as at_time
FROM day_accommodations a
JOIN visible_trips tr ON tr.id = a.trip_id
JOIN days d ON d.id = a.end_day_id
LEFT JOIN places p ON p.id = a.place_id
)
SELECT id, trip_id, title, type, status, location, reservation_time,
confirmation_number, trip_title, trip_cover, day_date, place_name, place_image
FROM entries
WHERE at_date IS NOT NULL
AND (at_date > ? OR (at_date = ? AND COALESCE(at_time, '23:59') >= ?))
ORDER BY at_date ASC, COALESCE(at_time, '00:00') ASC, id ASC
LIMIT ?
`, userId, userId, now, today, limit);
`, userId, userId, today, today, now.slice(11, 16), limit);
return reservations;
}
@@ -334,6 +334,94 @@ describe('ReservationsService (DI-native, real SQL)', () => {
const rows = svc.listUpcoming(user.id) as { title: string }[];
expect(rows.map(r => r.title)).toEqual(['Untyped']);
});
// The column holds two shapes: the booking form writes 'YYYY-MM-DDTHH:MM',
// day-anchored rows hold a bare 'HH:MM'. Compared as strings against an ISO
// timestamp, ':' sorts above '2', so a bare time was an arbitrary filter:
// everything from 20:00 on passed and everything before it vanished (#1934).
it('RESV-SVC-017e: a bare clock time falls back to its day instead of being read as a date', () => {
const { user, trip } = ownerTrip();
const day = createDay(testDb, trip.id, { date: '2999-06-01' });
const morning = createReservation(testDb, trip.id, { title: 'Ferry', day_id: day.id });
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('08:30', morning.id);
const evening = createReservation(testDb, trip.id, { title: 'Show', day_id: day.id });
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('20:00', evening.id);
const rows = svc.listUpcoming(user.id) as { title: string }[];
expect(rows.map(r => r.title)).toEqual(['Ferry', 'Show']);
});
it('RESV-SVC-017f: orders by the day and the clock, not by the raw column', () => {
const { user, trip } = ownerTrip();
const early = createDay(testDb, trip.id, { date: '2999-06-01' });
const late = createDay(testDb, trip.id, { date: '2999-06-02' });
const bare = createReservation(testDb, trip.id, { title: 'Bare morning', day_id: early.id });
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('08:30', bare.id);
const dated = createReservation(testDb, trip.id, { title: 'Dated day two' });
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-06-02T09:00', dated.id);
const later = createReservation(testDb, trip.id, { title: 'Bare evening', day_id: early.id });
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('20:00', later.id);
const dayOnly = createReservation(testDb, trip.id, { title: 'No time', day_id: late.id });
testDb.prepare('UPDATE reservations SET reservation_time = NULL WHERE id = ?').run(dayOnly.id);
const rows = svc.listUpcoming(user.id) as { title: string }[];
expect(rows.map(r => r.title)).toEqual(['Bare morning', 'Bare evening', 'No time', 'Dated day two']);
});
// The stay stays out, but arriving and leaving are moments, and they are the
// two the traveller needs reminding of.
it('RESV-SVC-017g: a stay contributes a check-in and a check-out named after its place', () => {
const { user, trip } = ownerTrip();
const start = createDay(testDb, trip.id, { date: '2999-06-01' });
const end = createDay(testDb, trip.id, { date: '2999-06-05' });
const place = createPlace(testDb, trip.id, { name: 'The Plaza' });
createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '11:00' });
const rows = svc.listUpcoming(user.id) as { title: string; type: string; day_date: string }[];
expect(rows).toEqual([
expect.objectContaining({ title: 'The Plaza', type: 'checkin', day_date: '2999-06-01' }),
expect.objectContaining({ title: 'The Plaza', type: 'checkout', day_date: '2999-06-05' }),
]);
});
it('RESV-SVC-017h: the two moments sort among the bookings rather than after them', () => {
const { user, trip } = ownerTrip();
const start = createDay(testDb, trip.id, { date: '2999-06-01' });
const end = createDay(testDb, trip.id, { date: '2999-06-03' });
const place = createPlace(testDb, trip.id, { name: 'Hostel' });
createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '10:00' });
const dinner = createReservation(testDb, trip.id, { title: 'Dinner' });
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-06-01T19:00', dinner.id);
const museum = createReservation(testDb, trip.id, { title: 'Museum' });
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-06-02T09:00', museum.id);
const rows = svc.listUpcoming(user.id) as { title: string }[];
expect(rows.map(r => r.title)).toEqual(['Hostel', 'Dinner', 'Museum', 'Hostel']);
});
it('RESV-SVC-017i: a stay that is already over contributes nothing', () => {
const { user, trip } = ownerTrip();
const start = createDay(testDb, trip.id, { date: '2000-06-01' });
const end = createDay(testDb, trip.id, { date: '2000-06-05' });
const place = createPlace(testDb, trip.id, { name: 'Old Inn' });
createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '11:00' });
expect(svc.listUpcoming(user.id)).toEqual([]);
});
it('RESV-SVC-017j: a nameless stay falls back to its linked booking', () => {
const { user, trip } = ownerTrip();
const start = createDay(testDb, trip.id, { date: '2999-06-01' });
const end = createDay(testDb, trip.id, { date: '2999-06-02' });
const acc = testDb.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) VALUES (?, NULL, ?, ?, ?, ?)'
).run(trip.id, start.id, end.id, '15:00', '11:00');
const booking = createReservation(testDb, trip.id, { title: 'Hotel Ibis', type: 'hotel' });
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.lastInsertRowid), booking.id);
const rows = svc.listUpcoming(user.id) as { title: string; type: string }[];
expect(rows.map(r => r.type + ':' + r.title)).toEqual(['checkin:Hotel Ibis', 'checkout:Hotel Ibis']);
});
});
describe('resyncReservationDays', () => {