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

This commit is contained in:
Maurice
2026-10-22 12:14:28 +02:00
parent b24a82fe33
commit 8d4d3423ff
53 changed files with 1269 additions and 133 deletions
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { budgetApi } from '../../api/client';
import { addListener } from '../../api/websocket';
import { clearAll, offlineDb } from '../../db/offlineDb';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
@@ -350,3 +351,24 @@ describe('useCostsLedger: offline (#2601)', () => {
await waitFor(() => expect(settlementMock().mock.calls.length).toBeGreaterThan(before));
});
});
describe('useCostsLedger: changes written elsewhere', () => {
it('FE-BCAST-017: a burst of payment, split and guest events reads the balances once more', async () => {
// Only this test's listeners: the mock keeps the calls of earlier tests otherwise.
vi.mocked(addListener).mockClear();
const { result } = setup();
await waitFor(() => expect(result.current.settlement).toEqual(SETTLEMENT));
const before = settlementMock().mock.calls.length;
act(() => {
for (const [listener] of vi.mocked(addListener).mock.calls) {
listener({ type: 'budget:settlement-created', settlement: { id: 31 } });
listener({ type: 'budget:member-paid-updated', itemId: 4, userId: 2, paid: 1 });
listener({ type: 'member:updated', member: { id: 2, username: 'Bo' } });
listener({ type: 'budget:created', item: { id: 99 } });
}
});
await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(before + 1));
await new Promise((resolve) => setTimeout(resolve, 300));
expect(settlementMock()).toHaveBeenCalledTimes(before + 1);
});
});
+32 -8
View File
@@ -8,6 +8,7 @@ import { budgetRepo, type SettlementWriteResult } from '../../repo/budgetRepo';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { TripStoreState } from '../../store/tripStore';
import { useServerPing } from '../../sync/useServerPing';
import type { BudgetItem, Trip } from '../../types';
import { downloadBlob } from '../../utils/fileDownload';
import { localToday } from '../Planner/today';
@@ -26,6 +27,18 @@ import {
import { knownPersonNames } from './CostsPanel.helpers';
import { useFreezeMissingRates } from './useFreezeMissingRates';
/** The events after which the balances are read again from the server. */
const SETTLEMENT_RELOAD_EVENTS = [
'budget:settlement-created',
'budget:settlement-updated',
'budget:settlement-deleted',
'budget:updated',
'budget:members-updated',
'budget:member-paid-updated',
'member:removed',
'member:updated',
] as const;
type Translate = (key: string, params?: Record<string, string | number>) => string;
interface Toaster {
error: (message: string) => void;
@@ -91,15 +104,26 @@ export function useCostsLedger({
// The browser's own figure for the display currency goes along, for the server to
// answer in it when it cannot fetch a quote itself.
const readSettlement = useCallback(
() =>
budgetRepo
.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
.then((s) => {
setSettlement(s);
setSettlementError(false);
})
.catch(() => setSettlementError(true)),
[tripId, base, tripCurrency, displayPerTrip]
);
const loadSettlement = useCallback(() => {
budgetRepo
.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
.then((s) => {
setSettlement(s);
setSettlementError(false);
})
.catch(() => setSettlementError(true));
}, [tripId, base, tripCurrency, displayPerTrip]);
void readSettlement();
}, [readSettlement]);
// A payment, a split, a paid mark or a renamed or removed guest written
// elsewhere (a fellow traveller, the assistant) moves the balances without
// changing the number of expenses, so those events read the settlement again,
// one read per burst however many changes one assistant step applies.
useServerPing(SETTLEMENT_RELOAD_EVENTS, true, readSettlement);
// The items reload when the trip changes; the settlement reloads on a trip or
// currency change and when the number of expenses changes. Further refreshes are
@@ -5,11 +5,25 @@ import { useNavigate } from 'react-router';
import { collectionsApi } from '../../api/collections';
import { useTranslation } from '../../i18n';
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
import { useServerPing } from '../../sync/useServerPing';
import { getApiErrorMessage } from '../../utils/apiError';
import { useToast } from '../shared/Toast';
type MembershipEntry = CollectionMembership['lists'][number];
/**
* The collection events that can change whether a place is saved: a place saved or
* removed in another tab or through MCP, an invite to a list accepted, a list
* deleted, left or taken away.
*/
const MEMBERSHIP_EVENTS = [
'collections:updated',
'collections:accepted',
'collections:deleted',
'collections:left',
'collections:removed',
] as const;
/** busyId while the "visited everywhere" bulk action runs. */
export const VISITED_EVERYWHERE_BUSY = -1;
@@ -54,6 +68,19 @@ export function useSaveToCollection() {
}
}, [membershipQuery]);
// Saved elsewhere: the open picker reads its membership again, and the version
// bump lets the inspector bookmark re-check, once per burst of events. Collection
// events go to the user, not to a trip room, so opening a trip is no reason to re-check.
useServerPing(
MEMBERSHIP_EVENTS,
true,
async () => {
bumpVersion();
await refreshMembership();
},
{ rejoin: false }
);
// Load lists + membership whenever the picker opens for a new target. The query is
// derived from the target alone, so both change together.
useEffect(() => {
@@ -1,13 +1,26 @@
import { useServerPing } from '../../sync/useServerPing'
/**
* Re-read the bag weights when the server says they moved (#2191).
*
* The ping is content-free by design: totals are summed across every member,
* including people whose items this client may not see, so the event cannot
* carry the new numbers without leaking what produced them. `useServerPing`
* carries the coalescing, the in-flight guard and the reconnect handling.
* The events after which the bags are read again. The totals ping is content-free
* by design (#2191): totals are summed across every member, including people whose
* items this client may not see, so the event cannot carry the new numbers without
* leaking what produced them. A bag created, renamed, deleted or given new members
* elsewhere (a fellow traveller, the assistant) is read back the same way, so the
* bag list has one source instead of a second copy patched from the payloads.
*/
const BAG_RELOAD_EVENTS = [
'packing:bag-totals',
'packing:bag-created',
'packing:bag-updated',
'packing:bag-deleted',
'packing:bag-members-updated',
] as const
/**
* Re-read the bags when the server says they moved. `useServerPing` carries the
* coalescing, the in-flight guard and the reconnect handling, so an assistant step
* that touches several bags is still one read.
*/
export function useBagTotalsPing(enabled: boolean, reload: () => Promise<void>): void {
useServerPing('packing:bag-totals', enabled, reload)
useServerPing(BAG_RELOAD_EVENTS, enabled, reload)
}
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { packingApi } from '../../api/client';
import { useServerPing } from '../../sync/useServerPing';
import { offlineOr } from '../../utils/needsConnection';
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -23,14 +24,24 @@ export interface CategoryAssignee {
export function usePackingCategoryAssignees({ tripId, t, toast }: { tripId: number; t: Translate; toast: Toaster }) {
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({});
const reload = useCallback(
() =>
packingApi
.getCategoryAssignees(tripId)
.then((data) => setCategoryAssignees(data.assignees || {}))
.catch(() => {
// A failed read leaves the last answer standing; there is nothing to undo.
}),
[tripId]
);
useEffect(() => {
packingApi
.getCategoryAssignees(tripId)
.then((data) => setCategoryAssignees(data.assignees || {}))
.catch(() => {
// A failed read leaves every category unassigned; there is nothing to undo.
});
}, [tripId]);
void reload();
}, [reload]);
// Assignees set elsewhere (a fellow traveller, the assistant) are read back,
// one read per burst, rather than patched in from the event.
useServerPing('packing:assignees', true, reload);
const setAssignees = async (category: string, userIds: number[]) => {
try {
@@ -11,6 +11,7 @@ import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { addListener } from '../../api/websocket'
import { usePackingList, type PackingListPanelProps } from './usePackingListPanel'
import type { PackingItem } from '../../types'
@@ -84,6 +85,31 @@ describe('usePackingList — members & assignees', () => {
expect(result.current.tripMembers).toEqual([])
})
it('FE-BCAST-024: a traveller added or renamed elsewhere reaches the pickers, one read per burst', async () => {
vi.mocked(addListener).mockClear()
let calls = 0
server.use(
http.get('/api/trips/:id/members', () => {
calls++
return HttpResponse.json({
owner: { id: 1, username: 'owner', avatar_url: null },
members: calls > 1 ? [{ id: 2, username: 'Ana', avatar_url: null, is_guest: 1 }] : [],
})
}),
)
const { result } = renderPanel()
await waitFor(() => expect(result.current.tripMembers).toHaveLength(1))
act(() => {
for (const [listener] of vi.mocked(addListener).mock.calls) {
listener({ type: 'member:added', member: { id: 2 } })
listener({ type: 'member:updated', member: { id: 2, username: 'Ana' } })
}
})
await waitFor(() => expect(result.current.tripMembers.map((m) => m.username)).toEqual(['owner', 'Ana']))
expect(calls).toBe(2)
})
it('FE-W5HOOK-004: an assignee response without a map falls back to an empty record', async () => {
server.use(http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({})))
const { result } = renderPanel()
@@ -1,4 +1,4 @@
import { useState, useMemo, useRef, useEffect } from 'react'
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import { useServerPing } from '../../sync/useServerPing'
import type { PackingItem } from '../../types'
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
@@ -19,6 +20,9 @@ export type { CategoryAssignee } from './usePackingCategoryAssignees'
const PACKING_SORT_KEY = 'trek:packing-sort'
/** The roster events that change who the pickers offer. */
const MEMBER_EVENTS = ['member:added', 'member:updated', 'member:removed'] as const
export type PackingSort = 'manual' | 'name'
export interface TripMember {
@@ -78,14 +82,19 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
// Trip members & category assignees
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
const readMembers = useCallback(() => tripsApi.getMembers(tripId).then(data => {
const all: TripMember[] = []
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url, is_guest: false })
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest })))
setTripMembers(all)
}).catch(() => {}), [tripId])
useEffect(() => {
tripsApi.getMembers(tripId).then(data => {
const all: TripMember[] = []
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url, is_guest: false })
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest })))
setTripMembers(all)
}).catch(() => {})
}, [tripId])
void readMembers()
}, [readMembers])
// A traveller or guest added, renamed or removed elsewhere: the pickers read the
// roster again, once per burst (the phone list reads the planner's roster).
useServerPing(MEMBER_EVENTS, true, readMembers)
const { categoryAssignees, setAssignees: handleSetAssignees } = usePackingCategoryAssignees({ tripId, t, toast })