Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | 4x 4x 64x 64x 64x 97x 52x 13x 52x 52x 52x 17x 15x 14x 14x 14x 14x 14x 14x 3x 2x 2x 11x 11x 1x 14x 6x 6x 6x 6x 6x 6x 3x 3x 3x 3x 3x 1x 2x 3x 6x 52x | "use client";
import { createContext, useContext, useMemo, useRef, useState } from "react";
import {
enqueue,
newClientKey,
removeQueued,
type PendingEntry,
type QueueKey,
} from "./decisionQueue";
import { isInFlight, triggerFlush } from "./decisionFlusher";
import { directSubmit } from "./directSubmit";
// The set-aside undo, hoisted out of the empty-state branch. Not mine
// (TriageRound's `handleNotMine`, via `SetAsideButton`) lives inside the triage
// decision sub-row, but the NOTICE (TriageUndoNotice) renders OUTSIDE the
// `{!item ? <EmptyState/> : …}` ternary —
// so when the curator sets aside the LAST untriaged item and the item branch
// unmounts (item === null), the "Bring back" undo still shows. The durable
// "Set aside" list recovers it too, but the spec's promise is that the inline
// undo survives a refresh (§11) — including the refresh that empties the queue.
//
// Epic P (spec §3.3): dispositions ride the decision queue — setAside
// enqueues a disposition entry and triggers a flush; there is NO awaited fetch
// and NO router.refresh() on the tap path (the optimistic advance is owned by
// TriageRound; server truth reconciles at the round-end refresh). Undo of a
// still-queued set-aside is pure client (removeQueued); once the entry has
// flushed (or is inside an active request), bringBack enqueues a `clear` —
// the flusher's DELETE is idempotent. Only the storage-unavailable fallback
// (F18) still performs today's awaited direct submit.
//
// There is deliberately no auto-dismiss timer — a countdown reads as a
// deadline, and this is a gentle escape, not a race (TONE.md, commitment 4).
//
// collectionId MUST be the ULID collection.id, not the slug: the dispositions
// API authorizes the item then asserts item.collectionId === params.id, which
// is always a ULID — a slug there 404s.
// clientKey null = the fallback direct POST landed it (nothing queued to remove).
type Undo = { itemId: string; name: string | null; clientKey: string | null };
interface TriageUndoContextValue {
undo: Undo | null;
error: string;
setAside: (itemId: string, name: string | null) => Promise<void>;
bringBack: () => Promise<void>;
}
const TriageUndoContext = createContext<TriageUndoContextValue | null>(null);
export function useTriageUndo(): TriageUndoContextValue {
const ctx = useContext(TriageUndoContext);
Iif (!ctx) throw new Error("useTriageUndo must be used within a TriageUndoProvider");
return ctx;
}
// Non-throwing read of the context: returns null when no provider is mounted.
// TriageRound reads it this way so it still renders without a provider; Not mine
// then skips the set-aside undo.
export function useOptionalTriageUndo(): TriageUndoContextValue | null {
return useContext(TriageUndoContext);
}
export function TriageUndoProvider({
curatorId,
sessionId,
collectionId,
children,
}: {
curatorId: string;
sessionId: string;
collectionId: string;
children: React.ReactNode;
}) {
const key = useMemo<QueueKey>(
() => ({ curatorId, sessionId, collectionId }),
[curatorId, sessionId, collectionId],
);
const [undo, setUndo] = useState<Undo | null>(null);
const [error, setError] = useState("");
const inFlight = useRef(false);
function dispositionEntry(
itemId: string,
disposition: "set_aside" | "deferred" | "clear",
): PendingEntry {
return {
kind: "disposition",
clientKey: newClientKey(),
itemId,
disposition,
enqueuedAt: new Date().toISOString(),
};
}
async function setAside(itemId: string, name: string | null) {
if (inFlight.current) return; // a double-tap before the button disables is a no-op
inFlight.current = true;
setError("");
try {
const entry = dispositionEntry(itemId, "set_aside");
const persisted = await enqueue(key, entry, () => triggerFlush(key));
if (persisted === "needs_fallback") {
// Storage unavailable even after a flush: degrade to today's awaited
// direct submit (F18) — nothing is queued, so the undo must DELETE.
if (!(await directSubmit({ sessionId, collectionId }, entry))) throw new Error("set aside failed");
setUndo({ itemId, name, clientKey: null });
return;
}
void triggerFlush(key);
// Replace any prior undo with the item just set aside: the provider sits
// above the item branch, so this state rides over the next item — or over
// the empty state when the queue empties.
setUndo({ itemId, name, clientKey: entry.clientKey });
} catch {
setError("That didn't go through — give it another try in a moment.");
} finally {
inFlight.current = false;
}
}
async function bringBack() {
Iif (!undo || inFlight.current) return;
inFlight.current = true;
setError("");
try {
const { itemId, clientKey } = undo;
// Still queued and not inside an active request → the answer never left
// the device; removing the entry IS the undo.
if (clientKey !== null && !isInFlight(key, clientKey) && removeQueued(key, clientKey)) {
setUndo(null);
return;
}
// Flushed or in flight: a `clear` entry rides the queue behind it; the
// flusher's DELETE is idempotent, so ordering is safe either way.
const clear = dispositionEntry(itemId, "clear");
const persisted = await enqueue(key, clear, () => triggerFlush(key));
if (persisted === "needs_fallback") {
Iif (!(await directSubmit({ sessionId, collectionId }, clear))) throw new Error("bring back failed");
} else {
void triggerFlush(key);
}
setUndo(null);
} catch {
setError("That didn't go through — give it another try in a moment.");
} finally {
inFlight.current = false;
}
}
return (
<TriageUndoContext.Provider value={{ undo, error, setAside, bringBack }}>
{children}
</TriageUndoContext.Provider>
);
}
|