mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
@
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:
@@ -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 />);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user