fix(collab): scope the realtime handlers to the trip, and stop losing writes quietly

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.
This commit is contained in:
Maurice
2026-08-23 10:07:56 +02:00
committed by Maurice
parent 61d4fd4d64
commit 7959470802
12 changed files with 283 additions and 58 deletions
@@ -2,7 +2,12 @@ import { useState, useEffect } from 'react'
import { collabApi } from '../../api/client'
/* ── Link Preview ── */
const previewCache = {}
// linkPreview is trip-scoped on the server, so the cache key has to be too.
// Capped as well: a long chat session would otherwise keep every preview it ever
// rendered for as long as the tab lives.
const previewCache = new Map()
const cacheKey = (tripId: number, url: string): string => `${tripId} ${url}`
const MAX_CACHED_PREVIEWS = 200
interface LinkPreviewProps {
url: string
@@ -12,17 +17,23 @@ interface LinkPreviewProps {
}
export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
const [data, setData] = useState(previewCache[url] || null)
const [loading, setLoading] = useState(!previewCache[url])
const [data, setData] = useState(previewCache.get(cacheKey(tripId, url)) || null)
const [loading, setLoading] = useState(!previewCache.has(cacheKey(tripId, url)))
useEffect(() => {
if (previewCache[url]) return
const key = cacheKey(tripId, url)
const cached = previewCache.get(key)
if (cached) { setData(cached); setLoading(false); return }
let current = true
collabApi.linkPreview(tripId, url).then(d => {
previewCache[url] = d
if (previewCache.size >= MAX_CACHED_PREVIEWS) previewCache.delete(previewCache.keys().next().value)
previewCache.set(key, d)
if (!current) return
setData(d)
setLoading(false)
if (d?.title || d?.description || d?.image) onLoad?.()
}).catch(() => setLoading(false))
}).catch(() => { if (current) setLoading(false) })
return () => { current = false }
}, [url, tripId])
if (loading || !data || (!data.title && !data.description && !data.image)) return null
@@ -1,4 +1,4 @@
// FE-W4CCS-001 to FE-W4CCS-016
// FE-W4CCS-001 to FE-W4CCS-019
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import type { ChatReaction } from './CollabChat.types'
@@ -11,6 +11,7 @@ import { TwemojiImg } from './CollabChatTwemojiImg'
import { ReactionBadge } from './CollabChatReactionBadge'
import { UserAvatar } from './CollabNotesUserAvatar'
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
import { LinkPreview } from './CollabChatLinkPreview'
function reaction(overrides: Partial<ChatReaction> = {}): ChatReaction {
return { emoji: '👍', count: 1, users: [{ id: 1, username: 'ada' }], ...overrides } as unknown as ChatReaction
@@ -147,13 +148,26 @@ describe('WebsiteThumbnail', () => {
expect(screen.getByText('example.com')).toBeInTheDocument()
})
it('FE-W4CCS-014: falls back to a link label for an unparseable url and a failing preview', async () => {
it('FE-W4CCS-014: keeps the domain chip when the preview request fails', async () => {
linkPreview.mockRejectedValue(new Error('blocked'))
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
render(<WebsiteThumbnail url="https://blocked.example/a" tripId={4} color="#000" />)
expect(screen.getByText('link')).toBeInTheDocument()
expect(screen.getByText('blocked.example')).toBeInTheDocument()
await waitFor(() => expect(linkPreview).toHaveBeenCalled())
expect(screen.getByRole('link')).toHaveAttribute('title', 'not a url')
expect(screen.getByRole('link')).toHaveAttribute('title', 'https://blocked.example/a')
})
it('FE-W4CCS-017: a url that is not http(s) is shown but neither linked nor previewed', async () => {
const { unmount } = render(<WebsiteThumbnail url="javascript:alert(1)" tripId={4} color="#000" />)
expect(screen.queryByRole('link')).toBeNull()
expect(screen.getByTitle('javascript:alert(1)')).toBeInTheDocument()
unmount()
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
expect(screen.getByText('link')).toBeInTheDocument()
expect(screen.queryByRole('link')).toBeNull()
expect(linkPreview).not.toHaveBeenCalled()
})
it('FE-W4CCS-015: caches per trip, because the preview endpoint is trip-scoped', async () => {
@@ -185,3 +199,34 @@ describe('WebsiteThumbnail', () => {
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/b.png')
})
})
describe('LinkPreview', () => {
it('FE-W4CCS-018: caches per trip, because the preview endpoint is trip-scoped', async () => {
const shared = 'https://example.com/preview'
linkPreview.mockResolvedValue({ title: 'Trip 4', image: null })
const first = render(<LinkPreview url={shared} tripId={4} own={false} onLoad={undefined} />)
expect(await screen.findByText('Trip 4')).toBeInTheDocument()
first.unmount()
linkPreview.mockResolvedValue({ title: 'Trip 9', image: null })
render(<LinkPreview url={shared} tripId={9} own={false} onLoad={undefined} />)
expect(await screen.findByText('Trip 9')).toBeInTheDocument()
expect(linkPreview).toHaveBeenCalledTimes(2)
expect(linkPreview).toHaveBeenLastCalledWith(9, shared)
})
it('FE-W4CCS-019: a preview that resolves after the url changed is not shown', async () => {
let resolveFirst: (v: { title: string; image: string | null }) => void = () => {}
linkPreview.mockImplementationOnce(() => new Promise(res => { resolveFirst = res }))
const { rerender } = render(<LinkPreview url="https://slow.example/a" tripId={4} own={false} onLoad={undefined} />)
linkPreview.mockResolvedValue({ title: 'Second', image: null })
rerender(<LinkPreview url="https://fast.example/b" tripId={4} own={false} onLoad={undefined} />)
expect(await screen.findByText('Second')).toBeInTheDocument()
resolveFirst({ title: 'First', image: null })
await waitFor(() => expect(screen.queryByText('First')).toBeNull())
expect(screen.getByText('Second')).toBeInTheDocument()
})
})
@@ -271,6 +271,7 @@ describe('CollabNotes', () => {
const listener = calls[calls.length - 1][0];
act(() => {
listener({
tripId: 1,
type: 'collab:note:created',
note: {
id: 50, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null,
@@ -301,7 +302,7 @@ describe('CollabNotes', () => {
const calls = (addListener as ReturnType<typeof vi.fn>).mock.calls;
const listener = calls[calls.length - 1][0];
act(() => {
listener({ type: 'collab:note:deleted', noteId: 7 });
listener({ tripId: 1, type: 'collab:note:deleted', noteId: 7 });
});
await waitFor(() => expect(screen.queryByText('WS Delete')).not.toBeInTheDocument());
});
@@ -463,6 +464,7 @@ describe('CollabNotes', () => {
const listener = calls[calls.length - 1][0];
act(() => {
listener({
tripId: 1,
type: 'collab:note:updated',
note: {
id: 5, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null,
@@ -1390,7 +1392,7 @@ describe('CollabNotes details', () => {
await screen.findByText('Already here');
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
handler({ tripId: 1, type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
});
expect(screen.getAllByText('Already here')).toHaveLength(1);
});
@@ -1403,7 +1405,7 @@ describe('CollabNotes details', () => {
await screen.findByText('Second');
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
handler({ tripId: 1, type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
});
expect(await screen.findByText('Second renamed')).toBeInTheDocument();
expect(screen.getByText('First')).toBeInTheDocument();
@@ -1414,9 +1416,9 @@ describe('CollabNotes details', () => {
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Doomed');
const handler = wsHandler();
await act(async () => { handler({ type: 'collab:note:deleted' }); });
await act(async () => { handler({ tripId: 1, type: 'collab:note:deleted' }); });
expect(screen.getByText('Doomed')).toBeInTheDocument();
await act(async () => { handler({ type: 'collab:note:deleted', id: 9 }); });
await act(async () => { handler({ tripId: 1, type: 'collab:note:deleted', id: 9 }); });
await waitFor(() => expect(screen.queryByText('Doomed')).not.toBeInTheDocument());
});
@@ -1803,6 +1805,56 @@ describe('CollabNotes details', () => {
expect(screen.getByText('Older note')).toBeInTheDocument();
});
it('FE-W5CNT-030: a WebSocket event for another trip is ignored', async () => {
serveNotes({ notes: [] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
const handler = wsHandler();
await act(async () => {
handler({ tripId: 2, type: 'collab:note:created', note: buildNote({ id: 60, title: 'Other trip note' }) });
});
expect(screen.queryByText('Other trip note')).not.toBeInTheDocument();
});
it('FE-W5CNT-031: a rejected rename still renames the rest and re-reads the list', async () => {
const user = userEvent.setup();
const puts: string[] = [];
serveNotesSequence([
{ notes: [buildNote({ id: 1, title: 'First note', category: 'OldCat' }), buildNote({ id: 2, title: 'Second note', category: 'OldCat' })] },
{ notes: [buildNote({ id: 1, title: 'First note', category: 'OldCat' }), buildNote({ id: 2, title: 'Second note', category: 'NewCat' })] },
]);
server.use(
http.put('/api/trips/1/collab/notes/1', () => {
puts.push('1');
return new HttpResponse(null, { status: 500 });
}),
http.put('/api/trips/1/collab/notes/2', () => {
puts.push('2');
return HttpResponse.json({ note: buildNote({ id: 2, title: 'Second note', category: 'NewCat' }) });
}),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('First note');
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('Manage Categories', { selector: 'h3' });
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename')!;
await user.click(oldCat);
const editInput = screen.getByDisplayValue('OldCat');
await user.clear(editInput);
await user.type(editInput, 'NewCat');
await user.keyboard('{Enter}');
await user.click(screen.getByRole('button', { name: /^Save$/i }));
// The rejected note must not stop the second one, and the modal still closes.
await waitFor(() => expect(puts).toEqual(['1', '2']));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h3' })).not.toBeInTheDocument());
// Re-read: the note the server refused is still shown under its old category.
await waitFor(() => expect(screen.getAllByText('NewCat').length).toBeGreaterThan(0));
expect(screen.getAllByText('OldCat').length).toBeGreaterThan(0);
});
it('FE-W5CNT-029: a failing delete reports an error and keeps the note in the list', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 30, title: 'Stubborn note' })] });
+30 -11
View File
@@ -89,6 +89,9 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
if (!tripId) return
const handler = (msg) => {
// The panel is not remounted on a trip change, so an event still in flight
// from the trip we just left must not land in this list.
if (String(msg?.tripId) !== String(tripId)) return
if (msg.type === 'collab:note:created' && msg.note) {
setNotes(prev => {
if (prev.some(n => n.id === msg.note.id)) return prev
@@ -161,16 +164,28 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
}
}, [tripId, toast, t])
// A colour or a rename is N single-note writes; if one of them is rejected the
// rest still have to run, and the list has to be re-read so it stops showing a
// change the server never took. handleUpdateNote already toasts each failure.
const resyncNotes = useCallback(async () => {
try {
const fresh = await collabApi.getNotes(tripId)
setNotes(fresh?.notes || fresh || [])
} catch {}
}, [tripId])
const saveCategoryColors = useCallback(async (newMap) => {
let failed = 0
// Update notes with changed colors
for (const [cat, color] of Object.entries(newMap)) {
const notesInCat = notes.filter(n => n.category === cat)
if (notesInCat.length > 0 && categoryColors[cat] !== color) {
for (const n of notesInCat) {
await handleUpdateNote(n.id, { color })
try { await handleUpdateNote(n.id, { color }) } catch { failed++ }
}
}
}
if (failed > 0) await resyncNotes()
// Save all categories (including empty ones) to localStorage
const emptyCats = {}
for (const [cat, color] of Object.entries(newMap)) {
@@ -179,7 +194,17 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
}
}
saveEmptyCategories(emptyCats)
}, [categoryColors, notes, handleUpdateNote])
}, [categoryColors, notes, handleUpdateNote, resyncNotes])
const renameCategory = useCallback(async (oldName, newName) => {
// Update all notes with this category in DB
const toUpdate = notes.filter(n => n.category === oldName)
let failed = 0
for (const n of toUpdate) {
try { await handleUpdateNote(n.id, { category: newName }) } catch { failed++ }
}
if (failed > 0) await resyncNotes()
}, [notes, handleUpdateNote, resyncNotes])
const handleEditSubmit = useCallback(async (data) => {
if (!editingNote) return
@@ -232,7 +257,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
notes, loading, showNewModal, setShowNewModal, editingNote, setEditingNote,
viewingNote, setViewingNote, previewFile, setPreviewFile, showSettings, setShowSettings,
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
handleCreateNote, handleUpdateNote, saveCategoryColors, renameCategory, handleEditSubmit,
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
pendingDeleteNoteId, setPendingDeleteNoteId,
}
@@ -462,7 +487,7 @@ export default function CollabNotes(props: CollabNotesProps) {
loading, tripId, t, categories, categoryColors, getCategoryColor, notes,
viewingNote, showNewModal, editingNote, previewFile, showSettings,
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, renameCategory, handleUpdateNote,
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
} = S
@@ -511,13 +536,7 @@ export default function CollabNotes(props: CollabNotesProps) {
categories={categories}
categoryColors={categoryColors}
onSave={saveCategoryColors}
onRenameCategory={async (oldName, newName) => {
// Update all notes with this category in DB
const toUpdate = notes.filter(n => n.category === oldName)
for (const n of toUpdate) {
await handleUpdateNote(n.id, { category: newName })
}
}}
onRenameCategory={renameCategory}
t={t}
/>
)}
@@ -12,7 +12,7 @@ vi.mock('../../api/websocket', () => ({
import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { http, HttpResponse, delay } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
@@ -234,7 +234,7 @@ describe('CollabPolls', () => {
// Get the WS listener that was registered
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
listener({ tripId: 1, type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
await screen.findByText('Live poll?');
});
@@ -249,7 +249,7 @@ describe('CollabPolls', () => {
await screen.findByText('Best destination?');
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:deleted', pollId: 3 });
listener({ tripId: 1, type: 'collab:poll:deleted', pollId: 3 });
await waitFor(() =>
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
@@ -658,8 +658,8 @@ describe('CollabPolls details', () => {
const handler = wsHandler();
await act(async () => {
handler({});
handler({ type: 'collab:poll:deleted' });
handler({ type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
handler({ tripId: 1, type: 'collab:poll:deleted' });
handler({ tripId: 1, type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
});
expect(screen.getAllByText('Best destination?')).toHaveLength(1);
});
@@ -671,22 +671,52 @@ describe('CollabPolls details', () => {
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
handler({ tripId: 1, type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
});
expect(await screen.findByText('Voted live')).toBeInTheDocument();
expect(screen.getByText('Other poll')).toBeInTheDocument();
await act(async () => {
handler({ type: 'collab:poll:closed', poll: { id: 41 } });
handler({ tripId: 1, type: 'collab:poll:closed', poll: { id: 41 } });
});
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
await act(async () => {
handler({ type: 'collab:poll:deleted', poll: { id: 42 } });
handler({ tripId: 1, type: 'collab:poll:deleted', poll: { id: 42 } });
});
await waitFor(() => expect(screen.queryByText('Other poll')).not.toBeInTheDocument());
});
it('FE-W5CPL-027: a WebSocket event for another trip is ignored', async () => {
servePolls({ polls: [] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab.polls.empty/i);
const handler = wsHandler();
await act(async () => {
handler({ tripId: 2, type: 'collab:poll:created', poll: buildPoll({ id: 88, question: 'Other trip poll?' }) });
});
expect(screen.queryByText('Other trip poll?')).not.toBeInTheDocument();
});
it('FE-W5CPL-028: a slow load for the trip we left does not overwrite the new one', async () => {
server.use(
http.get('/api/trips/1/collab/polls', async () => {
await delay(80);
return HttpResponse.json({ polls: [buildPoll({ id: 1, question: 'Trip one poll?' })] });
}),
http.get('/api/trips/2/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ id: 2, question: 'Trip two poll?' })] }),
),
);
const { rerender } = render(<CollabPolls {...defaultProps} />);
rerender(<CollabPolls tripId={2} currentUser={currentUser} />);
await screen.findByText('Trip two poll?');
await act(async () => { await delay(150); });
expect(screen.queryByText('Trip one poll?')).not.toBeInTheDocument();
expect(screen.getByText('Trip two poll?')).toBeInTheDocument();
});
it('FE-W5CPL-026: a poll with a live deadline starts the countdown ticker', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
+13 -3
View File
@@ -359,15 +359,25 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const [showForm, setShowForm] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
collabApi.getPolls(tripId).then(data => {
setPolls(Array.isArray(data) ? data : data.polls || [])
}).catch(() => {}).finally(() => setLoading(false))
if (!cancelled) setPolls(Array.isArray(data) ? data : data.polls || [])
}).catch(() => {
if (!cancelled) setPolls([])
}).finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [tripId])
// WebSocket
useEffect(() => {
const handler = (msg) => {
if (!msg?.type) return
// The panel is not remounted on a trip change, so an event still in flight
// from the trip we just left must not land in this list.
if (String(msg.tripId) !== String(tripId)) return
if (msg.type === 'collab:poll:created' && msg.poll) {
setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [msg.poll, ...prev])
}
@@ -384,7 +394,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
}
addListener(handler)
return () => removeListener(handler)
}, [])
}, [tripId])
const handleCreate = useCallback(async (data) => {
try {
@@ -3,21 +3,25 @@ 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 } from 'vitest'
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
// Dynamic date helpers
const today = new Date().toISOString().split('T')[0]
// 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 d.toISOString().split('T')[0]
return localDate(d)
}
function getPastDate(daysBack: number): string {
const d = new Date()
d.setDate(d.getDate() - daysBack)
return d.toISOString().split('T')[0]
return localDate(d)
}
const tomorrow = getFutureDate(1)
@@ -258,6 +262,29 @@ describe('WhatsNextWidget', () => {
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()
@@ -17,11 +17,15 @@ function formatTime(timeStr, is12h) {
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
const localIsoDate = (d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
function formatDayLabel(date, t, locale) {
const now = new Date()
const nowDate = now.toISOString().split('T')[0]
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
// Day dates are plain calendar strings, so "today"/"tomorrow" have to be
// compared against the local calendar day, not the UTC one.
const nowDate = localIsoDate(now)
const tomorrowDate = localIsoDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1))
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
@@ -47,7 +51,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
const upcoming = useMemo(() => {
const now = new Date()
const nowDate = now.toISOString().split('T')[0]
const nowDate = localIsoDate(now)
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
const items = []
+1 -1
View File
@@ -98,7 +98,7 @@ interface StatCardProps {
canEdit: boolean
selectedYear: number
isShiftedYear: boolean
onSave: (userId: number, year: number, days: number) => Promise<void>
onSave: (year: number, days: number, targetUserId: number) => Promise<void>
t: TranslationFn
}
@@ -1,7 +1,9 @@
// FE-COMP-VCYREG-001 to FE-COMP-VCYREG-007
// FE-COMP-VCYREG-001 to FE-COMP-VCYREG-009
import { describe, it, expect, beforeEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions'
function holidaysRespond(rows: unknown) {
@@ -14,6 +16,7 @@ function regionsRespond(body: unknown) {
beforeEach(() => {
server.resetHandlers()
resetAllStores()
})
describe('fetchRegionOptions', () => {
@@ -80,7 +83,8 @@ describe('fetchSchoolHolidayRegionOptions', () => {
])
})
it('FE-COMP-VCYREG-007: a subdivision country prefers the German name and falls back to the code', async () => {
it('FE-COMP-VCYREG-007: a subdivision country takes the name in the active language and falls back to the code', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, language: 'de' } })
regionsRespond({
groups: [],
subdivisions: [
@@ -95,6 +99,19 @@ describe('fetchSchoolHolidayRegionOptions', () => {
])
})
it('FE-COMP-VCYREG-009: a language the data has no name for falls back to English', async () => {
regionsRespond({
groups: [],
subdivisions: [
{ code: 'DE-BY', name: [{ language: 'DE', text: 'Bayern' }, { language: 'EN', text: 'Bavaria' }] },
],
})
expect(await fetchSchoolHolidayRegionOptions('DE', 'fr')).toEqual([
{ value: 'DE-BY', label: 'Bavaria' },
])
})
it('FE-COMP-VCYREG-008: a failing region request degrades to no regions', async () => {
server.use(http.get('/api/addons/vacay/school-holidays/regions/:country', () =>
HttpResponse.json({ error: 'down' }, { status: 500 })))
+18 -8
View File
@@ -1,4 +1,5 @@
import apiClient from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
// Loads the subdivision (state/region) options for a holiday-calendar country.
@@ -48,31 +49,40 @@ type OpenHolidayOption = {
children?: OpenHolidayOption[] | null
}
function localizedName(names: LocalizedName[] | undefined, fallback: string): string {
return names?.find(n => n.language?.toUpperCase() === 'DE')?.text
// OpenHolidays ships every region name in several languages, so pick the one the
// user actually reads before falling back to English.
function localizedName(names: LocalizedName[] | undefined, fallback: string, lang: string): string {
const want = lang.slice(0, 2).toUpperCase()
return names?.find(n => n.language?.toUpperCase() === want)?.text
|| names?.find(n => n.language?.toUpperCase() === 'EN')?.text
|| names?.[0]?.text
|| fallback
}
function flattenOptions(items: OpenHolidayOption[] | undefined, prefix = ''): { value: string; label: string }[] {
function flattenOptions(items: OpenHolidayOption[] | undefined, lang: string, prefix = ''): { value: string; label: string }[] {
const result: { value: string; label: string }[] = []
for (const item of items ?? []) {
const label = localizedName(item.name, item.shortName || item.code)
const label = localizedName(item.name, item.shortName || item.code, lang)
result.push({ value: item.code, label: prefix ? `${prefix} / ${label}` : label })
result.push(...flattenOptions(item.children ?? undefined, prefix ? `${prefix} / ${label}` : label))
result.push(...flattenOptions(item.children ?? undefined, lang, prefix ? `${prefix} / ${label}` : label))
}
return result
}
export async function fetchSchoolHolidayRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
// Read outside React: the two settings screens hand this function nothing but the
// country, and threading the language through both of them buys nothing.
function activeLanguage(): string {
return useSettingsStore.getState().settings.language || 'en'
}
export async function fetchSchoolHolidayRegionOptions(country: string, lang = activeLanguage()): Promise<{ value: string; label: string }[]> {
const config = SCHOOL_HOLIDAY_COUNTRY_CONFIG[country]
if (!config || config.strategy === 'country') return []
try {
const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`)
if (config.strategy === 'groups') {
return flattenOptions(r.data.groups)
return flattenOptions(r.data.groups, lang)
.map(opt => ({
value: `${country}|group:${opt.value}`,
label: opt.label,
@@ -80,7 +90,7 @@ export async function fetchSchoolHolidayRegionOptions(country: string): Promise<
.sort((a, b) => a.label.localeCompare(b.label))
}
return flattenOptions(r.data.subdivisions)
return flattenOptions(r.data.subdivisions, lang)
.map(opt => ({ value: opt.value, label: opt.label }))
.sort((a, b) => a.label.localeCompare(b.label))
} catch {