mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(tours): pending checklist saves per tour, a queued checklist waits for its replay
A save answered on one tour no longer clears the pending state of a save started on another. A checklist changed here offline waits for the queue to replay before it is read online, as a bench of such a write already did. A saved tour the list has not loaded yet is no unsaved paddle draft, and Next walks the rows as the list shows them, a vanished object among them.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// PCM-001 to PCM-018: the check bench of a paddle tour in one pill, how the dates of the checklist read, where the
|
||||
// PCM-001 to PCM-019: the check bench of a paddle tour in one pill, how the dates of the checklist read, where the
|
||||
// camera goes for an entry and the order the checklist lists its rows in (PAD-06, PAD-09).
|
||||
import { Ban, CircleDashed, ListChecks } from 'lucide-react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -244,8 +244,9 @@ describe('nextPendingEntry', () => {
|
||||
});
|
||||
const last = obstacleEntry({ key: 'node/5', refs: ['node/5'], along_m: 1700, status: 'decided', decision });
|
||||
const gone = earlierEntry();
|
||||
const offRoute = earlierEntry({ key: 'node/11', refs: ['node/11'], status: 'off_route' });
|
||||
// Listed out of route order: the next one is found by its metre, not by its place in the list.
|
||||
const check = obstacleCheck({ entries: [review, last, gone, todo, first, decided] });
|
||||
const check = obstacleCheck({ entries: [review, last, gone, offRoute, todo, first, decided] });
|
||||
|
||||
it('PCM-016: skips what is decided and takes the next one after the entry, one to check again counted', () => {
|
||||
expect(nextPendingEntry(check, first)).toEqual(todo);
|
||||
@@ -253,13 +254,14 @@ describe('nextPendingEntry', () => {
|
||||
expect(nextPendingEntry(check, todo)).toEqual(review);
|
||||
});
|
||||
|
||||
it('PCM-017: from an earlier decision the first pending entry of the route comes next', () => {
|
||||
expect(nextPendingEntry(check, gone)).toEqual(first);
|
||||
it('PCM-017: from an earlier decision outside the rows the first pending entry of the route comes next', () => {
|
||||
expect(nextPendingEntry(check, offRoute)).toEqual(first);
|
||||
});
|
||||
|
||||
it('PCM-018: after the last pending entry there is none', () => {
|
||||
expect(nextPendingEntry(check, review)).toBeNull();
|
||||
expect(nextPendingEntry(check, last)).toBeNull();
|
||||
it('PCM-018: a vanished object, listed after the route, comes after its last entry, and after it there is none', () => {
|
||||
expect(nextPendingEntry(check, review)).toEqual(gone);
|
||||
expect(nextPendingEntry(check, last)).toEqual(gone);
|
||||
expect(nextPendingEntry(check, gone)).toBeNull();
|
||||
expect(nextPendingEntry(obstacleCheck({ entries: [first] }), first)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,17 +108,16 @@ export function checklistRows(check: TourObstacleCheck): { rows: ChecklistRow[];
|
||||
};
|
||||
}
|
||||
|
||||
const PENDING = new Set(['open', 'todo', 'review']);
|
||||
/** The statuses that still ask for a look, as the bench counts them (a vanished object among them). */
|
||||
const PENDING = new Set(['open', 'todo', 'review', 'gone']);
|
||||
|
||||
/**
|
||||
* The next entry still asking for a look, in the order the checklist lists
|
||||
* them (route order, entries at one metre in list order); from an earlier
|
||||
* decision, the first one.
|
||||
* its rows (route order, entries at one metre in list order, then the
|
||||
* vanished ones); from an earlier decision outside the rows, the first one.
|
||||
*/
|
||||
export function nextPendingEntry(check: TourObstacleCheck, entry: TourObstacleEntry): TourObstacleEntry | null {
|
||||
const current = check.entries
|
||||
.filter((candidate) => candidate.relation !== null)
|
||||
.sort((a, b) => (a.along_m ?? 0) - (b.along_m ?? 0));
|
||||
const from = current.findIndex((candidate) => candidate.key === entry.key);
|
||||
return current.slice(from + 1).find((candidate) => PENDING.has(candidate.status)) ?? null;
|
||||
const listed = checklistRows(check).rows.flatMap((row) => (row.type === 'obstacle' ? [row.entry] : []));
|
||||
const from = listed.findIndex((candidate) => candidate.key === entry.key);
|
||||
return listed.slice(from + 1).find((candidate) => PENDING.has(candidate.status)) ?? null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* UPC-001 to UPC-030: the obstacle checklist of the paddle tour open in the
|
||||
* UPC-001 to UPC-032: the obstacle checklist of the paddle tour open in the
|
||||
* tours workspace (PAD-06, PAD-09), as its card and the map share it.
|
||||
*
|
||||
* Pinned: off for anything but an open paddle tour in an active workspace,
|
||||
@@ -760,4 +760,42 @@ describe('usePaddleChecklist after review', () => {
|
||||
});
|
||||
expect(saved).toBe(true);
|
||||
});
|
||||
|
||||
it('UPC-031: a save on one tour and then on another keep their own pending state until each is answered', async () => {
|
||||
const other = paddleTour({ place_id: 8, paddle_check: paddleBench({ revision: 'rev-8' }) });
|
||||
const otherEntry = obstacleEntry({ key: 'node/8', refs: ['node/8'] });
|
||||
const first = deferred<TourDetailResponse>();
|
||||
const second = deferred<TourDetailResponse>();
|
||||
repo.saveObstacleReviews.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||
const props = options({ tours: [TOUR, other] });
|
||||
const { result, rerender } = await renderReady(props);
|
||||
act(() => {
|
||||
void result.current.decide(ENTRY, 'pass');
|
||||
});
|
||||
repo.obstacleCheck.mockResolvedValue({
|
||||
check: obstacleCheck({ revision: 'rev-8', entries: [otherEntry] }),
|
||||
stale: false,
|
||||
});
|
||||
rerender({ ...props, planner: planner({ mode: { type: 'edit-saved', placeId: 8 } }) });
|
||||
await waitFor(() => expect(result.current.check?.entries[0]?.key).toBe('node/8'));
|
||||
act(() => {
|
||||
void result.current.decide(otherEntry, 'portage');
|
||||
});
|
||||
expect(result.current.savingKey).toBe('node/8');
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(answer(obstacleCheck({ revision: 'rev-2' }), 'rev-2'));
|
||||
await first.promise;
|
||||
});
|
||||
expect(result.current.savingKey).toBe('node/8');
|
||||
expect(result.current.check?.entries[0]).toMatchObject({ key: 'node/8', decision: { decision: 'portage' } });
|
||||
});
|
||||
|
||||
it('UPC-032: a saved paddle tour the list has not loaded yet is off until it is there, then read', async () => {
|
||||
const { result, rerender } = renderChecklist(options({ tours: [] }));
|
||||
expect(result.current.status).toBe('off');
|
||||
expect(repo.obstacleCheck).not.toHaveBeenCalled();
|
||||
rerender(options());
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,20 @@ function waypointBefore(points: TourPlannerController['waypoints'], alongM: numb
|
||||
return points[points.length - 2]?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new paddle draft, or a saved tour the list shows as another type that the
|
||||
* planner turned into a paddle tour: no server check exists for either. A
|
||||
* tour the list does not hold yet (still loading) is neither.
|
||||
*/
|
||||
function unsavedPaddleDraft(planner: TourPlannerController, tours: readonly TourListItem[]): boolean {
|
||||
if (!planner.isPaddle) return false;
|
||||
const { mode } = planner;
|
||||
if (mode.type === 'new-draft') return true;
|
||||
return (
|
||||
mode.type === 'edit-saved' && tours.some((item) => item.place_id === mode.placeId && item.tour_type !== 'kayak')
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the checklist of the open tour stands. */
|
||||
function checklistStatus(input: {
|
||||
active: boolean;
|
||||
@@ -234,9 +248,7 @@ export function usePaddleChecklist(options: PaddleChecklistOptions) {
|
||||
const dirty = planner.hasUnsavedChanges || !!trackPaddlePlan?.dirty;
|
||||
const status = checklistStatus({
|
||||
active,
|
||||
// A new paddle draft, or a saved tour turned into one and not saved yet: no server check exists for either.
|
||||
newPaddleDraft:
|
||||
planner.isPaddle && (planner.mode.type === 'new-draft' || (planner.mode.type === 'edit-saved' && !tour)),
|
||||
newPaddleDraft: unsavedPaddleDraft(planner, tours),
|
||||
tour,
|
||||
current: read.current,
|
||||
offline: read.offline,
|
||||
@@ -262,6 +274,22 @@ export function usePaddleChecklist(options: PaddleChecklistOptions) {
|
||||
|
||||
export type PaddleChecklist = ReturnType<typeof usePaddleChecklist>;
|
||||
|
||||
interface PendingSave {
|
||||
key: string;
|
||||
check: TourObstacleCheck;
|
||||
}
|
||||
|
||||
/** The saves on their way without this one, once it is answered; a later save of the same tour stays. */
|
||||
function withoutSave(
|
||||
all: Readonly<Record<number, PendingSave>>,
|
||||
placeId: number,
|
||||
done: PendingSave
|
||||
): Readonly<Record<number, PendingSave>> {
|
||||
if (all[placeId] !== done) return all;
|
||||
const { [placeId]: _done, ...rest } = all;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** The decisions saved through the offline core: shown at once, the server's answer taken in, a failure undone. */
|
||||
function useChecklistWrites(
|
||||
options: PaddleChecklistOptions,
|
||||
@@ -272,7 +300,8 @@ function useChecklistWrites(
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const userId = useAuthStore((state) => state.user?.id ?? null);
|
||||
const [pending, setPending] = useState<{ placeId: number; key: string; check: TourObstacleCheck } | null>(null);
|
||||
// The save on its way per tour, so one tour's answer never clears another's.
|
||||
const [pending, setPending] = useState<Readonly<Record<number, PendingSave>>>({});
|
||||
const base = read.current?.check ?? null;
|
||||
const placeId = tour?.place_id ?? null;
|
||||
const { setLoaded, offline } = read;
|
||||
@@ -280,11 +309,11 @@ function useChecklistWrites(
|
||||
const save = useCallback(
|
||||
async (key: string, op: ObstacleReviewOp) => {
|
||||
if (!base || placeId === null) return false;
|
||||
setPending({
|
||||
placeId,
|
||||
const entry: PendingSave = {
|
||||
key,
|
||||
check: applyObstacleOps(base, [op], { now: new Date().toISOString(), userId, revision: 'pending' }),
|
||||
});
|
||||
};
|
||||
setPending((all) => ({ ...all, [placeId]: entry }));
|
||||
try {
|
||||
const result = await tourRepo.saveObstacleReviews(tripId, placeId, { ops: [op] }, userId);
|
||||
const revision = result.tour.paddle_check?.revision ?? null;
|
||||
@@ -305,7 +334,7 @@ function useChecklistWrites(
|
||||
toast.error(t('tours.paddle.marks.failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setPending(null);
|
||||
setPending((all) => withoutSave(all, placeId, entry));
|
||||
}
|
||||
},
|
||||
[base, offline, onSaved, placeId, setLoaded, t, toast, tripId, userId]
|
||||
@@ -331,6 +360,6 @@ function useChecklistWrites(
|
||||
[save]
|
||||
);
|
||||
// A save still running for a tour left meanwhile shows nothing on the one open now.
|
||||
const mine = pending && pending.placeId === placeId ? pending : null;
|
||||
const mine = placeId === null ? null : (pending[placeId] ?? null);
|
||||
return { optimistic: mine?.check ?? null, savingKey: mine?.key ?? null, actions };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-REPO-TOUR-030 to FE-REPO-TOUR-040
|
||||
// FE-REPO-TOUR-030 to FE-REPO-TOUR-041
|
||||
// A paddle tour's checklist through the offline core (PAD-06, PAD-09): read
|
||||
// from the cache while its revision matches the bench, decisions saved online
|
||||
// and cached, saved offline under a local revision and queued, and a route
|
||||
@@ -234,4 +234,29 @@ describe('after review', () => {
|
||||
expect(flush).toHaveBeenCalled();
|
||||
expect(read).toMatchObject({ check: { revision: 'rev-5' }, tour: { paddle_check: { revision: 'rev-5' } } });
|
||||
});
|
||||
|
||||
it('FE-REPO-TOUR-041: online, a checklist changed here and still queued waits for the queue before it is read', async () => {
|
||||
await offlineDb.tours.put({
|
||||
...paddleTour({ paddle_check: paddleBench({ revision: 'rev-2' }) }),
|
||||
trip_id: 3,
|
||||
waypoints,
|
||||
obstacle_check: obstacleCheck({ revision: 'local:x' }),
|
||||
});
|
||||
const order: string[] = [];
|
||||
vi.spyOn(mutationQueue, 'flush').mockImplementation(async () => {
|
||||
order.push('flush');
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/trips/3/tours/40', () => {
|
||||
order.push('get');
|
||||
return HttpResponse.json({
|
||||
tour: paddleTour({ paddle_check: paddleBench({ revision: 'rev-3' }) }),
|
||||
waypoints,
|
||||
obstacle_check: obstacleCheck({ revision: 'rev-3' }),
|
||||
});
|
||||
})
|
||||
);
|
||||
await tourRepo.obstacleCheck(3, 40, 'rev-2');
|
||||
expect(order).toEqual(['flush', 'get']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,8 +278,13 @@ export const tourRepo = {
|
||||
revision: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ check: TourObstacleCheck | null; stale: boolean; tour?: TourListItem }> {
|
||||
if (revision?.startsWith('local:') && !isEffectivelyOffline()) await mutationQueue.flush()
|
||||
const cached = await offlineDb.tours.get(placeId)
|
||||
let cached = await offlineDb.tours.get(placeId)
|
||||
// A bench or a checklist of a write made here offline waits for the queue to replay once online.
|
||||
const queuedHere = !!revision?.startsWith('local:') || !!cached?.obstacle_check?.revision.startsWith('local:')
|
||||
if (queuedHere && !isEffectivelyOffline()) {
|
||||
await mutationQueue.flush()
|
||||
cached = await offlineDb.tours.get(placeId)
|
||||
}
|
||||
if (revision !== null && cached?.obstacle_check?.revision === revision) return { check: cached.obstacle_check, stale: false }
|
||||
const detail = await tourRepo.detail(tripId, placeId, signal)
|
||||
const check = detail.obstacle_check ?? null
|
||||
|
||||
Reference in New Issue
Block a user