From 8d4d3423ff5424ca7f678cb2e315aa8ced5a0af3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 12:14:28 +0200 Subject: [PATCH] feat(client): open views follow member, settings, tag, atlas and journey changes made elsewhere --- client/src/api/wsEventPolicy.ts | 80 +++--- .../components/Budget/useCostsLedger.test.ts | 22 ++ .../src/components/Budget/useCostsLedger.ts | 40 ++- .../Collections/useSaveToCollection.ts | 27 ++ .../components/Packing/useBagTotalsPing.ts | 27 +- .../Packing/usePackingCategoryAssignees.ts | 27 +- .../Packing/usePackingListPanel.test.tsx | 26 ++ .../components/Packing/usePackingListPanel.ts | 25 +- .../src/hooks/useInAppNotificationListener.ts | 24 +- client/src/pages/VacayPage.test.tsx | 75 ++++- client/src/pages/atlas/useAtlas.test.tsx | 25 ++ client/src/pages/atlas/useAtlas.ts | 10 +- .../src/pages/dashboard/useDashboard.test.tsx | 29 ++ client/src/pages/dashboard/useDashboard.ts | 16 ++ client/src/pages/journey/useJourney.ts | 12 + .../src/pages/tripPlanner/useTripPlanner.ts | 17 +- .../pages/tripPlanner/useTripRosterSync.ts | 37 +++ client/src/pages/vacay/useVacay.ts | 77 ++++- client/src/store/inAppNotificationStore.ts | 19 ++ client/src/store/slices/remoteEventHandler.ts | 195 ++++++++++++- client/src/store/tripStore.ts | 44 +++ client/src/sync/useServerPing.ts | 29 +- .../useInAppNotificationListener.test.ts | 5 + .../broadcastParity.test.ts | 176 ++++++++++++ .../broadcastReloads.test.tsx | 271 ++++++++++++++++++ .../registry-parity.test.ts | 40 +-- shared/src/i18n/ar/trip.ts | 1 + shared/src/i18n/az/trip.ts | 1 + shared/src/i18n/br/trip.ts | 1 + shared/src/i18n/ca/trip.ts | 1 + shared/src/i18n/cs/trip.ts | 1 + shared/src/i18n/de/trip.ts | 1 + shared/src/i18n/en/trip.ts | 1 + shared/src/i18n/es/trip.ts | 1 + shared/src/i18n/et/trip.ts | 1 + shared/src/i18n/fr/trip.ts | 1 + shared/src/i18n/gr/trip.ts | 1 + shared/src/i18n/hu/trip.ts | 1 + shared/src/i18n/id/trip.ts | 1 + shared/src/i18n/it/trip.ts | 1 + shared/src/i18n/ja/trip.ts | 1 + shared/src/i18n/ko/trip.ts | 1 + shared/src/i18n/nl/trip.ts | 1 + shared/src/i18n/pl/trip.ts | 1 + shared/src/i18n/ru/trip.ts | 1 + shared/src/i18n/sk/trip.ts | 1 + shared/src/i18n/sv/trip.ts | 1 + shared/src/i18n/th/trip.ts | 1 + shared/src/i18n/tr/trip.ts | 1 + shared/src/i18n/uk/trip.ts | 1 + shared/src/i18n/vi/trip.ts | 1 + shared/src/i18n/zh-TW/trip.ts | 1 + shared/src/i18n/zh/trip.ts | 1 + 53 files changed, 1269 insertions(+), 133 deletions(-) create mode 100644 client/src/pages/tripPlanner/useTripRosterSync.ts create mode 100644 client/tests/unit/remoteEventHandler/broadcastParity.test.ts create mode 100644 client/tests/unit/remoteEventHandler/broadcastReloads.test.tsx diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index fac299062..3aa08c4b8 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -43,9 +43,37 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ 'roadtripPreferences:changed', // components/Roadtrip/useDayBoundaries reloads the shared boundaries. 'roadtripBoundary:changed', - // In-app notifications — hooks/useInAppNotificationListener + // In-app notifications: hooks/useInAppNotificationListener, which also keeps + // the bell's unread count in step (inAppNotificationStore). 'notification:new', 'notification:updated', + 'notification:read-all', + // The same app-wide listener reads the display settings (settingsStore) and the + // tags (tripStore.reloadTags) again; both events carry names, never values. + 'settings:updated', + 'tags:changed', + // pages/dashboard/useDashboard reads the trip list again (desktop and phone). + 'trips:changed', + // pages/atlas/useAtlas reads the atlas again (desktop and phone). + 'atlas:changed', + // Trip roster: pages/tripPlanner/useTripRosterSync and the desktop packing panel + // (components/Packing/usePackingListPanel) read the members again. member:removed + // (it may be this user leaving the trip) and member:updated (a guest's new name on + // the rows that list people) are applied in the store and read back by the same + // hooks; both also move the balances below. + 'member:added', + // Balances: components/Budget/useCostsLedger reads the settlement again on + // these and on the budget item events that move a split. + 'budget:settlement-created', + 'budget:settlement-updated', + 'budget:settlement-deleted', + // Bags and who looks after a category: components/Packing/useBagTotalsPing and + // usePackingCategoryAssignees read them again, for both packing lists. + 'packing:bag-created', + 'packing:bag-updated', + 'packing:bag-deleted', + 'packing:bag-members-updated', + 'packing:assignees', // Bag weights (#2191) — content-free ping; components/Packing/usePackingListPanel // and mobile/screens/trip/tabs/MPackingListTab re-read the bags on it. It carries // no state to apply, which is the point: the totals are summed server-side across @@ -73,6 +101,7 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ 'vacay:shared-update', // Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener) // reads the open journey again, and leaves it once journey:deleted says it is gone. + // The journey list (pages/journey/useJourney) reads itself again on those two. 'journey:updated', 'journey:deleted', 'journey:trip:synced', @@ -121,43 +150,20 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ ] as const satisfies readonly TrekWsEventName[] /** - * Events the client deliberately does not act on today (state of the world - * when the registry landed — every one of these was already dropped by the - * old silent `default:` branches). Removing an entry means the event is now - * handled somewhere; ADDING an entry is a product decision that a new server - * event should have no client reaction — never add one just to silence the - * registry-parity test. + * Events the client deliberately does not act on, each with the reason. Removing + * an entry means the event is now handled somewhere; ADDING one is a product + * decision that a server event should have no client reaction, never a way to + * silence the registry-parity test, which pins these keys to a list that only + * shrinks. */ -export const IGNORED_WS_EVENTS = [ - 'assignment:participants', - 'packing:reordered', - 'packing:bag-created', - 'packing:bag-updated', - 'packing:bag-deleted', - 'packing:bag-members-updated', - 'packing:assignees', - 'packing:template-applied', - 'todo:assignees', - 'budget:settlement-created', - 'budget:settlement-updated', - 'budget:settlement-deleted', - 'trip:deleted', - 'member:added', - 'member:removed', -] as const satisfies readonly TrekWsEventName[] +export const IGNORED_WS_EVENTS: Partial> = { + 'todo:assignees': + 'No screen shows who looks after a todo category (todoApi.getCategoryAssignees has no caller), so there is nothing to refresh.', +} /** - * Events registered ahead of the write that sends them: nothing on the server - * sends these yet, so there is nothing for the client to act on. They are not - * a decision to ignore. The change that adds a sender moves its event to a - * handler, and the list only shrinks. + * Events registered ahead of the write that sends them. The broadcast parity + * work gave every one of them a handler, so the list is empty; it stays as the + * place a future event registered before its sender goes, and only shrinks. */ -export const AWAITING_SENDER_WS_EVENTS = [ - 'member:updated', - 'todo:reordered', - 'trips:changed', - 'settings:updated', - 'tags:changed', - 'notification:read-all', - 'atlas:changed', -] as const satisfies readonly TrekWsEventName[] +export const AWAITING_SENDER_WS_EVENTS: readonly TrekWsEventName[] = [] diff --git a/client/src/components/Budget/useCostsLedger.test.ts b/client/src/components/Budget/useCostsLedger.test.ts index 3d558cdc3..da054e71c 100644 --- a/client/src/components/Budget/useCostsLedger.test.ts +++ b/client/src/components/Budget/useCostsLedger.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { budgetApi } from '../../api/client'; +import { addListener } from '../../api/websocket'; import { clearAll, offlineDb } from '../../db/offlineDb'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; @@ -350,3 +351,24 @@ describe('useCostsLedger: offline (#2601)', () => { await waitFor(() => expect(settlementMock().mock.calls.length).toBeGreaterThan(before)); }); }); + +describe('useCostsLedger: changes written elsewhere', () => { + it('FE-BCAST-017: a burst of payment, split and guest events reads the balances once more', async () => { + // Only this test's listeners: the mock keeps the calls of earlier tests otherwise. + vi.mocked(addListener).mockClear(); + const { result } = setup(); + await waitFor(() => expect(result.current.settlement).toEqual(SETTLEMENT)); + const before = settlementMock().mock.calls.length; + act(() => { + for (const [listener] of vi.mocked(addListener).mock.calls) { + listener({ type: 'budget:settlement-created', settlement: { id: 31 } }); + listener({ type: 'budget:member-paid-updated', itemId: 4, userId: 2, paid: 1 }); + listener({ type: 'member:updated', member: { id: 2, username: 'Bo' } }); + listener({ type: 'budget:created', item: { id: 99 } }); + } + }); + await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(before + 1)); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(settlementMock()).toHaveBeenCalledTimes(before + 1); + }); +}); diff --git a/client/src/components/Budget/useCostsLedger.ts b/client/src/components/Budget/useCostsLedger.ts index 6b4e48152..c5c0dd639 100644 --- a/client/src/components/Budget/useCostsLedger.ts +++ b/client/src/components/Budget/useCostsLedger.ts @@ -8,6 +8,7 @@ import { budgetRepo, type SettlementWriteResult } from '../../repo/budgetRepo'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; import type { TripStoreState } from '../../store/tripStore'; +import { useServerPing } from '../../sync/useServerPing'; import type { BudgetItem, Trip } from '../../types'; import { downloadBlob } from '../../utils/fileDownload'; import { localToday } from '../Planner/today'; @@ -26,6 +27,18 @@ import { import { knownPersonNames } from './CostsPanel.helpers'; import { useFreezeMissingRates } from './useFreezeMissingRates'; +/** The events after which the balances are read again from the server. */ +const SETTLEMENT_RELOAD_EVENTS = [ + 'budget:settlement-created', + 'budget:settlement-updated', + 'budget:settlement-deleted', + 'budget:updated', + 'budget:members-updated', + 'budget:member-paid-updated', + 'member:removed', + 'member:updated', +] as const; + type Translate = (key: string, params?: Record) => string; interface Toaster { error: (message: string) => void; @@ -91,15 +104,26 @@ export function useCostsLedger({ // The browser's own figure for the display currency goes along, for the server to // answer in it when it cannot fetch a quote itself. + const readSettlement = useCallback( + () => + budgetRepo + .settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) + .then((s) => { + setSettlement(s); + setSettlementError(false); + }) + .catch(() => setSettlementError(true)), + [tripId, base, tripCurrency, displayPerTrip] + ); const loadSettlement = useCallback(() => { - budgetRepo - .settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) - .then((s) => { - setSettlement(s); - setSettlementError(false); - }) - .catch(() => setSettlementError(true)); - }, [tripId, base, tripCurrency, displayPerTrip]); + void readSettlement(); + }, [readSettlement]); + + // A payment, a split, a paid mark or a renamed or removed guest written + // elsewhere (a fellow traveller, the assistant) moves the balances without + // changing the number of expenses, so those events read the settlement again, + // one read per burst however many changes one assistant step applies. + useServerPing(SETTLEMENT_RELOAD_EVENTS, true, readSettlement); // The items reload when the trip changes; the settlement reloads on a trip or // currency change and when the number of expenses changes. Further refreshes are diff --git a/client/src/components/Collections/useSaveToCollection.ts b/client/src/components/Collections/useSaveToCollection.ts index a300141f0..35bfbd5be 100644 --- a/client/src/components/Collections/useSaveToCollection.ts +++ b/client/src/components/Collections/useSaveToCollection.ts @@ -5,11 +5,25 @@ import { useNavigate } from 'react-router'; import { collectionsApi } from '../../api/collections'; import { useTranslation } from '../../i18n'; import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'; +import { useServerPing } from '../../sync/useServerPing'; import { getApiErrorMessage } from '../../utils/apiError'; import { useToast } from '../shared/Toast'; type MembershipEntry = CollectionMembership['lists'][number]; +/** + * The collection events that can change whether a place is saved: a place saved or + * removed in another tab or through MCP, an invite to a list accepted, a list + * deleted, left or taken away. + */ +const MEMBERSHIP_EVENTS = [ + 'collections:updated', + 'collections:accepted', + 'collections:deleted', + 'collections:left', + 'collections:removed', +] as const; + /** busyId while the "visited everywhere" bulk action runs. */ export const VISITED_EVERYWHERE_BUSY = -1; @@ -54,6 +68,19 @@ export function useSaveToCollection() { } }, [membershipQuery]); + // Saved elsewhere: the open picker reads its membership again, and the version + // bump lets the inspector bookmark re-check, once per burst of events. Collection + // events go to the user, not to a trip room, so opening a trip is no reason to re-check. + useServerPing( + MEMBERSHIP_EVENTS, + true, + async () => { + bumpVersion(); + await refreshMembership(); + }, + { rejoin: false } + ); + // Load lists + membership whenever the picker opens for a new target. The query is // derived from the target alone, so both change together. useEffect(() => { diff --git a/client/src/components/Packing/useBagTotalsPing.ts b/client/src/components/Packing/useBagTotalsPing.ts index c0c2fa8a9..38de9ddb3 100644 --- a/client/src/components/Packing/useBagTotalsPing.ts +++ b/client/src/components/Packing/useBagTotalsPing.ts @@ -1,13 +1,26 @@ import { useServerPing } from '../../sync/useServerPing' /** - * Re-read the bag weights when the server says they moved (#2191). - * - * The ping is content-free by design: totals are summed across every member, - * including people whose items this client may not see, so the event cannot - * carry the new numbers without leaking what produced them. `useServerPing` - * carries the coalescing, the in-flight guard and the reconnect handling. + * The events after which the bags are read again. The totals ping is content-free + * by design (#2191): totals are summed across every member, including people whose + * items this client may not see, so the event cannot carry the new numbers without + * leaking what produced them. A bag created, renamed, deleted or given new members + * elsewhere (a fellow traveller, the assistant) is read back the same way, so the + * bag list has one source instead of a second copy patched from the payloads. + */ +const BAG_RELOAD_EVENTS = [ + 'packing:bag-totals', + 'packing:bag-created', + 'packing:bag-updated', + 'packing:bag-deleted', + 'packing:bag-members-updated', +] as const + +/** + * Re-read the bags when the server says they moved. `useServerPing` carries the + * coalescing, the in-flight guard and the reconnect handling, so an assistant step + * that touches several bags is still one read. */ export function useBagTotalsPing(enabled: boolean, reload: () => Promise): void { - useServerPing('packing:bag-totals', enabled, reload) + useServerPing(BAG_RELOAD_EVENTS, enabled, reload) } diff --git a/client/src/components/Packing/usePackingCategoryAssignees.ts b/client/src/components/Packing/usePackingCategoryAssignees.ts index d3718ed12..b60f612b5 100644 --- a/client/src/components/Packing/usePackingCategoryAssignees.ts +++ b/client/src/components/Packing/usePackingCategoryAssignees.ts @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { packingApi } from '../../api/client'; +import { useServerPing } from '../../sync/useServerPing'; import { offlineOr } from '../../utils/needsConnection'; type Translate = (key: string, params?: Record) => string; @@ -23,14 +24,24 @@ export interface CategoryAssignee { export function usePackingCategoryAssignees({ tripId, t, toast }: { tripId: number; t: Translate; toast: Toaster }) { const [categoryAssignees, setCategoryAssignees] = useState>({}); + const reload = useCallback( + () => + packingApi + .getCategoryAssignees(tripId) + .then((data) => setCategoryAssignees(data.assignees || {})) + .catch(() => { + // A failed read leaves the last answer standing; there is nothing to undo. + }), + [tripId] + ); + useEffect(() => { - packingApi - .getCategoryAssignees(tripId) - .then((data) => setCategoryAssignees(data.assignees || {})) - .catch(() => { - // A failed read leaves every category unassigned; there is nothing to undo. - }); - }, [tripId]); + void reload(); + }, [reload]); + + // Assignees set elsewhere (a fellow traveller, the assistant) are read back, + // one read per burst, rather than patched in from the event. + useServerPing('packing:assignees', true, reload); const setAssignees = async (category: string, userIds: number[]) => { try { diff --git a/client/src/components/Packing/usePackingListPanel.test.tsx b/client/src/components/Packing/usePackingListPanel.test.tsx index 8fa10c9a9..3cb5001e0 100644 --- a/client/src/components/Packing/usePackingListPanel.test.tsx +++ b/client/src/components/Packing/usePackingListPanel.test.tsx @@ -11,6 +11,7 @@ import { useAuthStore } from '../../store/authStore' import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' import { usePermissionsStore } from '../../store/permissionsStore' +import { addListener } from '../../api/websocket' import { usePackingList, type PackingListPanelProps } from './usePackingListPanel' import type { PackingItem } from '../../types' @@ -84,6 +85,31 @@ describe('usePackingList — members & assignees', () => { expect(result.current.tripMembers).toEqual([]) }) + it('FE-BCAST-024: a traveller added or renamed elsewhere reaches the pickers, one read per burst', async () => { + vi.mocked(addListener).mockClear() + let calls = 0 + server.use( + http.get('/api/trips/:id/members', () => { + calls++ + return HttpResponse.json({ + owner: { id: 1, username: 'owner', avatar_url: null }, + members: calls > 1 ? [{ id: 2, username: 'Ana', avatar_url: null, is_guest: 1 }] : [], + }) + }), + ) + const { result } = renderPanel() + await waitFor(() => expect(result.current.tripMembers).toHaveLength(1)) + + act(() => { + for (const [listener] of vi.mocked(addListener).mock.calls) { + listener({ type: 'member:added', member: { id: 2 } }) + listener({ type: 'member:updated', member: { id: 2, username: 'Ana' } }) + } + }) + await waitFor(() => expect(result.current.tripMembers.map((m) => m.username)).toEqual(['owner', 'Ana'])) + expect(calls).toBe(2) + }) + it('FE-W5HOOK-004: an assignee response without a map falls back to an empty record', async () => { server.use(http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({}))) const { result } = renderPanel() diff --git a/client/src/components/Packing/usePackingListPanel.ts b/client/src/components/Packing/usePackingListPanel.ts index 0b1a3f4f3..e222ea2d9 100644 --- a/client/src/components/Packing/usePackingListPanel.ts +++ b/client/src/components/Packing/usePackingListPanel.ts @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef, useEffect } from 'react' +import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useAuthStore } from '../../store/authStore' @@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { tripsApi } from '../../api/client' import { useAddonStore } from '../../store/addonStore' +import { useServerPing } from '../../sync/useServerPing' import type { PackingItem } from '../../types' import { parseImportLines, sortItemsByName } from './packingListPanel.helpers' import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel' @@ -19,6 +20,9 @@ export type { CategoryAssignee } from './usePackingCategoryAssignees' const PACKING_SORT_KEY = 'trek:packing-sort' +/** The roster events that change who the pickers offer. */ +const MEMBER_EVENTS = ['member:added', 'member:updated', 'member:removed'] as const + export type PackingSort = 'manual' | 'name' export interface TripMember { @@ -78,14 +82,19 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor // Trip members & category assignees const [tripMembers, setTripMembers] = useState([]) + const readMembers = useCallback(() => tripsApi.getMembers(tripId).then(data => { + const all: TripMember[] = [] + if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url, is_guest: false }) + if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest }))) + setTripMembers(all) + }).catch(() => {}), [tripId]) + useEffect(() => { - tripsApi.getMembers(tripId).then(data => { - const all: TripMember[] = [] - if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url, is_guest: false }) - if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest }))) - setTripMembers(all) - }).catch(() => {}) - }, [tripId]) + void readMembers() + }, [readMembers]) + // A traveller or guest added, renamed or removed elsewhere: the pickers read the + // roster again, once per burst (the phone list reads the planner's roster). + useServerPing(MEMBER_EVENTS, true, readMembers) const { categoryAssignees, setAssignees: handleSetAssignees } = usePackingCategoryAssignees({ tripId, t, toast }) diff --git a/client/src/hooks/useInAppNotificationListener.ts b/client/src/hooks/useInAppNotificationListener.ts index d5fced1d3..655a9ec27 100644 --- a/client/src/hooks/useInAppNotificationListener.ts +++ b/client/src/hooks/useInAppNotificationListener.ts @@ -1,10 +1,26 @@ import { useEffect } from 'react' import { addListener, removeListener } from '../api/websocket' +import { useAuthStore } from '../store/authStore' import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts' +import { useSettingsStore } from '../store/settingsStore' +import { useTripStore } from '../store/tripStore' +import { useServerPing } from '../sync/useServerPing' +const reloadTags = () => useTripStore.getState().reloadTags() +const ACCOUNT_WIDE = { rejoin: false } + +/** + * The user's own channel, mounted once for the whole app: the bell's + * notifications, and the account-wide state a change elsewhere (another tab, an + * MCP client, the assistant) leaves stale in this one. The display settings and + * the tags carry no values on the wire, so both are read again, one read per burst. + */ export function useInAppNotificationListener(): void { const handleNew = useInAppNotificationStore(s => s.handleNewNotification) const handleUpdated = useInAppNotificationStore(s => s.handleUpdatedNotification) + const handleAllRead = useInAppNotificationStore(s => s.handleAllRead) + const signedIn = useAuthStore(s => s.isAuthenticated) + const loadSettings = useSettingsStore(s => s.loadSettings) useEffect(() => { const listener = (event: Record) => { @@ -12,9 +28,15 @@ export function useInAppNotificationListener(): void { handleNew(event.notification as any) } else if (event.type === 'notification:updated') { handleUpdated(event.notification as any) + } else if (event.type === 'notification:read-all') { + handleAllRead() } } addListener(listener) return () => removeListener(listener) - }, [handleNew, handleUpdated]) + }, [handleNew, handleUpdated, handleAllRead]) + + // Both go to this user's sockets, so opening a trip (a room join) is no reason to read them. + useServerPing('settings:updated', signedIn, loadSettings, ACCOUNT_WIDE) + useServerPing('tags:changed', signedIn, reloadTags, ACCOUNT_WIDE) } diff --git a/client/src/pages/VacayPage.test.tsx b/client/src/pages/VacayPage.test.tsx index 713bd29eb..dac2cb99d 100644 --- a/client/src/pages/VacayPage.test.tsx +++ b/client/src/pages/VacayPage.test.tsx @@ -210,7 +210,7 @@ describe('VacayPage', () => { }); // FE-PAGE-VACAY-013: WebSocket vacay:update triggers loadPlan + loadEntries + loadStats - it('handles vacay:update WebSocket message', () => { + it('handles vacay:update WebSocket message', async () => { const mockLoadPlan = vi.fn().mockResolvedValue(undefined); const mockLoadEntries = vi.fn().mockResolvedValue(undefined); const mockLoadStats = vi.fn().mockResolvedValue(undefined); @@ -219,13 +219,13 @@ describe('VacayPage', () => { render(); const handler = addListenerMock.mock.calls[0][0]; handler({ type: 'vacay:update' }); - expect(mockLoadPlan).toHaveBeenCalled(); - expect(mockLoadEntries).toHaveBeenCalledWith(2025); - expect(mockLoadStats).toHaveBeenCalledWith(2025); + await vi.waitFor(() => expect(mockLoadPlan).toHaveBeenCalled()); + await vi.waitFor(() => expect(mockLoadEntries).toHaveBeenCalledWith(2025)); + await vi.waitFor(() => expect(mockLoadStats).toHaveBeenCalledWith(2025)); }); // FE-PAGE-VACAY-014: WebSocket vacay:settings also calls loadAll - it('handles vacay:settings WebSocket message', () => { + it('handles vacay:settings WebSocket message', async () => { const mockLoadAll = vi.fn().mockResolvedValue(undefined); const mockLoadPlan = vi.fn().mockResolvedValue(undefined); const addListenerMock = websocket.addListener as ReturnType; @@ -235,12 +235,12 @@ describe('VacayPage', () => { // loadAll is called once on mount, reset to track the WS-triggered call mockLoadAll.mockClear(); handler({ type: 'vacay:settings' }); - expect(mockLoadAll).toHaveBeenCalled(); - expect(mockLoadPlan).toHaveBeenCalled(); + await vi.waitFor(() => expect(mockLoadAll).toHaveBeenCalled()); + await vi.waitFor(() => expect(mockLoadPlan).toHaveBeenCalled()); }); // FE-PAGE-VACAY-015: WebSocket vacay:invite calls loadAll - it('handles vacay:invite WebSocket message', () => { + it('handles vacay:invite WebSocket message', async () => { const mockLoadAll = vi.fn().mockResolvedValue(undefined); const addListenerMock = websocket.addListener as ReturnType; seedStore(useVacayStore, makeVacayState({ loadAll: mockLoadAll }) as any); @@ -248,7 +248,7 @@ describe('VacayPage', () => { const handler = addListenerMock.mock.calls[0][0]; mockLoadAll.mockClear(); handler({ type: 'vacay:invite' }); - expect(mockLoadAll).toHaveBeenCalled(); + await vi.waitFor(() => expect(mockLoadAll).toHaveBeenCalled()); }); // FE-PAGE-VACAY-016: Add next year button calls addYear with max+1 @@ -380,7 +380,7 @@ describe('VacayPage', () => { }); // FE-PAGE-VACAY-025: WebSocket vacay:share reloads shares and shared calendars - it('handles vacay:share WebSocket message', () => { + it('handles vacay:share WebSocket message', async () => { const mockLoadShares = vi.fn().mockResolvedValue(undefined); const mockLoadSharedCalendars = vi.fn().mockResolvedValue(undefined); const addListenerMock = websocket.addListener as ReturnType; @@ -392,7 +392,58 @@ describe('VacayPage', () => { const handler = addListenerMock.mock.calls[0][0]; mockLoadSharedCalendars.mockClear(); handler({ type: 'vacay:share' }); - expect(mockLoadShares).toHaveBeenCalled(); - expect(mockLoadSharedCalendars).toHaveBeenCalledWith(2025); + await vi.waitFor(() => expect(mockLoadShares).toHaveBeenCalled()); + await vi.waitFor(() => expect(mockLoadSharedCalendars).toHaveBeenCalledWith(2025)); + }); + + it('FE-BCAST-020: a burst of vacay events reads each part once, after it settles', () => { + const mockLoadPlan = vi.fn().mockResolvedValue(undefined); + const mockLoadAll = vi.fn().mockResolvedValue(undefined); + const mockLoadShares = vi.fn().mockResolvedValue(undefined); + const addListenerMock = websocket.addListener as ReturnType; + seedStore(useVacayStore, makeVacayState({ loadPlan: mockLoadPlan, loadAll: mockLoadAll, loadShares: mockLoadShares }) as never); + render(); + const handler = addListenerMock.mock.calls[0][0]; + mockLoadAll.mockClear(); + mockLoadPlan.mockClear(); + mockLoadShares.mockClear(); + vi.useFakeTimers(); + try { + // An assistant step marking days one by one, then a share and an invite in the same breath. + for (let i = 0; i < 20; i++) handler({ type: 'vacay:update' }); + handler({ type: 'vacay:share' }); + handler({ type: 'vacay:invite' }); + expect(mockLoadPlan).not.toHaveBeenCalled(); + vi.advanceTimersByTime(400); + expect(mockLoadPlan).toHaveBeenCalledTimes(1); + expect(mockLoadAll).toHaveBeenCalledTimes(1); + expect(mockLoadShares).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('FE-BCAST-027: a steady stream of vacay events still reads within two seconds', () => { + const mockLoadPlan = vi.fn().mockResolvedValue(undefined); + const addListenerMock = websocket.addListener as ReturnType; + seedStore(useVacayStore, makeVacayState({ loadPlan: mockLoadPlan }) as never); + render(); + const handler = addListenerMock.mock.calls[0][0]; + mockLoadPlan.mockClear(); + vi.useFakeTimers(); + try { + // One event every 300 ms, closer than the 400 ms settle time: without a cap + // the read would wait for the stream to stop. + for (let i = 0; i < 6; i++) { + handler({ type: 'vacay:update' }); + vi.advanceTimersByTime(300); + } + expect(mockLoadPlan).not.toHaveBeenCalled(); + handler({ type: 'vacay:update' }); + vi.advanceTimersByTime(300); + expect(mockLoadPlan).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/client/src/pages/atlas/useAtlas.test.tsx b/client/src/pages/atlas/useAtlas.test.tsx index daef0598f..2f2001e70 100644 --- a/client/src/pages/atlas/useAtlas.test.tsx +++ b/client/src/pages/atlas/useAtlas.test.tsx @@ -5,6 +5,7 @@ import { render, act, waitFor } from '../../../tests/helpers/render'; import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildSettings } from '../../../tests/helpers/factories'; +import { addListener } from '../../api/websocket'; import { useSettingsStore } from '../../store/settingsStore'; import L from 'leaflet'; import { A2_TO_A3 } from './atlasModel'; @@ -1386,3 +1387,27 @@ describe('useAtlas', () => { }); }); }); + +describe('useAtlas: changes written elsewhere', () => { + it('FE-BCAST-022: atlas:changed reads the atlas again, once per burst', async () => { + vi.mocked(addListener).mockClear(); + await mountAtlas(); + let statsCalls = 0; + server.use( + http.get('/api/addons/atlas/stats', () => { + statsCalls++; + return HttpResponse.json(statsResponse); + }), + ); + act(() => { + for (const [listener] of vi.mocked(addListener).mock.calls) { + listener({ type: 'atlas:changed' }); + listener({ type: 'atlas:changed' }); + listener({ type: 'collections:updated' }); + } + }); + await waitFor(() => expect(statsCalls).toBe(1)); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(statsCalls).toBe(1); + }); +}); diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index ed06b658b..65046741e 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -16,6 +16,7 @@ import { dawarichApi } from '../../api/dawarich' import { useToast } from '../../components/shared/Toast' import { getApiErrorMessage } from '../../types' import { usePlaceLanguage } from '../../hooks/usePlaceLanguage' +import { useServerPing } from '../../sync/useServerPing' import type { AtlasConfirmAction, VisitedRegionMap } from './useAtlasCountryActions' const PLANNED_KEY = 'trek_atlas_show_planned' @@ -252,13 +253,12 @@ export function useAtlas() { return opts }, [geoData, resolveName]) - // Load atlas data + bucket list - // - // Re-run on `atlasEpoch` so a confirmation made elsewhere on the page — the - // Dawarich card ticking wishes off or marking countries (#2279) — lands in the - // same numbers the map is drawing, rather than only after a reload. + // Load atlas data + bucket list. Re-run on `atlasEpoch` so a confirmation made on the + // page (the Dawarich card, #2279) or a change in another tab or through MCP + // (atlas:changed, once per burst) lands in the numbers the map draws, without a reload. const [atlasEpoch, setAtlasEpoch] = useState(0) const reloadAfterDawarich = useCallback(() => setAtlasEpoch(epoch => epoch + 1), []) + useServerPing('atlas:changed', true, async () => reloadAfterDawarich()) /** * Undo a wish that a recording ticked off. diff --git a/client/src/pages/dashboard/useDashboard.test.tsx b/client/src/pages/dashboard/useDashboard.test.tsx index 17cb741fc..949c5ec00 100644 --- a/client/src/pages/dashboard/useDashboard.test.tsx +++ b/client/src/pages/dashboard/useDashboard.test.tsx @@ -6,6 +6,7 @@ import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip } from '../../../tests/helpers/factories'; +import { addListener } from '../../api/websocket'; import { TranslationProvider } from '../../i18n/TranslationContext'; import { useAuthStore } from '../../store/authStore'; import { useDashboard } from './useDashboard'; @@ -436,3 +437,31 @@ describe('useDashboard', () => { expect(statsCalls).toBe(1); }); }); + +describe('useDashboard: trips changed elsewhere', () => { + it('FE-BCAST-018: trips:changed reads the list again quietly, once per burst', async () => { + vi.mocked(addListener).mockClear(); + const view = await mountLoaded(); + const COPY = buildTrip({ id: 104, title: 'Paris Adventure (copy)', start_date: '2028-08-01', end_date: '2028-08-05' }); + let listCalls = 0; + server.use( + http.get('/api/trips', ({ request }) => { + const url = new URL(request.url); + if (!url.searchParams.get('archived')) listCalls++; + return HttpResponse.json({ trips: url.searchParams.get('archived') ? [ROME] : [PARIS, TOKYO, COPY] }); + }), + ); + act(() => { + for (const [listener] of vi.mocked(addListener).mock.calls) { + listener({ type: 'trips:changed' }); + listener({ type: 'trips:changed' }); + } + }); + const shown = () => [view.result.current.spotlight, ...view.result.current.gridTrips].map((t) => t?.id); + expect(shown()).not.toContain(104); + await waitFor(() => expect(shown()).toContain(104)); + expect(listCalls).toBe(1); + expect(view.result.current.isLoading).toBe(false); + expect(toastCalls).toEqual([]); + }); +}); diff --git a/client/src/pages/dashboard/useDashboard.ts b/client/src/pages/dashboard/useDashboard.ts index 66fb8e518..95e191b08 100644 --- a/client/src/pages/dashboard/useDashboard.ts +++ b/client/src/pages/dashboard/useDashboard.ts @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router' import { tripsApi, authApi, reservationsApi } from '../../api/client' import { tripRepo } from '../../repo/tripRepo' import { isEffectivelyOffline } from '../../sync/networkMode' +import { useServerPing } from '../../sync/useServerPing' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { useToast } from '../../components/shared/Toast' @@ -92,6 +93,21 @@ export function useDashboard() { } } + // A trip created, copied or deleted by this user elsewhere (another tab, an MCP + // client) is read back quietly, one read per burst: no loading state and no + // toast, since nobody here asked for it. trips:changed goes to the acting user + // only; trip:deleted goes to the trip's room, which this page is never in. + useServerPing('trips:changed', true, async () => { + try { + const { trips, archivedTrips } = await tripRepo.list() + setTrips(sortTrips(trips)) + setArchivedTrips(sortTrips(archivedTrips)) + loadSideData() + } catch { + // The list on screen stays; the next visit reads it again. + } + }) + // Re-run both the trip fetch and the auth check so a recovered backend clears // the error banner (loadUser resets authCheckFailed on success). #1283 const retryLoad = () => { diff --git a/client/src/pages/journey/useJourney.ts b/client/src/pages/journey/useJourney.ts index c24f5086d..8a631d43f 100644 --- a/client/src/pages/journey/useJourney.ts +++ b/client/src/pages/journey/useJourney.ts @@ -6,6 +6,7 @@ import { journeyApi } from '../../api/client' import { useToast } from '../../components/shared/Toast' import { useTranslation } from '../../i18n' import { computeJourneyLifecycle } from '../../utils/journeyLifecycle' +import { useServerPing } from '../../sync/useServerPing' function dismissedSuggestionsKey(userId: number | undefined): string { return `trek:journey:dismissed-suggestions:v1:${userId ?? 'anonymous'}` @@ -62,6 +63,17 @@ export function useJourney() { useEffect(() => { loadJourneys() }, []) + // A journey renamed, given other trips or deleted in another tab or through + // MCP, or this user taken off one as a contributor: the list reads itself + // again, once per burst. The spinner only shows over an empty list, so a + // reread of a full one does not flash. + useServerPing(['journey:updated', 'journey:deleted', 'journey:contributor:changed'], true, async () => { + try { + await loadJourneys() + } catch { + // A failed reread keeps the list on screen; the next event or visit reads it again. + } + }) useEffect(() => { let cancelled = false diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index c0332d558..fcebced64 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -46,6 +46,7 @@ import { } from '../../utils/connectionsVisibility' import { usePlaceLanguage } from '../../hooks/usePlaceLanguage' import { useDayDelete } from './useDayDelete' +import { useTripRosterSync } from './useTripRosterSync' import { useDayAdd } from './useDayAdd' import { usePlannerDialogs } from './usePlannerDialogs' import { usePlaceDeepLink } from './usePlaceDeepLink' @@ -176,13 +177,17 @@ export function useTripPlanner() { // Re-fetch the trip roster so consumers (Costs participants, Collab, …) pick up a // just-added guest or member without a full page reload. - const refreshMembers = useCallback(() => { + const refreshMembers = useCallback(async () => { if (!tripId || isEffectivelyOffline()) return - tripsApi.getMembers(tripId).then(d => { - const all = [d.owner, ...(d.members || [])].filter(Boolean) - setTripMembers(all) - }).catch(() => {}) + try { + const d = await tripsApi.getMembers(tripId) + setTripMembers([d.owner, ...(d.members || [])].filter(Boolean)) + } catch { + // The roster on screen stays; the next member event or reload reads it again. + } }, [tripId]) + // A member added, removed or renamed elsewhere, and the trip going away under us. + useTripRosterSync(tripId, refreshMembers) useEffect(() => { addonsApi.enabled().then(data => { @@ -415,7 +420,7 @@ export function useTripPlanner() { .then(rows => setTripMembers(rows)) .catch(() => {}) } else { - refreshMembers() + void refreshMembers() } } }, [tripId]) diff --git a/client/src/pages/tripPlanner/useTripRosterSync.ts b/client/src/pages/tripPlanner/useTripRosterSync.ts new file mode 100644 index 000000000..adfa28d49 --- /dev/null +++ b/client/src/pages/tripPlanner/useTripRosterSync.ts @@ -0,0 +1,37 @@ +import { useEffect, useEffectEvent } from 'react'; +import { useNavigate } from 'react-router'; +import { useToast } from '../../components/shared/Toast'; +import { useTranslation } from '../../i18n'; +import { useTripStore, type TripGone } from '../../store/tripStore'; +import { useServerPing } from '../../sync/useServerPing'; + +/** The events after which the planner reads the trip's roster again. */ +const MEMBER_EVENTS = ['member:added', 'member:removed', 'member:updated'] as const; + +/** + * Keeps the planner's roster current and leaves a trip that went away. + * + * The roster is page-local (Costs participants, packing and day-plan pickers read + * it from the planner), so a member or guest added, removed or renamed elsewhere + * reads it again, one read per burst. When the open trip is deleted elsewhere, or + * this user is taken off it, the trip store marks it gone; the planner then says + * so and goes back to the trip list, on the desktop and the phone alike, since + * both run this planner. + */ +export function useTripRosterSync(tripId: number, refreshMembers: () => Promise): void { + const navigate = useNavigate(); + const toast = useToast(); + const { t } = useTranslation(); + + useServerPing(MEMBER_EVENTS, Number.isFinite(tripId), refreshMembers); + + const gone = useTripStore((s) => s.tripGone); + const leave = useEffectEvent((why: TripGone['reason']) => { + useTripStore.getState().clearTripGone(); + toast.info(t(why === 'deleted' ? 'dashboard.toast.deleted' : 'trip.toast.accessLost')); + navigate('/dashboard', { replace: true }); + }); + useEffect(() => { + if (gone && gone.tripId === tripId) leave(gone.reason); + }, [gone, tripId]); +} diff --git a/client/src/pages/vacay/useVacay.ts b/client/src/pages/vacay/useVacay.ts index 97ec817a1..f248c03da 100644 --- a/client/src/pages/vacay/useVacay.ts +++ b/client/src/pages/vacay/useVacay.ts @@ -1,10 +1,31 @@ -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useEffectEvent, useRef, useState } from 'react' import { useVacayStore } from '../../store/vacayStore' import { addListener, removeListener } from '../../api/websocket' import { useToast } from '../../components/shared/Toast' import { useTranslation } from '../../i18n' import { getApiErrorMessage } from '../../utils/apiError' +/** How long vacay events are collected before the page reads once. */ +const WS_RELOAD_SETTLE_MS = 400 +/** The longest a steady stream of events can put the read off. */ +const WS_RELOAD_MAX_WAIT_MS = 2000 + +/** What a vacay event makes the page read again: the plan year, the whole state, the shares. */ +type VacayReload = 'plan' | 'all' | 'shares' + +const VACAY_RELOADS: Record = { + 'vacay:update': ['plan'], + 'vacay:settings': ['plan', 'all'], + 'vacay:invite': ['all'], + 'vacay:accepted': ['all'], + 'vacay:declined': ['all'], + 'vacay:cancelled': ['all'], + 'vacay:dissolved': ['all'], + 'vacay:share': ['shares'], + 'vacay:share-removed': ['shares'], + 'vacay:shared-update': ['shares'], +} + /** * Vacay page logic — pulls the vacay store, owns the page-local UI state * (settings modal, delete-year prompt, mobile drawer), wires the WebSocket live @@ -22,27 +43,53 @@ export function useVacay() { useEffect(() => { loadAll() }, []) - // Live sync via WebSocket - const handleWsMessage = useCallback((msg: { type: string }) => { - if (msg.type === 'vacay:update' || msg.type === 'vacay:settings') { + // Live sync via WebSocket. An agent marking days one by one over MCP sends one + // event per day, so the reads wait until the events stop (or two seconds pass) + // and then run once, for whatever year is selected by that time (#2559). + const pendingReloads = useRef>(new Set()) + const wsReloadTimer = useRef | null>(null) + const burstStartedAt = useRef(null) + + // An effect event sees the latest render, so the year is the one selected now. + const flushReloads = useEffectEvent(() => { + const due = pendingReloads.current + pendingReloads.current = new Set() + const year = selectedYear + if (due.has('plan')) { loadPlan() - loadEntries(selectedYear) - loadStats(selectedYear) - if (msg.type === 'vacay:settings') loadAll() + loadEntries(year) + loadStats(year) } - if (msg.type === 'vacay:invite' || msg.type === 'vacay:accepted' || msg.type === 'vacay:declined' || msg.type === 'vacay:cancelled' || msg.type === 'vacay:dissolved') { - loadAll() - } - if (msg.type === 'vacay:share' || msg.type === 'vacay:share-removed' || msg.type === 'vacay:shared-update') { + if (due.has('all')) loadAll() + if (due.has('shares')) { loadShares() - loadSharedCalendars(selectedYear) + loadSharedCalendars(year) } - }, [selectedYear]) + }) useEffect(() => { + const handleWsMessage = (msg: { type: string }) => { + const reloads = VACAY_RELOADS[msg.type] + if (!reloads) return + reloads.forEach(r => pendingReloads.current.add(r)) + // Each event restarts the wait, but never past the cap counted from the + // burst's first event, so events closer than the settle time still land. + const now = Date.now() + burstStartedAt.current ??= now + const wait = Math.max(0, Math.min(WS_RELOAD_SETTLE_MS, burstStartedAt.current + WS_RELOAD_MAX_WAIT_MS - now)) + if (wsReloadTimer.current !== null) clearTimeout(wsReloadTimer.current) + wsReloadTimer.current = setTimeout(() => { + wsReloadTimer.current = null + burstStartedAt.current = null + flushReloads() + }, wait) + } addListener(handleWsMessage) - return () => removeListener(handleWsMessage) - }, [handleWsMessage]) + return () => { + removeListener(handleWsMessage) + if (wsReloadTimer.current !== null) clearTimeout(wsReloadTimer.current) + } + }, []) useEffect(() => { if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear); loadSharedCalendars(selectedYear) } }, [selectedYear]) diff --git a/client/src/store/inAppNotificationStore.ts b/client/src/store/inAppNotificationStore.ts index 70006a1cb..1ef428c6f 100644 --- a/client/src/store/inAppNotificationStore.ts +++ b/client/src/store/inAppNotificationStore.ts @@ -60,6 +60,7 @@ interface NotificationState { handleNewNotification: (notification: RawNotification) => void handleUpdatedNotification: (notification: RawNotification) => void + handleAllRead: () => void } const PAGE_SIZE = 20 @@ -187,10 +188,28 @@ export const useInAppNotificationStore = create((set, get) => })) }, + // A notification read, unread or answered in another tab (or through MCP) moves + // the bell's count too. One this tab has not loaded says nothing about what it + // was before, so the count is asked for instead of guessed. handleUpdatedNotification: (raw: RawNotification) => { const notification = normalizeNotification(raw) + const before = get().notifications.find(n => n.id === notification.id) + if (!before) { + void get().fetchUnreadCount() + return + } + const delta = Number(!notification.is_read) - Number(!before.is_read) set(state => ({ notifications: state.notifications.map(n => n.id === notification.id ? notification : n), + unreadCount: Math.max(0, state.unreadCount + delta), + })) + }, + + // Everything marked read at once elsewhere: the same end state markAllRead leaves. + handleAllRead: () => { + set(state => ({ + notifications: state.notifications.map(n => n.is_read ? n : { ...n, is_read: true }), + unreadCount: 0, })) }, })) diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index 91d4aa12a..f7903ff3c 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -1,7 +1,7 @@ import type { StoreApi } from 'zustand' import type { TrekWsTripEventName } from '@trek/shared' import type { TripStoreState } from '../tripStore' -import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetItemMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types' +import type { Assignment, AssignmentParticipant, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetItemMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types' import { offlineDb } from '../../db/offlineDb' import { useAuthStore } from '../authStore' import { mergeAssignmentPlace } from './placesSlice' @@ -87,6 +87,61 @@ const putTripFile: DexieWriter = async payload => { await offlineDb.tripFiles.put(payload.file as TripFile) } +/** The key of the day whose plan holds the assignment, if this tab has it. */ +function dayKeyOf(state: TripStoreState, assignmentId: number): string | undefined { + return Object.keys(state.assignments).find(key => state.assignments[key].some(a => a.id === assignmentId)) +} + +/** + * A list in the order a `*:reordered` event names. A named row takes its index in + * the event as `sort_order`, which is what the server stores (ids this tab does not + * hold still count), and moves to the front in that order. Rows the event does not + * name keep their `sort_order` and sit behind, since the server leaves them alone. + */ +function inEventOrder(items: T[], orderedIds: unknown): T[] | null { + if (!Array.isArray(orderedIds)) return null + // A repeated id ends where the server's last write for it puts it. + const position = new Map() + ;(orderedIds as number[]).forEach((id, index) => position.set(id, index)) + const at = (item: T): number => position.get(item.id) ?? 0 + const named = items + .filter(item => position.has(item.id)) + .sort((a, b) => at(a) - at(b)) + .map(item => (item.sort_order === at(item) ? item : { ...item, sort_order: at(item) })) + return [...named, ...items.filter(item => !position.has(item.id))] +} + +/** A guest renamed: `member:updated` carries the id and the new name. */ +function renamedMember(payload: Record): { id: number; username: string } | null { + const member = payload.member as { id?: unknown; username?: unknown } | undefined + if (typeof member?.id !== 'number' || typeof member.username !== 'string') return null + return { id: member.id, username: member.username } +} + +type NamedPerson = { user_id: number; username: string } + +/** Whether a list of people stored on a row names this person. */ +function names(people: readonly NamedPerson[] | undefined, id: number): boolean { + return (people ?? []).some(person => person.user_id === id) +} + +/** `people` with this person's name changed, the same array when nothing changes. */ +function withName

(people: P[] | undefined, id: number, username: string): P[] | undefined { + if (!people?.some(person => person.user_id === id && person.username !== username)) return people + return people.map(person => (person.user_id === id ? { ...person, username } : person)) +} + +/** `list` with `patch` applied, the same array when no row changes. */ +function patchRows(list: T[], patch: (row: T) => T): T[] { + let changed = false + const next = list.map(row => { + const patched = patch(row) + if (patched !== row) changed = true + return patched + }) + return changed ? next : list +} + /** * Per-event IndexedDB write-through, keyed by the shared WS event registry. * The key set is enumerable on purpose: the registry-parity test asserts every @@ -115,6 +170,10 @@ export const DEXIE_WRITERS: Partial> = ]) }, 'assignment:reordered': writePayloadDay, + 'assignment:participants': async (payload, state) => { + const dayKey = dayKeyOf(state, Number(payload.assignmentId)) + if (dayKey) await _writeDayToDb(Number(dayKey), state) + }, // ── Days ───────────────────────────────────────────────────────────────── 'day:created': writeDayById, @@ -134,6 +193,10 @@ export const DEXIE_WRITERS: Partial> = 'packing:deleted': async payload => { await offlineDb.packingItems.delete(payload.itemId as number) }, + // The rows this tab holds already passed the privacy check on their way in. + 'packing:reordered': async (_payload, state) => { + await offlineDb.packingItems.bulkPut(state.packingItems) + }, // ── Todo ───────────────────────────────────────────────────────────────── 'todo:created': putTodoItem, @@ -141,6 +204,9 @@ export const DEXIE_WRITERS: Partial> = 'todo:deleted': async payload => { await offlineDb.todoItems.delete(payload.itemId as number) }, + 'todo:reordered': async (_payload, state) => { + await offlineDb.todoItems.bulkPut(state.todoItems) + }, // ── Budget ─────────────────────────────────────────────────────────────── 'budget:created': putBudgetItem, @@ -169,6 +235,31 @@ export const DEXIE_WRITERS: Partial> = 'trip:updated': async payload => { await offlineDb.trips.put(payload.trip as Trip) }, + // A deleted trip must not come back from the cache on the next offline start. + 'trip:deleted': async payload => { + await offlineDb.trips.delete(Number(payload.id)) + }, + // Nor may a trip this user was taken off: the server no longer lets them read it. + 'member:removed': async (payload, state) => { + const me = useAuthStore.getState().user?.id + if (!state.trip || me == null || Number(payload.userId) !== me) return + await offlineDb.trips.delete(state.trip.id) + }, + // The rows the rename reached, read from the state the applier left. + 'member:updated': async (payload, state) => { + const member = renamedMember(payload) + if (!member) return + const { id } = member + const dayIds = Object.keys(state.assignments) + .filter(key => state.assignments[key].some(a => names(a.participants, id))) + .map(Number) + await Promise.all([ + offlineDb.budgetItems.bulkPut(state.budgetItems.filter(i => names(i.members, id))), + offlineDb.reservations.bulkPut(state.reservations.filter(r => names(r.travelers, id))), + offlineDb.packingItems.bulkPut(state.packingItems.filter(i => names(i.recipients, id) || names(i.contributors, id))), + ...dayIds.map(dayId => _writeDayToDb(dayId, state)), + ]) + }, // ── Files ───────────────────────────────────────────────────────────────── 'file:created': putTripFile, @@ -337,6 +428,20 @@ export const STATE_APPLIERS: Partial> } } }, + // Who joins a visit, as the participant chips in the day plan and the place + // inspector show it. The event names the assignment only, so find its day. + 'assignment:participants': (payload, state) => { + const assignmentId = Number(payload.assignmentId) + const dayKey = dayKeyOf(state, assignmentId) + if (!dayKey || !Array.isArray(payload.participants)) return {} + const participants = payload.participants as AssignmentParticipant[] + return { + assignments: { + ...state.assignments, + [dayKey]: state.assignments[dayKey].map(a => (a.id === assignmentId ? { ...a, participants } : a)), + }, + } + }, // Days 'day:created': (payload, state) => { @@ -401,6 +506,10 @@ export const STATE_APPLIERS: Partial> 'packing:deleted': (payload, state) => ({ packingItems: state.packingItems.filter(i => i.id !== payload.itemId), }), + 'packing:reordered': (payload, state) => { + const packingItems = inEventOrder(state.packingItems, payload.orderedIds) + return packingItems ? { packingItems } : {} + }, // Todo 'todo:created': (payload, state) => { @@ -413,6 +522,10 @@ export const STATE_APPLIERS: Partial> 'todo:deleted': (payload, state) => ({ todoItems: state.todoItems.filter(i => i.id !== payload.itemId), }), + 'todo:reordered': (payload, state) => { + const todoItems = inEventOrder(state.todoItems, payload.orderedIds) + return todoItems ? { todoItems } : {} + }, // Budget 'budget:created': (payload, state) => { @@ -509,6 +622,53 @@ export const STATE_APPLIERS: Partial> // Trip 'trip:updated': payload => ({ trip: payload.trip as Trip }), + // The open trip deleted elsewhere: the planner says so and leaves (tripGone). + // The key is `id`, not the transport's `tripId`. + 'trip:deleted': (payload, state) => { + const tripId = Number(payload.id) + return state.trip?.id === tripId ? { tripGone: { tripId, reason: 'deleted' } } : {} + }, + // Only the removal of this user means anything to the store: the planner then + // leaves the trip. The roster itself is page-local (useTripRosterSync reads it + // again on every member event). A removed guest stays on the rows that list it + // until they are read again: a member taken off keeps those rows on the server, + // and this store cannot tell a guest from a member. + 'member:removed': (payload, state) => { + const me = useAuthStore.getState().user?.id + if (!state.trip || me == null || Number(payload.userId) !== me) return {} + return { tripGone: { tripId: state.trip.id, reason: 'removed' } } + }, + // A guest renamed. The roster itself is read again by useTripRosterSync; the name + // is also stored inside the rows that list people, so it changes there too. + 'member:updated': (payload, state) => { + const member = renamedMember(payload) + if (!member) return {} + const { id, username } = member + return { + budgetItems: patchRows(state.budgetItems, item => { + const members = withName(item.members, id, username) + return members === item.members ? item : { ...item, members } + }), + reservations: patchRows(state.reservations, reservation => { + const travelers = withName(reservation.travelers, id, username) + return travelers === reservation.travelers ? reservation : { ...reservation, travelers } + }), + packingItems: patchRows(state.packingItems, item => { + const recipients = withName(item.recipients, id, username) + const contributors = withName(item.contributors, id, username) + return recipients === item.recipients && contributors === item.contributors + ? item + : { ...item, recipients, contributors } + }), + assignments: Object.fromEntries(Object.entries(state.assignments).map(([dayKey, items]) => [ + dayKey, + patchRows(items, assignment => { + const participants = withName(assignment.participants, id, username) + return participants === assignment.participants ? assignment : { ...assignment, participants } + }), + ])), + } + }, // Files 'file:created': (payload, state) => { @@ -529,6 +689,38 @@ export const STATE_APPLIERS: Partial> }, } +/** How long events of one kind are collected before the store reads once. */ +const STORE_REFRESH_SETTLE_MS = 250 +const pendingRefreshes = new Map>() + +/** + * Runs `refresh` once for a burst of events of one kind: one assistant step can + * apply up to fifty changes, every one of them its own event. + */ +function refreshOncePerBurst(kind: string, refresh: () => void): void { + if (pendingRefreshes.has(kind)) return + pendingRefreshes.set(kind, setTimeout(() => { + pendingRefreshes.delete(kind) + refresh() + }, STORE_REFRESH_SETTLE_MS)) +} + +type StoreRefresh = (get: GetState) => void + +/** + * Events whose change the store reads back from the server instead of applying + * it from the payload, keyed by the shared WS event registry like the appliers. + */ +export const STORE_REFRESHES: Partial> = { + // A template applied elsewhere sends its new rows as one bare list, without the + // item events' privacy guarantees (a template applied to "my list" adds items + // only their owner may see). Read the list again through the repo instead. + 'packing:template-applied': get => refreshOncePerBurst('packing', () => { + const tripId = get().trip?.id + if (tripId) void get().reloadPackingItems(tripId) + }), +} + /** * The changes that add, move or take away a booked night: a stay written * elsewhere (the assistant, a fellow traveller, a plugin, a booking import), and @@ -560,6 +752,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket : undefined set(state => STATE_APPLIERS[type as TrekWsTripEventName]?.(payload, state) ?? {}) + STORE_REFRESHES[type as TrekWsTripEventName]?.(get) // Accommodation cards read their thumbnail from a page-local copy of the // place, not from this store, so a new hero image would only appear there diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index 4baaefd84..80c975bbf 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -47,6 +47,13 @@ export interface PlanMapViewport { east: number } +/** The open trip went away under this tab, and why. */ +export interface TripGone { + tripId: number + /** Deleted by someone else, or this user is no longer one of its members. */ + reason: 'deleted' | 'removed' +} + export interface TripStoreState extends PlacesSlice, AssignmentsSlice, @@ -83,6 +90,12 @@ export interface TripStoreState planMapViewport: PlanMapViewport | null isLoading: boolean error: string | null + /** + * Set by the remote handler when the open trip was deleted elsewhere or this + * user was taken off it (or left it in another tab). The planner says so and + * goes back to the trip list; nothing else reads it. + */ + tripGone: TripGone | null setSelectedDay: (dayId: number | null) => void setPlacesFilter: (filter: string) => void @@ -99,6 +112,11 @@ export interface TripStoreState updateTrip: (tripId: number | string, data: Partial & { date_shift_mode?: 'keep_bookings' | 'shift_all' }) => Promise addTag: (data: Partial & { name: string }) => Promise addCategory: (data: Partial & { name: string }) => Promise + /** Reads the user's tags again after they changed elsewhere (`tags:changed`). */ + reloadTags: () => Promise + /** Reads the packing list again, for a change no item event describes (a template applied). */ + reloadPackingItems: (tripId: number | string) => Promise + clearTripGone: () => void } export const useTripStore = create((set, get) => ({ @@ -121,6 +139,7 @@ export const useTripStore = create((set, get) => ({ planMapViewport: null, isLoading: false, error: null, + tripGone: null, setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }), setPlacesFilter: (filter: string) => set({ placesFilter: filter }), @@ -151,8 +170,11 @@ export const useTripStore = create((set, get) => ({ placesRatingFilter: 'all', planMapViewport: null, error: null, + tripGone: null, }), + clearTripGone: () => set({ tripGone: null }), + loadTrip: async (tripId: number | string) => { get().resetTrip() set({ isLoading: true, error: null }) @@ -281,6 +303,28 @@ export const useTripStore = create((set, get) => ({ } }, + // Online only: offline the cached tags are all there is, and the change that + // pinged us is not readable anyway. A failed read keeps the tags on screen. + reloadTags: async () => { + if (!isEffectivelyOnline()) return + try { + const { tags } = await tagsApi.list() + set({ tags }) + } catch { + // The next trip load reads them again. + } + }, + + // Through the repo like loadTrip, so the offline cache follows the new list. + reloadPackingItems: async (tripId: number | string) => { + try { + const { items } = await packingRepo.list(tripId) + if (String(get().trip?.id) === String(tripId)) set({ packingItems: items }) + } catch { + // The list on screen stays; the next reload or reconnect catches up. + } + }, + addCategory: async (data: Partial & { name: string }) => { try { const result = await categoriesApi.create(data) diff --git a/client/src/sync/useServerPing.ts b/client/src/sync/useServerPing.ts index 741a385f2..0e67e2786 100644 --- a/client/src/sync/useServerPing.ts +++ b/client/src/sync/useServerPing.ts @@ -27,13 +27,30 @@ const COALESCE_MS = 250 * re-join after a reconnect, and the return to online. * * `reload` may change identity every render; the latest one is always used. + * `eventName` may also be a list, for a view that reads one thing again after + * any of several events: a burst across all of them is still one refetch. + * + * `rejoin: false` leaves the room re-join out. The server answers every join with + * `joined`, opening a trip included, so a hook that reads account-wide state + * (settings, tags, collections) would read it again on each trip opened. Those + * events go to the user's own sockets, not to a room, so a re-join says nothing + * about them; the return to online still counts. */ -export function useServerPing(eventName: string, enabled: boolean, reload: () => Promise): void { +export function useServerPing( + eventName: string | readonly string[], + enabled: boolean, + reload: () => Promise, + options: { rejoin?: boolean } = {} +): void { const reloadRef = useRef(reload) reloadRef.current = reload + // A string key, so a list written inline does not resubscribe on every render. + const eventKey = typeof eventName === 'string' ? eventName : eventName.join('\n') + const rejoin = options.rejoin ?? true useEffect(() => { if (!enabled) return + const eventNames = new Set(eventKey.split('\n')) let timer: ReturnType | null = null let inFlight = false /** A ping that arrived while a refetch was running: serve it once that lands. */ @@ -65,11 +82,11 @@ export function useServerPing(eventName: string, enabled: boolean, reload: () => } const handler = (event: Record) => { - // 'joined' is the room re-join the socket sends on every reconnect, so it - // is also the moment what is on screen is what was there before the drop: + // 'joined' answers every room join, the re-join after a reconnect included, + // which is the moment what is on screen is what was there before the drop: // whatever moved in the meantime pinged into a closed socket. - if (event.type !== eventName && event.type !== 'joined') return - schedule() + const ping = event.type === 'joined' ? rejoin : eventNames.has(String(event.type)) + if (ping) schedule() } let wasOnline = isEffectivelyOnline() @@ -89,5 +106,5 @@ export function useServerPing(eventName: string, enabled: boolean, reload: () => unsubscribeMode() if (timer) clearTimeout(timer) } - }, [enabled, eventName]) + }, [enabled, eventKey, rejoin]) } diff --git a/client/tests/integration/hooks/useInAppNotificationListener.test.ts b/client/tests/integration/hooks/useInAppNotificationListener.test.ts index 13cfeb0ae..91a7b4d97 100644 --- a/client/tests/integration/hooks/useInAppNotificationListener.test.ts +++ b/client/tests/integration/hooks/useInAppNotificationListener.test.ts @@ -1,5 +1,6 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useAuthStore } from '../../../src/store/authStore'; import { useInAppNotificationStore } from '../../../src/store/inAppNotificationStore'; import { resetAllStores } from '../../helpers/store'; @@ -37,6 +38,10 @@ describe('useInAppNotificationListener', () => { }); it('FE-HOOK-NOTIFLISTENER-001: on mount, addListener is called once', () => { + // Signed out, as resetAllStores leaves it: only the bell listener. Signed in, the + // settings and tags pings add theirs (FE-BCAST-013), and capturedListener below + // would hold the last of them instead. + expect(useAuthStore.getState().isAuthenticated).toBe(false); const { unmount } = renderHook(() => useInAppNotificationListener()); expect(wsMock.addListener).toHaveBeenCalledTimes(1); unmount(); diff --git a/client/tests/unit/remoteEventHandler/broadcastParity.test.ts b/client/tests/unit/remoteEventHandler/broadcastParity.test.ts new file mode 100644 index 000000000..e5dbd1698 --- /dev/null +++ b/client/tests/unit/remoteEventHandler/broadcastParity.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { offlineDb } from '../../../src/db/offlineDb'; +import { useAuthStore } from '../../../src/store/authStore'; +import { useTripStore } from '../../../src/store/tripStore'; +import { + buildAssignment, + buildBudgetItem, + buildDay, + buildPackingItem, + buildReservation, + buildTodoItem, + buildTrip, +} from '../../helpers/factories'; +import { resetAllStores } from '../../helpers/store'; + +/** + * The trip-scoped events the broadcast parity work taught the store (plan 2.8): + * changes written elsewhere (a fellow traveller, the assistant, an MCP client) + * that used to be dropped and only showed after a reload. + */ + +const ME = 7; + +beforeEach(() => { + resetAllStores(); + useAuthStore.setState({ user: { id: ME, username: 'me', email: 'me@example.test' } as never }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +const send = (event: Record) => useTripStore.getState().handleRemoteEvent(event as { type: string }); + +describe('remoteEventHandler > broadcast parity', () => { + it('FE-BCAST-001: assignment:participants sets who joins the visit and caches its day', async () => { + const put = vi.spyOn(offlineDb.days, 'put'); + useTripStore.setState({ + days: [buildDay({ id: 10 }), buildDay({ id: 20 })], + assignments: { + '10': [buildAssignment({ id: 100, day_id: 10 })], + '20': [buildAssignment({ id: 200, day_id: 20 }), buildAssignment({ id: 201, day_id: 20 })], + }, + }); + const participants = [{ user_id: 3, username: 'ana', avatar: null }]; + send({ type: 'assignment:participants', assignmentId: 201, participants }); + const { assignments } = useTripStore.getState(); + expect(assignments['20'].find((a) => a.id === 201)?.participants).toEqual(participants); + expect(assignments['20'].find((a) => a.id === 200)?.participants).not.toEqual(participants); + expect(assignments['10'][0].participants).not.toEqual(participants); + await vi.waitFor(() => expect(put).toHaveBeenCalledTimes(1)); + expect(put.mock.calls[0][0]).toMatchObject({ id: 20 }); + }); + + it('FE-BCAST-002: assignment:participants for an assignment this tab does not hold changes nothing', () => { + const before = { '10': [buildAssignment({ id: 100, day_id: 10 })] }; + useTripStore.setState({ days: [buildDay({ id: 10 })], assignments: before }); + send({ type: 'assignment:participants', assignmentId: 999, participants: [] }); + expect(useTripStore.getState().assignments).toBe(before); + }); + + it('FE-BCAST-003: packing:reordered numbers the named rows as the server does and caches them', async () => { + const bulkPut = vi.spyOn(offlineDb.packingItems, 'bulkPut'); + useTripStore.setState({ + packingItems: [ + buildPackingItem({ id: 1, sort_order: 0 }), + buildPackingItem({ id: 2, sort_order: 1 }), + buildPackingItem({ id: 3, sort_order: 2 }), + ], + }); + // 404 is a row this tab does not hold (somebody's private item): it still takes + // index 1 on the server, so item 1 lands on 2. Item 2 is not named and keeps its order. + send({ type: 'packing:reordered', orderedIds: [3, 404, 1] }); + const items = useTripStore.getState().packingItems; + expect(items.map((i) => [i.id, i.sort_order])).toEqual([ + [3, 0], + [1, 2], + [2, 1], + ]); + await vi.waitFor(() => expect(bulkPut).toHaveBeenCalledTimes(1)); + expect(bulkPut.mock.calls[0][0]).toEqual(items); + }); + + it('FE-BCAST-004: todo:reordered applies the order the same way and caches it', async () => { + const bulkPut = vi.spyOn(offlineDb.todoItems, 'bulkPut'); + useTripStore.setState({ + todoItems: [buildTodoItem({ id: 1, sort_order: 0 }), buildTodoItem({ id: 2, sort_order: 1 })], + }); + send({ type: 'todo:reordered', orderedIds: [2, 1] }); + const items = useTripStore.getState().todoItems; + expect(items.map((i) => [i.id, i.sort_order])).toEqual([ + [2, 0], + [1, 1], + ]); + await vi.waitFor(() => expect(bulkPut).toHaveBeenCalledTimes(1)); + expect(bulkPut.mock.calls[0][0]).toEqual(items); + }); + + it('FE-BCAST-005: a reorder without ids leaves the list alone', () => { + const packingItems = [buildPackingItem({ id: 1 })]; + useTripStore.setState({ packingItems }); + send({ type: 'packing:reordered' }); + expect(useTripStore.getState().packingItems).toBe(packingItems); + }); + + it('FE-BCAST-006: trip:deleted of the open trip marks it gone, another trip does not', async () => { + const drop = vi.spyOn(offlineDb.trips, 'delete'); + useTripStore.setState({ trip: buildTrip({ id: 5 }) }); + send({ type: 'trip:deleted', tripId: 6, id: 6 }); + expect(useTripStore.getState().tripGone).toBeNull(); + send({ type: 'trip:deleted', tripId: 5, id: 5 }); + expect(useTripStore.getState().tripGone).toEqual({ tripId: 5, reason: 'deleted' }); + await vi.waitFor(() => expect(drop).toHaveBeenCalledWith(5)); + }); + + it('FE-BCAST-007: member:removed marks the trip gone only when it is this user', () => { + useTripStore.setState({ trip: buildTrip({ id: 5 }) }); + send({ type: 'member:removed', tripId: 5, userId: 99 }); + expect(useTripStore.getState().tripGone).toBeNull(); + send({ type: 'member:removed', tripId: 5, userId: ME }); + expect(useTripStore.getState().tripGone).toEqual({ tripId: 5, reason: 'removed' }); + }); + + it('FE-BCAST-025: member:updated renames the guest on every row that lists it and caches those rows', async () => { + const putBudget = vi.spyOn(offlineDb.budgetItems, 'bulkPut'); + const putDay = vi.spyOn(offlineDb.days, 'put'); + const guest = { user_id: 3, username: 'Ana' }; + const other = { user_id: 4, username: 'Bo' }; + const untouched = buildBudgetItem({ id: 61, members: [{ ...other, paid: 0 }] }); + useTripStore.setState({ + days: [buildDay({ id: 10 })], + budgetItems: [buildBudgetItem({ id: 60, members: [{ ...guest, paid: 0 }, { ...other, paid: 1 }] }), untouched], + reservations: [buildReservation({ id: 70, travelers: [guest] })], + packingItems: [buildPackingItem({ id: 80, recipients: [guest], contributors: [{ ...guest, status: 'packed' }] })], + assignments: { '10': [buildAssignment({ id: 100, day_id: 10, participants: [guest] })] }, + }); + send({ type: 'member:updated', tripId: 5, member: { id: 3, username: 'Anna' } }); + const state = useTripStore.getState(); + expect(state.budgetItems[0].members?.map((m) => m.username)).toEqual(['Anna', 'Bo']); + expect(state.budgetItems[1]).toBe(untouched); + expect(state.reservations[0].travelers?.[0].username).toBe('Anna'); + expect(state.packingItems[0].recipients?.[0].username).toBe('Anna'); + expect(state.packingItems[0].contributors?.[0]).toEqual({ user_id: 3, username: 'Anna', status: 'packed' }); + expect(state.assignments['10'][0].participants?.[0].username).toBe('Anna'); + await vi.waitFor(() => expect(putDay).toHaveBeenCalledTimes(1)); + expect(putBudget.mock.calls[0][0]).toEqual([state.budgetItems[0]]); + }); + + it('FE-BCAST-026: only this user's removal drops the open trip from the offline cache', async () => { + const drop = vi.spyOn(offlineDb.trips, 'delete'); + useTripStore.setState({ trip: buildTrip({ id: 5 }) }); + send({ type: 'member:removed', tripId: 5, userId: 99 }); + send({ type: 'member:removed', tripId: 5, userId: ME }); + await vi.waitFor(() => expect(drop).toHaveBeenCalledWith(5)); + expect(drop).toHaveBeenCalledTimes(1); + }); + + it('FE-BCAST-008: loading another trip forgets a trip that went away', () => { + useTripStore.setState({ tripGone: { tripId: 5, reason: 'removed' } }); + useTripStore.getState().resetTrip(); + expect(useTripStore.getState().tripGone).toBeNull(); + }); + + it('FE-BCAST-009: a burst of packing:template-applied reads the list once, after it settles', () => { + vi.useFakeTimers(); + const reload = vi.fn(async () => undefined); + useTripStore.setState({ trip: buildTrip({ id: 5 }), reloadPackingItems: reload }); + for (let i = 0; i < 5; i++) send({ type: 'packing:template-applied', tripId: 5, items: [] }); + expect(reload).not.toHaveBeenCalled(); + vi.advanceTimersByTime(300); + expect(reload).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledWith(5); + }); +}); diff --git a/client/tests/unit/remoteEventHandler/broadcastReloads.test.tsx b/client/tests/unit/remoteEventHandler/broadcastReloads.test.tsx new file mode 100644 index 000000000..f69aefd91 --- /dev/null +++ b/client/tests/unit/remoteEventHandler/broadcastReloads.test.tsx @@ -0,0 +1,271 @@ +import { act, renderHook } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { journeyApi, packingApi } from '../../../src/api/client'; +import { collectionsApi } from '../../../src/api/collections'; +import { addListener, removeListener } from '../../../src/api/websocket'; +import { useSaveToCollection } from '../../../src/components/Collections/useSaveToCollection'; +import { useBagTotalsPing } from '../../../src/components/Packing/useBagTotalsPing'; +import { usePackingCategoryAssignees } from '../../../src/components/Packing/usePackingCategoryAssignees'; +import { useInAppNotificationListener } from '../../../src/hooks/useInAppNotificationListener'; +import { useJourney } from '../../../src/pages/journey/useJourney'; +import { useTripRosterSync } from '../../../src/pages/tripPlanner/useTripRosterSync'; +import { useAuthStore } from '../../../src/store/authStore'; +import { useInAppNotificationStore, type InAppNotification } from '../../../src/store/inAppNotificationStore'; +import { useJourneyStore } from '../../../src/store/journeyStore'; +import { useSaveToCollectionStore } from '../../../src/store/saveToCollectionStore'; +import { useSettingsStore } from '../../../src/store/settingsStore'; +import { useTripStore } from '../../../src/store/tripStore'; +import { resetAllStores } from '../../helpers/store'; + +/** + * The views that read their data again after a change written elsewhere (plan + * 2.8): every one folds a burst into one read, because one assistant step can + * apply up to fifty changes, each its own event. + */ + +const nav = vi.hoisted(() => ({ navigate: vi.fn() })); +const toast = vi.hoisted(() => ({ info: vi.fn(), error: vi.fn(), success: vi.fn(), warning: vi.fn() })); + +vi.mock('react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => nav.navigate, +})); +vi.mock('../../../src/components/shared/Toast', () => ({ useToast: () => toast })); + +/** A bell row as the notification store holds it. */ +function note(id: number, isRead: boolean): InAppNotification { + return { + id, + type: 'simple', + scope: 'trip', + target: 5, + sender_id: null, + sender_username: null, + sender_avatar: null, + recipient_id: 7, + title_key: 'notif.title', + title_params: {}, + text_key: 'notif.text', + text_params: {}, + positive_text_key: null, + negative_text_key: null, + response: null, + navigate_text_key: null, + navigate_target: null, + is_read: isRead, + created_at: '2026-10-01T09:00:00Z', + }; +} + +/** The same row as the server sends it, read state as a number. */ +const wire = (id: number, isRead: boolean) => ({ ...note(id, isRead), is_read: Number(isRead) }) as never; + +/** Deliver an event to every listener still registered, the way the socket layer does. */ +function emit(event: Record): void { + const removed = new Set(vi.mocked(removeListener).mock.calls.map(([fn]) => fn)); + for (const [fn] of vi.mocked(addListener).mock.calls) if (!removed.has(fn)) fn(event); +} + +/** Past the 250ms window useServerPing collects a burst in. */ +async function settle(): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); +} + +beforeEach(() => { + resetAllStores(); + vi.mocked(addListener).mockClear(); + vi.mocked(removeListener).mockClear(); + nav.navigate.mockClear(); + toast.info.mockClear(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('broadcast reloads', () => { + it('FE-BCAST-010: the bag events and the totals ping of one burst are one read of the bags', async () => { + const reload = vi.fn(async () => {}); + renderHook(() => useBagTotalsPing(true, reload)); + act(() => { + emit({ type: 'packing:bag-created', bag: { id: 1 } }); + emit({ type: 'packing:bag-members-updated', bagId: 1, members: [] }); + emit({ type: 'packing:bag-updated', bag: { id: 1 } }); + emit({ type: 'packing:bag-totals' }); + emit({ type: 'packing:bag-deleted', bagId: 2 }); + emit({ type: 'packing:created', item: { id: 9 } }); + }); + await settle(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('FE-BCAST-011: category assignees set elsewhere are read again', async () => { + const get = vi + .spyOn(packingApi, 'getCategoryAssignees') + .mockResolvedValueOnce({ assignees: {} }) + .mockResolvedValueOnce({ assignees: { Camp: [{ user_id: 3, username: 'anna' }] } }); + const { result } = renderHook(() => + usePackingCategoryAssignees({ tripId: 7, t: (k: string) => k, toast: { error: vi.fn() } }) + ); + await settle(); + act(() => { + emit({ type: 'packing:assignees', category: 'Camp', assignees: [] }); + emit({ type: 'packing:assignees', category: 'Food', assignees: [] }); + }); + await settle(); + expect(get).toHaveBeenCalledTimes(2); + expect(result.current.categoryAssignees).toEqual({ Camp: [{ user_id: 3, username: 'anna' }] }); + }); + + it('FE-BCAST-012: a notification read or unread elsewhere moves the unread count', () => { + const fetchUnreadCount = vi.fn(async () => {}); + useInAppNotificationStore.setState({ + notifications: [note(1, false), note(2, true)], + unreadCount: 1, + fetchUnreadCount, + }); + const store = useInAppNotificationStore.getState(); + store.handleUpdatedNotification(wire(1, true)); + expect(useInAppNotificationStore.getState().unreadCount).toBe(0); + store.handleUpdatedNotification(wire(2, false)); + store.handleUpdatedNotification(wire(1, false)); + expect(useInAppNotificationStore.getState().unreadCount).toBe(2); + // One this tab never loaded says nothing about its old state: the count is asked for. + store.handleUpdatedNotification(wire(77, true)); + expect(fetchUnreadCount).toHaveBeenCalledTimes(1); + expect(useInAppNotificationStore.getState().unreadCount).toBe(2); + }); + + it('FE-BCAST-013: notification:read-all clears the bell, settings and tags are read again once per burst', async () => { + const loadSettings = vi.fn(async () => {}); + const reloadTags = vi.fn(async () => {}); + useAuthStore.setState({ isAuthenticated: true }); + useSettingsStore.setState({ loadSettings }); + useTripStore.setState({ reloadTags }); + useInAppNotificationStore.setState({ + notifications: [note(1, false), note(2, false)], + unreadCount: 2, + }); + renderHook(() => useInAppNotificationListener()); + act(() => { + emit({ type: 'notification:read-all' }); + emit({ type: 'settings:updated', keys: ['dark_mode'] }); + emit({ type: 'settings:updated', keys: ['week_start'] }); + emit({ type: 'tags:changed' }); + emit({ type: 'tags:changed' }); + }); + const bell = useInAppNotificationStore.getState(); + expect(bell.unreadCount).toBe(0); + expect(bell.notifications.every((n) => n.is_read)).toBe(true); + await settle(); + expect(loadSettings).toHaveBeenCalledTimes(1); + expect(reloadTags).toHaveBeenCalledTimes(1); + }); + + it('FE-BCAST-014: member events read the roster again, once per burst', async () => { + const refresh = vi.fn(async () => {}); + renderHook(() => useTripRosterSync(5, refresh)); + act(() => { + emit({ type: 'member:added', tripId: 5, member: { id: 3 } }); + emit({ type: 'member:updated', tripId: 5, member: { id: 3, username: 'Ana' } }); + emit({ type: 'member:removed', tripId: 5, userId: 3 }); + }); + await settle(); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('FE-BCAST-015: a trip that went away is left for the trip list with a notice', () => { + renderHook(() => useTripRosterSync(5, async () => {})); + act(() => useTripStore.setState({ tripGone: { tripId: 6, reason: 'deleted' } })); + expect(nav.navigate).not.toHaveBeenCalled(); + act(() => useTripStore.setState({ tripGone: { tripId: 5, reason: 'removed' } })); + expect(toast.info).toHaveBeenCalledWith('trip.toast.accessLost'); + expect(nav.navigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + expect(useTripStore.getState().tripGone).toBeNull(); + }); + + it('FE-BCAST-016: a trip deleted elsewhere says so', () => { + renderHook(() => useTripRosterSync(5, async () => {})); + act(() => useTripStore.setState({ tripGone: { tripId: 5, reason: 'deleted' } })); + expect(toast.info).toHaveBeenCalledWith('dashboard.toast.deleted'); + expect(nav.navigate).toHaveBeenCalledTimes(1); + }); + + it('FE-BCAST-019: a place saved elsewhere re-checks the membership and the inspector bookmark once', async () => { + vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [], incomingInvites: [] } as never); + const membership = vi.spyOn(collectionsApi, 'membership').mockResolvedValue({ saved: false, lists: [] } as never); + useSaveToCollectionStore.getState().open({ name: 'Louvre', lat: 48.86, lng: 2.34 }); + renderHook(() => useSaveToCollection()); + await settle(); + membership.mockClear(); + const version = useSaveToCollectionStore.getState().version; + act(() => { + for (let i = 0; i < 3; i++) emit({ type: 'collections:updated', collectionId: 4 }); + emit({ type: 'collections:invite', collectionId: 5, from: { id: 2, username: 'bo' } }); + }); + await settle(); + expect(membership).toHaveBeenCalledTimes(1); + expect(useSaveToCollectionStore.getState().version).toBe(version + 1); + }); + + it('FE-BCAST-021: the journey list reads itself again after journeys change elsewhere, once per burst', async () => { + vi.spyOn(journeyApi, 'suggestions').mockResolvedValue({ trips: [] } as never); + const loadJourneys = vi.fn(async () => {}); + useJourneyStore.setState({ loadJourneys }); + renderHook(() => useJourney(), { wrapper: MemoryRouter }); + await settle(); + loadJourneys.mockClear(); + act(() => { + emit({ type: 'journey:updated', journeyId: 3 }); + emit({ type: 'journey:deleted', journeyId: 4 }); + emit({ type: 'journey:entry:created', journeyId: 3, entry: { id: 8 } }); + }); + await settle(); + expect(loadJourneys).toHaveBeenCalledTimes(1); + }); + + it('FE-BCAST-029: a contributor taken off a journey elsewhere reads the journey list again', async () => { + vi.spyOn(journeyApi, 'suggestions').mockResolvedValue({ trips: [] } as never); + const loadJourneys = vi.fn(async () => {}); + useJourneyStore.setState({ loadJourneys }); + renderHook(() => useJourney(), { wrapper: MemoryRouter }); + await settle(); + loadJourneys.mockClear(); + act(() => emit({ type: 'journey:contributor:changed', journeyId: 3, targetUserId: 2, role: null })); + await settle(); + expect(loadJourneys).toHaveBeenCalledTimes(1); + }); + + it('FE-BCAST-023: opening a trip (a room join) re-reads trip data, never the account-wide state', async () => { + const loadSettings = vi.fn(async () => {}); + const reloadTags = vi.fn(async () => {}); + const refresh = vi.fn(async () => {}); + useAuthStore.setState({ isAuthenticated: true }); + useSettingsStore.setState({ loadSettings }); + useTripStore.setState({ reloadTags }); + renderHook(() => useInAppNotificationListener()); + renderHook(() => useSaveToCollection()); + renderHook(() => useTripRosterSync(5, refresh)); + const version = useSaveToCollectionStore.getState().version; + act(() => emit({ type: 'joined', tripId: 5 })); + await settle(); + expect(refresh).toHaveBeenCalledTimes(1); + expect(loadSettings).not.toHaveBeenCalled(); + expect(reloadTags).not.toHaveBeenCalled(); + expect(useSaveToCollectionStore.getState().version).toBe(version); + }); + + it('FE-BCAST-028: accepting an invite to a list elsewhere re-checks the bookmark too', async () => { + renderHook(() => useSaveToCollection()); + const version = useSaveToCollectionStore.getState().version; + act(() => emit({ type: 'collections:accepted', collectionId: 4 })); + await settle(); + expect(useSaveToCollectionStore.getState().version).toBe(version + 1); + }); +}); diff --git a/client/tests/unit/remoteEventHandler/registry-parity.test.ts b/client/tests/unit/remoteEventHandler/registry-parity.test.ts index 8ea817774..a98318b47 100644 --- a/client/tests/unit/remoteEventHandler/registry-parity.test.ts +++ b/client/tests/unit/remoteEventHandler/registry-parity.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { TREK_WS_EVENT_NAMES, TREK_WS_TRIP_EVENT_NAMES } from '@trek/shared'; -import { DEXIE_WRITERS, STATE_APPLIERS } from '../../../src/store/slices/remoteEventHandler'; +import { DEXIE_WRITERS, STATE_APPLIERS, STORE_REFRESHES } from '../../../src/store/slices/remoteEventHandler'; import { AWAITING_SENDER_WS_EVENTS, HANDLED_OUTSIDE_TRIP_STORE, IGNORED_WS_EVENTS } from '../../../src/api/wsEventPolicy'; import { useTripStore } from '../../../src/store/tripStore'; import { resetAllStores } from '../../helpers/store'; @@ -10,16 +10,18 @@ import { buildPackingItem, buildPlace } from '../../helpers/factories'; * The exhaustiveness ratchet (roadmap: WS event contract registry). Every * event in the shared registry must be classified on the client — applied by * the tripStore reducer, consumed by a dedicated listener, explicitly - * ignored, or registered ahead of the write that will send it. A server-side event added without a client decision turns this - * test red instead of vanishing into the old silent `default:` branches. + * ignored with a reason, or registered ahead of the write that will send it. + * A server-side event added without a client decision turns this test red + * instead of vanishing into the old silent `default:` branches. */ const handledInStore = new Set([ ...Object.keys(STATE_APPLIERS), ...Object.keys(DEXIE_WRITERS), + ...Object.keys(STORE_REFRESHES), ]); const handledElsewhere = new Set(HANDLED_OUTSIDE_TRIP_STORE); -const ignored = new Set(IGNORED_WS_EVENTS); +const ignored = new Set(Object.keys(IGNORED_WS_EVENTS)); const awaitingSender = new Set(AWAITING_SENDER_WS_EVENTS); beforeEach(() => { @@ -80,9 +82,9 @@ describe('remoteEventHandler > registry parity', () => { it('FE-WSEVT-REGISTRY-006: an explicitly ignored event leaves the store untouched', () => { const items = [buildPackingItem({ id: 1 }), buildPackingItem({ id: 2 })]; useTripStore.setState({ packingItems: items, places: [buildPlace({ id: 5 })] }); - // 'packing:reordered' is on IGNORED_WS_EVENTS — the reducer must swallow it - // instead of throwing or clearing the slice. - useTripStore.getState().handleRemoteEvent({ type: 'packing:reordered', orderedIds: [2, 1] }); + // 'todo:assignees' is on IGNORED_WS_EVENTS: the reducer must swallow it + // instead of throwing or clearing a slice. + useTripStore.getState().handleRemoteEvent({ type: 'todo:assignees', category: 'Docs', assignees: [] }); const state = useTripStore.getState(); expect(state.packingItems.map(i => i.id)).toEqual([1, 2]); expect(state.places.map(p => p.id)).toEqual([5]); @@ -95,17 +97,17 @@ describe('remoteEventHandler > registry parity', () => { }); it('FE-WSEVT-REGISTRY-008: the events awaiting a sender only shrink', () => { - // Registered by the broadcast parity work ahead of their writes. A new event - // gets a handler or a deliberate IGNORED_WS_EVENTS slot, never a place here. - const registeredAhead = [ - 'member:updated', - 'todo:reordered', - 'trips:changed', - 'settings:updated', - 'tags:changed', - 'notification:read-all', - 'atlas:changed', - ]; - expect(AWAITING_SENDER_WS_EVENTS.filter(name => !registeredAhead.includes(name))).toEqual([]); + // Every event registered ahead of its write by the broadcast parity work has + // a handler now. A new event gets a handler or a deliberate IGNORED_WS_EVENTS + // slot, never a place here. + expect(AWAITING_SENDER_WS_EVENTS).toEqual([]); + }); + + it('FE-WSEVT-REGISTRY-009: the ignored events only shrink, and each says why', () => { + // Only the todo category assignees stay ignored: no screen shows them. A key + // added here is a product decision, so this list is edited by hand with it. + const allowed = ['todo:assignees']; + expect(Object.keys(IGNORED_WS_EVENTS).filter(name => !allowed.includes(name))).toEqual([]); + for (const reason of Object.values(IGNORED_WS_EVENTS)) expect(reason?.length ?? 0).toBeGreaterThan(20); }); }); diff --git a/shared/src/i18n/ar/trip.ts b/shared/src/i18n/ar/trip.ts index 3faf0509d..f55bba458 100644 --- a/shared/src/i18n/ar/trip.ts +++ b/shared/src/i18n/ar/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'يرجى اختيار يوم أولًا', 'trip.toast.assignedToDay': 'تم إسناد المكان إلى اليوم', 'trip.toast.loadError': 'تعذر تحميل الرحلة', + 'trip.toast.accessLost': 'لم تعد عضوًا في هذه الرحلة', 'trip.toast.reorderError': 'فشل إعادة الترتيب', 'trip.toast.reservationUpdated': 'تم تحديث الحجز', 'trip.toast.reservationAdded': 'تمت إضافة الحجز', diff --git a/shared/src/i18n/az/trip.ts b/shared/src/i18n/az/trip.ts index 5aa34a905..1eaa5fce6 100644 --- a/shared/src/i18n/az/trip.ts +++ b/shared/src/i18n/az/trip.ts @@ -29,6 +29,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Əvvəlcə bir gün seçin', 'trip.toast.assignedToDay': 'Məkan günə təyin edildi', 'trip.toast.loadError': 'Səyahəti yükləmək mümkün olmadı', + 'trip.toast.accessLost': 'Artıq bu səyahətin üzvü deyilsiniz', 'trip.toast.reorderError': 'Sıralamanı dəyişmək mümkün olmadı', 'trip.toast.reservationUpdated': 'Rezervasiya yeniləndi', 'trip.toast.reservationAdded': 'Rezervasiya əlavə edildi', diff --git a/shared/src/i18n/br/trip.ts b/shared/src/i18n/br/trip.ts index 0b85c71e7..becb0172d 100644 --- a/shared/src/i18n/br/trip.ts +++ b/shared/src/i18n/br/trip.ts @@ -22,6 +22,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Selecione um dia primeiro', 'trip.toast.assignedToDay': 'Lugar atribuído ao dia', 'trip.toast.loadError': 'Não foi possível carregar a viagem', + 'trip.toast.accessLost': 'Você não é mais membro desta viagem', 'trip.toast.reorderError': 'Falha ao reordenar', 'trip.toast.reservationUpdated': 'Reserva atualizada', 'trip.toast.reservationAdded': 'Reserva adicionada', diff --git a/shared/src/i18n/ca/trip.ts b/shared/src/i18n/ca/trip.ts index c11120f31..83471e2f6 100644 --- a/shared/src/i18n/ca/trip.ts +++ b/shared/src/i18n/ca/trip.ts @@ -22,6 +22,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Selecciona un dia primer', 'trip.toast.assignedToDay': 'Lloc assignat al dia', 'trip.toast.loadError': "No s'ha pogut carregar el viatge", + 'trip.toast.accessLost': "Ja no ets membre d'aquest viatge", 'trip.toast.reorderError': "No s'ha pogut reordenar", 'trip.toast.reservationUpdated': 'Reserva actualitzada', 'trip.toast.reservationAdded': 'Reserva afegida', diff --git a/shared/src/i18n/cs/trip.ts b/shared/src/i18n/cs/trip.ts index fd0533493..803441b49 100644 --- a/shared/src/i18n/cs/trip.ts +++ b/shared/src/i18n/cs/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Prosím nejdříve vyberte den', 'trip.toast.assignedToDay': 'Místo bylo přiřazeno ke dni', 'trip.toast.loadError': 'Cestu se nepodařilo načíst', + 'trip.toast.accessLost': 'Už nejste členem této cesty', 'trip.toast.reorderError': 'Nepodařilo se změnit pořadí', 'trip.toast.reservationUpdated': 'Rezervace aktualizována', 'trip.toast.reservationAdded': 'Rezervace přidána', diff --git a/shared/src/i18n/de/trip.ts b/shared/src/i18n/de/trip.ts index 418d8e3df..984ee0730 100644 --- a/shared/src/i18n/de/trip.ts +++ b/shared/src/i18n/de/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Bitte wähle zuerst einen Tag aus', 'trip.toast.assignedToDay': 'Ort wurde dem Tag zugewiesen', 'trip.toast.loadError': 'Reise konnte nicht geladen werden', + 'trip.toast.accessLost': 'Du bist kein Mitglied dieser Reise mehr', 'trip.toast.reorderError': 'Fehler beim Sortieren', 'trip.toast.reservationUpdated': 'Reservierung aktualisiert', 'trip.toast.reservationAdded': 'Reservierung hinzugefügt', diff --git a/shared/src/i18n/en/trip.ts b/shared/src/i18n/en/trip.ts index 5186458ed..4c8570ca5 100644 --- a/shared/src/i18n/en/trip.ts +++ b/shared/src/i18n/en/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Please select a day first', 'trip.toast.assignedToDay': 'Place assigned to day', 'trip.toast.loadError': 'Could not load the trip', + 'trip.toast.accessLost': 'You are no longer a member of this trip', 'trip.toast.reorderError': 'Failed to reorder', 'trip.toast.reservationUpdated': 'Reservation updated', 'trip.toast.reservationAdded': 'Reservation added', diff --git a/shared/src/i18n/es/trip.ts b/shared/src/i18n/es/trip.ts index 6f82e7a12..313c545e4 100644 --- a/shared/src/i18n/es/trip.ts +++ b/shared/src/i18n/es/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Selecciona primero un día', 'trip.toast.assignedToDay': 'Lugar asignado al día', 'trip.toast.loadError': 'No se pudo cargar el viaje', + 'trip.toast.accessLost': 'Ya no eres miembro de este viaje', 'trip.toast.reorderError': 'No se pudo reordenar', 'trip.toast.reservationUpdated': 'Reserva actualizada', 'trip.toast.reservationAdded': 'Reserva añadida', diff --git a/shared/src/i18n/et/trip.ts b/shared/src/i18n/et/trip.ts index cd03f735a..b3cfe2f6b 100644 --- a/shared/src/i18n/et/trip.ts +++ b/shared/src/i18n/et/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Vali esmalt päev', 'trip.toast.assignedToDay': 'Koht lisatud päevale', 'trip.toast.loadError': 'Reisi laadimine ebaõnnestus', + 'trip.toast.accessLost': 'Sa ei ole enam selle reisi liige', 'trip.toast.reorderError': 'Järjekorra muutmine ebaõnnestus', 'trip.toast.reservationUpdated': 'Broneering uuendatud', 'trip.toast.reservationAdded': 'Broneering lisatud', diff --git a/shared/src/i18n/fr/trip.ts b/shared/src/i18n/fr/trip.ts index cb07abd54..b0034612c 100644 --- a/shared/src/i18n/fr/trip.ts +++ b/shared/src/i18n/fr/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': "Veuillez d'abord sélectionner un jour", 'trip.toast.assignedToDay': 'Lieu attribué au planning', 'trip.toast.loadError': 'Impossible de charger le voyage', + 'trip.toast.accessLost': "Vous n'êtes plus membre de ce voyage", 'trip.toast.reorderError': 'Échec de la réorganisation', 'trip.toast.reservationUpdated': 'Réservation mise à jour', 'trip.toast.reservationAdded': 'Réservation ajoutée', diff --git a/shared/src/i18n/gr/trip.ts b/shared/src/i18n/gr/trip.ts index 9252a8037..f831253ed 100644 --- a/shared/src/i18n/gr/trip.ts +++ b/shared/src/i18n/gr/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Παρακαλώ επιλέξτε πρώτα μια ημέρα', 'trip.toast.assignedToDay': 'Το μέρος ανατέθηκε στην ημέρα', 'trip.toast.loadError': 'Δεν ήταν δυνατή η φόρτωση του ταξιδιού', + 'trip.toast.accessLost': 'Δεν είστε πλέον μέλος αυτού του ταξιδιού', 'trip.toast.reorderError': 'Η αναδιάταξη απέτυχε', 'trip.toast.reservationUpdated': 'Η κράτηση ενημερώθηκε', 'trip.toast.reservationAdded': 'Η κράτηση προστέθηκε', diff --git a/shared/src/i18n/hu/trip.ts b/shared/src/i18n/hu/trip.ts index d71c14bbc..9911d683a 100644 --- a/shared/src/i18n/hu/trip.ts +++ b/shared/src/i18n/hu/trip.ts @@ -22,6 +22,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Kérjük, először válassz egy napot', 'trip.toast.assignedToDay': 'Hely hozzárendelve a naphoz', 'trip.toast.loadError': 'Az utazást nem sikerült betölteni', + 'trip.toast.accessLost': 'Már nem vagy tagja ennek az utazásnak', 'trip.toast.reorderError': 'Nem sikerült átrendezni', 'trip.toast.reservationUpdated': 'Foglalás frissítve', 'trip.toast.reservationAdded': 'Foglalás hozzáadva', diff --git a/shared/src/i18n/id/trip.ts b/shared/src/i18n/id/trip.ts index 756c1cc22..4e67613ee 100644 --- a/shared/src/i18n/id/trip.ts +++ b/shared/src/i18n/id/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Pilih hari terlebih dahulu', 'trip.toast.assignedToDay': 'Tempat ditambahkan ke hari', 'trip.toast.loadError': 'Tidak dapat memuat perjalanan', + 'trip.toast.accessLost': 'Anda bukan lagi anggota perjalanan ini', 'trip.toast.reorderError': 'Gagal mengurutkan ulang', 'trip.toast.reservationUpdated': 'Reservasi diperbarui', 'trip.toast.reservationAdded': 'Reservasi ditambahkan', diff --git a/shared/src/i18n/it/trip.ts b/shared/src/i18n/it/trip.ts index 1544c78f9..7d2010559 100644 --- a/shared/src/i18n/it/trip.ts +++ b/shared/src/i18n/it/trip.ts @@ -22,6 +22,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Seleziona prima un giorno', 'trip.toast.assignedToDay': 'Luogo assegnato al giorno', 'trip.toast.loadError': 'Impossibile caricare il viaggio', + 'trip.toast.accessLost': 'Non sei più membro di questo viaggio', 'trip.toast.reorderError': 'Impossibile riordinare', 'trip.toast.reservationUpdated': 'Prenotazione aggiornata', 'trip.toast.reservationAdded': 'Prenotazione aggiunta', diff --git a/shared/src/i18n/ja/trip.ts b/shared/src/i18n/ja/trip.ts index 2392e08d9..adf00b0c4 100644 --- a/shared/src/i18n/ja/trip.ts +++ b/shared/src/i18n/ja/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'まず日を選択してください', 'trip.toast.assignedToDay': '場所を日に割り当てました', 'trip.toast.loadError': '旅行を読み込めませんでした', + 'trip.toast.accessLost': 'この旅行のメンバーではなくなりました', 'trip.toast.reorderError': '並び替えに失敗しました', 'trip.toast.reservationUpdated': '予約を更新しました', 'trip.toast.reservationAdded': '予約を追加しました', diff --git a/shared/src/i18n/ko/trip.ts b/shared/src/i18n/ko/trip.ts index 84eb0ed41..45aff1f97 100644 --- a/shared/src/i18n/ko/trip.ts +++ b/shared/src/i18n/ko/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': '먼저 날을 선택하세요', 'trip.toast.assignedToDay': '장소가 날에 배정되었습니다', 'trip.toast.loadError': '여행을 불러오지 못했습니다', + 'trip.toast.accessLost': '더 이상 이 여행의 멤버가 아닙니다', 'trip.toast.reorderError': '순서 변경 실패', 'trip.toast.reservationUpdated': '예약이 업데이트되었습니다', 'trip.toast.reservationAdded': '예약이 추가되었습니다', diff --git a/shared/src/i18n/nl/trip.ts b/shared/src/i18n/nl/trip.ts index 847f6e8eb..f00f0f6dc 100644 --- a/shared/src/i18n/nl/trip.ts +++ b/shared/src/i18n/nl/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Selecteer eerst een dag', 'trip.toast.assignedToDay': 'Plaats toegewezen aan dag', 'trip.toast.loadError': 'Kan de reis niet laden', + 'trip.toast.accessLost': 'Je bent geen lid meer van deze reis', 'trip.toast.reorderError': 'Herordenen mislukt', 'trip.toast.reservationUpdated': 'Reservering bijgewerkt', 'trip.toast.reservationAdded': 'Reservering toegevoegd', diff --git a/shared/src/i18n/pl/trip.ts b/shared/src/i18n/pl/trip.ts index d36924d24..330853427 100644 --- a/shared/src/i18n/pl/trip.ts +++ b/shared/src/i18n/pl/trip.ts @@ -22,6 +22,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Proszę najpierw wybrać dzień', 'trip.toast.assignedToDay': 'Miejsce przypisane do dnia', 'trip.toast.loadError': 'Nie udało się wczytać podróży', + 'trip.toast.accessLost': 'Nie jesteś już członkiem tej podróży', 'trip.toast.reorderError': 'Nie udało się zmienić kolejności', 'trip.toast.reservationUpdated': 'Rezerwacja została zaktualizowana', 'trip.toast.reservationAdded': 'Rezerwacja została dodana', diff --git a/shared/src/i18n/ru/trip.ts b/shared/src/i18n/ru/trip.ts index a0c40cd1d..3ae364f04 100644 --- a/shared/src/i18n/ru/trip.ts +++ b/shared/src/i18n/ru/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Сначала выберите день', 'trip.toast.assignedToDay': 'Место назначено на день', 'trip.toast.loadError': 'Не удалось загрузить поездку', + 'trip.toast.accessLost': 'Вы больше не участник этой поездки', 'trip.toast.reorderError': 'Ошибка изменения порядка', 'trip.toast.reservationUpdated': 'Бронирование обновлено', 'trip.toast.reservationAdded': 'Бронирование добавлено', diff --git a/shared/src/i18n/sk/trip.ts b/shared/src/i18n/sk/trip.ts index 35d463220..a1ed05ccb 100644 --- a/shared/src/i18n/sk/trip.ts +++ b/shared/src/i18n/sk/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Najprv prosím vyberte deň', 'trip.toast.assignedToDay': 'Miesto bolo priradené k dňu', 'trip.toast.loadError': 'Cestu sa nepodarilo načítať', + 'trip.toast.accessLost': 'Už nie ste členom tejto cesty', 'trip.toast.reorderError': 'Nepodarilo sa zmeniť poradie', 'trip.toast.reservationUpdated': 'Rezervácia aktualizovaná', 'trip.toast.reservationAdded': 'Rezervácia pridaná', diff --git a/shared/src/i18n/sv/trip.ts b/shared/src/i18n/sv/trip.ts index 05b6ca9a2..9104a9540 100644 --- a/shared/src/i18n/sv/trip.ts +++ b/shared/src/i18n/sv/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Vänligen välj en dag först', 'trip.toast.assignedToDay': 'Plats tilldelad till dag', 'trip.toast.loadError': 'Det gick inte att ladda resan', + 'trip.toast.accessLost': 'Du är inte längre medlem i den här resan', 'trip.toast.reorderError': 'Misslyckades att ordna om', 'trip.toast.reservationUpdated': 'Reservation uppdaterad', 'trip.toast.reservationAdded': 'Reservation tillagd', diff --git a/shared/src/i18n/th/trip.ts b/shared/src/i18n/th/trip.ts index 7c9574c3e..a8dbfc604 100644 --- a/shared/src/i18n/th/trip.ts +++ b/shared/src/i18n/th/trip.ts @@ -93,6 +93,7 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlaceBookedSame': 'การดำเนินการนี้จะลบการพักที่จองไว้ที่ “{name}” รวมถึงการจองและค่าใช้จ่ายที่ผูกไว้ด้วย', 'trip.toast.loadError': 'ไม่สามารถโหลดทริปได้', + 'trip.toast.accessLost': 'คุณไม่ได้เป็นสมาชิกของทริปนี้อีกต่อไป', 'transit.noResultsVia': 'ไม่พบเส้นทางผ่าน {provider} ลองเปลี่ยนเวลาหรือตัวกรอง', 'trip.settings.title': 'การตั้งค่าทริป', 'trip.settings.people': 'ผู้คน', diff --git a/shared/src/i18n/tr/trip.ts b/shared/src/i18n/tr/trip.ts index 0ef7267ba..9b0736376 100644 --- a/shared/src/i18n/tr/trip.ts +++ b/shared/src/i18n/tr/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Lütfen önce bir gün seçin', 'trip.toast.assignedToDay': 'Yer güne atandı', 'trip.toast.loadError': 'Gezi yüklenemedi', + 'trip.toast.accessLost': 'Artık bu gezinin üyesi değilsiniz', 'trip.toast.reorderError': 'Sıralama yapılamadı', 'trip.toast.reservationUpdated': 'Rezervasyon güncellendi', 'trip.toast.reservationAdded': 'Rezervasyon eklendi', diff --git a/shared/src/i18n/uk/trip.ts b/shared/src/i18n/uk/trip.ts index 536191d1f..ade3e353b 100644 --- a/shared/src/i18n/uk/trip.ts +++ b/shared/src/i18n/uk/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Спочатку виберіть день', 'trip.toast.assignedToDay': 'Місце призначено на день', 'trip.toast.loadError': 'Не вдалося завантажити подорож', + 'trip.toast.accessLost': 'Ви більше не учасник цієї подорожі', 'trip.toast.reorderError': 'Помилка зміни порядку', 'trip.toast.reservationUpdated': 'Бронювання оновлено', 'trip.toast.reservationAdded': 'Бронювання додано', diff --git a/shared/src/i18n/vi/trip.ts b/shared/src/i18n/vi/trip.ts index 067a53613..d916b1560 100644 --- a/shared/src/i18n/vi/trip.ts +++ b/shared/src/i18n/vi/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': 'Vui lòng chọn một ngày trước', 'trip.toast.assignedToDay': 'Địa điểm đã được gán vào ngày', 'trip.toast.loadError': 'Không thể tải chuyến đi', + 'trip.toast.accessLost': 'Bạn không còn là thành viên của chuyến đi này', 'trip.toast.reorderError': 'Không thể sắp xếp lại', 'trip.toast.reservationUpdated': 'Đã cập nhật đặt chỗ', 'trip.toast.reservationAdded': 'Đã thêm đặt chỗ', diff --git a/shared/src/i18n/zh-TW/trip.ts b/shared/src/i18n/zh-TW/trip.ts index 959d55791..aadf3d877 100644 --- a/shared/src/i18n/zh-TW/trip.ts +++ b/shared/src/i18n/zh-TW/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': '請先選擇一天', 'trip.toast.assignedToDay': '地點已分配到當天', 'trip.toast.loadError': '無法載入行程', + 'trip.toast.accessLost': '你已不再是此行程的成員', 'trip.toast.reorderError': '排序失敗', 'trip.toast.reservationUpdated': '預訂已更新', 'trip.toast.reservationAdded': '預訂已新增', diff --git a/shared/src/i18n/zh/trip.ts b/shared/src/i18n/zh/trip.ts index a0808ff44..412376c55 100644 --- a/shared/src/i18n/zh/trip.ts +++ b/shared/src/i18n/zh/trip.ts @@ -26,6 +26,7 @@ const trip: TranslationStrings = { 'trip.toast.selectDay': '请先选择一天', 'trip.toast.assignedToDay': '地点已分配到当天', 'trip.toast.loadError': '无法加载行程', + 'trip.toast.accessLost': '你已不再是此行程的成员', 'trip.toast.reorderError': '排序失败', 'trip.toast.reservationUpdated': '预订已更新', 'trip.toast.reservationAdded': '预订已添加',