mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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