feat(client): open views follow member, settings, tag, atlas and journey changes made elsewhere

This commit is contained in:
Maurice
2026-10-22 12:14:28 +02:00
parent b24a82fe33
commit 8d4d3423ff
53 changed files with 1269 additions and 133 deletions
+43 -37
View File
@@ -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<Record<TrekWsEventName, string>> = {
'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[] = []
@@ -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);
});
});
+32 -8
View File
@@ -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, string | number>) => 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
@@ -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(() => {
@@ -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>): void {
useServerPing('packing:bag-totals', enabled, reload)
useServerPing(BAG_RELOAD_EVENTS, enabled, reload)
}
@@ -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, string | number>) => string;
@@ -23,14 +24,24 @@ export interface CategoryAssignee {
export function usePackingCategoryAssignees({ tripId, t, toast }: { tripId: number; t: Translate; toast: Toaster }) {
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({});
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 {
@@ -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()
@@ -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<TripMember[]>([])
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 })
@@ -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<string, unknown>) => {
@@ -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)
}
+63 -12
View File
@@ -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(<VacayPage />);
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<typeof vi.fn>;
@@ -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<typeof vi.fn>;
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<typeof vi.fn>;
@@ -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<typeof vi.fn>;
seedStore(useVacayStore, makeVacayState({ loadPlan: mockLoadPlan, loadAll: mockLoadAll, loadShares: mockLoadShares }) as never);
render(<VacayPage />);
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<typeof vi.fn>;
seedStore(useVacayStore, makeVacayState({ loadPlan: mockLoadPlan }) as never);
render(<VacayPage />);
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();
}
});
});
+25
View File
@@ -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);
});
});
+5 -5
View File
@@ -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.
@@ -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([]);
});
});
@@ -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 = () => {
+12
View File
@@ -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
+11 -6
View File
@@ -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])
@@ -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>): 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]);
}
+62 -15
View File
@@ -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<string, readonly VacayReload[]> = {
'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<Set<VacayReload>>(new Set())
const wsReloadTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const burstStartedAt = useRef<number | null>(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])
@@ -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<NotificationState>((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,
}))
},
}))
+194 -1
View File
@@ -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<T extends { id: number; sort_order: number }>(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<number, number>()
;(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<string, unknown>): { 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<P extends NamedPerson>(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<T>(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<Record<TrekWsTripEventName, DexieWriter>> =
])
},
'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<Record<TrekWsTripEventName, DexieWriter>> =
'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<Record<TrekWsTripEventName, DexieWriter>> =
'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<Record<TrekWsTripEventName, DexieWriter>> =
'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<Record<TrekWsTripEventName, StateApplier>>
}
}
},
// 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<Record<TrekWsTripEventName, StateApplier>>
'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<Record<TrekWsTripEventName, StateApplier>>
'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<Record<TrekWsTripEventName, StateApplier>>
// 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<Record<TrekWsTripEventName, StateApplier>>
},
}
/** How long events of one kind are collected before the store reads once. */
const STORE_REFRESH_SETTLE_MS = 250
const pendingRefreshes = new Map<string, ReturnType<typeof setTimeout>>()
/**
* 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<Record<TrekWsTripEventName, StoreRefresh>> = {
// 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
+44
View File
@@ -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<Trip> & { date_shift_mode?: 'keep_bookings' | 'shift_all' }) => Promise<Trip>
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
addCategory: (data: Partial<Category> & { name: string }) => Promise<Category>
/** Reads the user's tags again after they changed elsewhere (`tags:changed`). */
reloadTags: () => Promise<void>
/** Reads the packing list again, for a change no item event describes (a template applied). */
reloadPackingItems: (tripId: number | string) => Promise<void>
clearTripGone: () => void
}
export const useTripStore = create<TripStoreState>((set, get) => ({
@@ -121,6 +139,7 @@ export const useTripStore = create<TripStoreState>((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<TripStoreState>((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<TripStoreState>((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<Category> & { name: string }) => {
try {
const result = await categoriesApi.create(data)
+23 -6
View File
@@ -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>): void {
export function useServerPing(
eventName: string | readonly string[],
enabled: boolean,
reload: () => Promise<void>,
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<typeof setTimeout> | 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<string, unknown>) => {
// '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])
}
@@ -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();
@@ -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<string, unknown>) => 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);
});
});
@@ -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<typeof import('react-router')>()),
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<string, unknown>): 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<void> {
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);
});
});
@@ -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<string>([
...Object.keys(STATE_APPLIERS),
...Object.keys(DEXIE_WRITERS),
...Object.keys(STORE_REFRESHES),
]);
const handledElsewhere = new Set<string>(HANDLED_OUTSIDE_TRIP_STORE);
const ignored = new Set<string>(IGNORED_WS_EVENTS);
const ignored = new Set<string>(Object.keys(IGNORED_WS_EVENTS));
const awaitingSender = new Set<string>(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);
});
});
+1
View File
@@ -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': 'تمت إضافة الحجز',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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': 'Η κράτηση προστέθηκε',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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': '予約を追加しました',
+1
View File
@@ -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': '예약이 추가되었습니다',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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': 'Бронирование добавлено',
+1
View File
@@ -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á',
+1
View File
@@ -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',
+1
View File
@@ -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': 'ผู้คน',
+1
View File
@@ -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',
+1
View File
@@ -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': 'Бронювання додано',
+1
View File
@@ -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ỗ',
+1
View File
@@ -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': '預訂已新增',
+1
View File
@@ -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': '预订已添加',