Merge pull request #2065 from liketrek/feat/upcoming-stays-and-status

fix(dashboard): fix the upcoming widget dropping bookings, and give it the stays back
This commit is contained in:
Maurice
2026-08-27 16:20:26 +02:00
committed by GitHub
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', () => {