mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(client): open views follow member, settings, tag, atlas and journey changes made elsewhere
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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': 'تمت إضافة الحجز',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Η κράτηση προστέθηκε',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '予約を追加しました',
|
||||
|
||||
@@ -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': '예약이 추가되었습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Бронирование добавлено',
|
||||
|
||||
@@ -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á',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ผู้คน',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Бронювання додано',
|
||||
|
||||
@@ -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ỗ',
|
||||
|
||||
@@ -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': '預訂已新增',
|
||||
|
||||
@@ -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': '预订已添加',
|
||||
|
||||
Reference in New Issue
Block a user