All files / src/features/sorting TriageUndoProvider.tsx

92% Statements 46/50
85.71% Branches 18/21
100% Functions 9/9
97.67% Lines 42/43

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 1574x                                                                                               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>
  );
}