mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
7959470802
The notes and polls WebSocket handlers applied every event they saw without checking which trip it belonged to, so an event for another trip the user is a member of landed in the open one. The poll load effect had no cancellation either, so switching trips fast let the old response win. What's Next compared trip-local calendar dates against a UTC one while building the time from the local clock. West of UTC that means the rest of today disappears from the widget every evening and tomorrow's items are labelled Today; east of UTC it happens after local midnight instead. A note website was rendered as a raw href with no scheme check, the same stored-javascript: shape the planner had. Category rename walked the notes in a loop with no handling for a partial failure. And the school-holiday region labels resolved German first for every locale.
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { render, screen } from '../../../tests/helpers/render'
|
|
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)
|
|
|
|
function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}, participants: unknown[] = []) {
|
|
return {
|
|
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,
|
|
}
|
|
}
|
|
|
|
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-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-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()
|
|
}
|
|
}
|
|
})
|
|
})
|