mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
7823ab1550
The day a stay begins, a hotel stop without a time of its own read TBD; it now shows the stay's check-in, as the road trip already times it.
353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
import { render, screen } from '../../../tests/helpers/render'
|
|
import { http, HttpResponse } from 'msw'
|
|
import { server } from '../../../tests/helpers/msw/server'
|
|
import { buildTrip } from '../../../tests/helpers/factories'
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import WhatsNextWidget from './WhatsNextWidget'
|
|
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
|
|
|
|
// Dynamic date helpers, in local calendar dates like the widget itself uses
|
|
function localDate(d: Date): string {
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
const today = localDate(new Date())
|
|
|
|
function getFutureDate(daysAhead: number): string {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() + daysAhead)
|
|
return localDate(d)
|
|
}
|
|
|
|
function getPastDate(daysBack: number): string {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() - daysBack)
|
|
return localDate(d)
|
|
}
|
|
|
|
const tomorrow = getFutureDate(1)
|
|
const yesterday = getPastDate(1)
|
|
|
|
/** One assignment as the trip store keeps it; the server sends nulls where the generated type says optional. */
|
|
type StoreAssignment = NonNullable<ReturnType<typeof useTripStore.getState>['assignments'][string]>[number]
|
|
|
|
function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}, participants: unknown[] = []): StoreAssignment {
|
|
const assignment = {
|
|
id,
|
|
day_id: 1,
|
|
place_id: id,
|
|
order_index: 0,
|
|
notes: null,
|
|
place: {
|
|
id,
|
|
name: `Place ${id}`,
|
|
description: null,
|
|
lat: 0,
|
|
lng: 0,
|
|
address: null,
|
|
category_id: null,
|
|
price: null,
|
|
currency: null,
|
|
image_url: null,
|
|
google_place_id: null,
|
|
place_time: null,
|
|
end_time: null,
|
|
duration_minutes: 60,
|
|
notes: null,
|
|
transport_mode: 'walking',
|
|
website: null,
|
|
phone: null,
|
|
...placeOverrides,
|
|
},
|
|
participants,
|
|
}
|
|
return assignment as unknown as StoreAssignment
|
|
}
|
|
|
|
describe('WhatsNextWidget', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
|
})
|
|
|
|
afterEach(() => {
|
|
resetAllStores()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-001: renders empty state when no days exist', () => {
|
|
seedStore(useTripStore, { days: [], assignments: {} })
|
|
render(<WhatsNextWidget />)
|
|
// Translation resolves to "No upcoming activities"
|
|
expect(screen.getByText(/no upcoming/i)).toBeInTheDocument()
|
|
expect(screen.queryByText('Place 1')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-001b: empty state element is rendered', () => {
|
|
seedStore(useTripStore, { days: [], assignments: {} })
|
|
render(<WhatsNextWidget />)
|
|
// collab.whatsNext.empty key is rendered as text in test env
|
|
const allText = document.body.textContent || ''
|
|
// No assignment time/name visible — just the header and empty hint
|
|
expect(allText).not.toContain('14:30')
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(10, { place_time: '08:00' })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.queryByText('08:00')).toBeNull()
|
|
expect(screen.queryByText('Place 10')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(21, { name: 'Museum' })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
// The label text comes from t('collab.whatsNext.tomorrow') which falls back to 'Tomorrow'
|
|
expect(screen.getByText(/tomorrow/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText(/today/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
|
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText('14:30')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
|
|
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText('2:30 PM')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText('TBD')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-018: a hotel without a time of its own shows the check-in of the stay it begins', async () => {
|
|
server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json({
|
|
accommodations: [{ id: 7, trip_id: 1, place_id: 40, start_day_id: 1, end_day_id: 2, check_in: '15:00', check_out: '11:00' }],
|
|
})))
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: { '1': [makeAssignment(40, { name: 'Hotel Kea', place_time: null })] },
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(await screen.findByText('15:00')).toBeInTheDocument()
|
|
expect(screen.queryByText('TBD')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText('123 Rue de Rivoli')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-010: caps list at 8 items', () => {
|
|
const days = Array.from({ length: 5 }, (_, i) => ({
|
|
id: i + 1,
|
|
trip_id: 1,
|
|
date: getFutureDate(i + 1),
|
|
title: null,
|
|
day_number: i,
|
|
assignments: [],
|
|
notes_items: [],
|
|
notes: null,
|
|
}))
|
|
|
|
const assignments: Record<string, unknown[]> = {}
|
|
let placeId = 100
|
|
for (const day of days) {
|
|
assignments[String(day.id)] = [
|
|
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '10:00' }),
|
|
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '11:00' }),
|
|
]
|
|
}
|
|
|
|
seedStore(useTripStore, { days, assignments })
|
|
render(<WhatsNextWidget />)
|
|
|
|
// 10 items seeded, only 8 should appear — count "TBD" or time occurrences
|
|
const timeElements = screen.getAllByText('10:00')
|
|
// At most 4 days * 1 morning slot = up to 4 "10:00" entries, but capped at 8 total items
|
|
// We verify total rendered items is at most 8 by counting both time slots
|
|
const allTimes = screen.getAllByText(/10:00|11:00/)
|
|
expect(allTimes.length).toBeLessThanOrEqual(8)
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText('alice')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(41, { name: 'Park' }, [])],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget tripMembers={[{ id: 7, username: 'bob', avatar_url: null }]} />)
|
|
expect(screen.getByText('bob')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText('19:00')).toBeInTheDocument()
|
|
expect(screen.getByText('21:30')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [
|
|
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
|
|
makeAssignment(61, { name: 'Lunch', place_time: '12:00' }),
|
|
],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
const tomorrowHeaders = screen.getAllByText(/tomorrow/i)
|
|
// Only one day header for tomorrow
|
|
expect(tomorrowHeaders).toHaveLength(1)
|
|
expect(screen.getByText('Breakfast')).toBeInTheDocument()
|
|
expect(screen.getByText('Lunch')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-016: keeps remaining events for the local day west of UTC after the UTC date rolls over', () => {
|
|
const prevTz = process.env.TZ
|
|
process.env.TZ = 'America/New_York'
|
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
try {
|
|
// 20:00 in New York, already the next calendar day in UTC
|
|
vi.setSystemTime(new Date('2026-08-24T00:00:00Z'))
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: '2026-08-23', title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(80, { name: 'Late Show', place_time: '21:00' })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText('Late Show')).toBeInTheDocument()
|
|
expect(screen.getByText(/today/i)).toBeInTheDocument()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
if (prevTz === undefined) delete process.env.TZ
|
|
else process.env.TZ = prevTz
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-017: a stop that started but has not ended yet is still listed', () => {
|
|
const prevTz = process.env.TZ
|
|
process.env.TZ = 'UTC'
|
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
try {
|
|
vi.setSystemTime(new Date('2026-08-23T20:00:00Z'))
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: '2026-08-23', title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [
|
|
makeAssignment(81, { name: 'Running Concert', place_time: '19:00', end_time: '21:30' }),
|
|
makeAssignment(82, { name: 'Finished Dinner', place_time: '17:00', end_time: '18:30' }),
|
|
],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
expect(screen.getByText('Running Concert')).toBeInTheDocument()
|
|
expect(screen.queryByText('Finished Dinner')).toBeNull()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
if (prevTz === undefined) delete process.env.TZ
|
|
else process.env.TZ = prevTz
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
|
|
// If it's not midnight, a past-time event today should not appear
|
|
const now = new Date()
|
|
if (now.getHours() > 0) {
|
|
const pastTime = '00:01' // Very early — will be past for most of the day
|
|
seedStore(useTripStore, {
|
|
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
|
assignments: {
|
|
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
|
|
},
|
|
})
|
|
render(<WhatsNextWidget />)
|
|
// If current time > 00:01, the item should not appear
|
|
if (now.getHours() > 0 || now.getMinutes() > 1) {
|
|
expect(screen.queryByText('Early Bird')).toBeNull()
|
|
}
|
|
}
|
|
})
|
|
})
|