All files / src/features/sorting TriageRound.tsx

100% Statements 107/107
92.59% Branches 50/54
100% Functions 37/37
100% Lines 76/76

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 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 2393x                                                 64x 3x 33x 33x 33x                                                   97x 97x 97x 97x 97x 97x 97x 97x   204x 97x 97x   97x 97x 97x 97x     97x 97x 97x 56x 56x 56x 1x 1x     97x 97x     97x 71x 2x 2x       97x 97x 71x 4x 4x       28x 27x 27x 27x 27x         6x 6x 5x 6x 3x 3x   2x 2x 2x 2x       2x 2x       5x 5x   1x 1x   4x             4x   4x 7x       2x 2x 2x               97x   97x   107x 16x   97x                                                                                                         28x           5x 2x                    
"use client";
 
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { Item } from "@/types";
import { Card } from "@/components/ui";
import { ItemCard } from "@/features/items/ItemCard";
import { DecisionControls } from "./DecisionControls";
import { triageDecisions, type TriageResult } from "./roundCopy";
import { DeferControl } from "./DeferControl";
import { SetAsideButton } from "./SetAsideButton";
import { useOptionalTriageUndo } from "./TriageUndoProvider";
import { enqueue, newClientKey, type PendingEntry } from "./decisionQueue";
import { directSubmit } from "./directSubmit";
import { drain, triggerFlush } from "./decisionFlusher";
import { STUMBLE_LINE, useOptimisticRound } from "./useOptimisticRound";
import { PendingNotices } from "./pendingNotices";
import { SortProgress } from "./SortProgress";
 
export interface TriageRoundQueueEntry {
  item: Item;
  deferred: boolean;
  heroPhotoUrl?: string;
}
 
const add = (set: ReadonlySet<string>, id: string): ReadonlySet<string> => new Set(set).add(id);
const without = (set: ReadonlySet<string>, id: string): ReadonlySet<string> => {
  const next = new Set(set);
  next.delete(id);
  return next;
};
 
interface TriageRoundProps {
  curatorId: string;
  sessionId: string;
  /** ULID collection.id — the queue key + disposition endpoint (never the slug). */
  collectionId: string;
  /** collection.slug ?? collection.id — ItemCard's link base. */
  collectionSlugOrId: string;
  /** chooseSortPhase's feelings queue: active pieces first, then Not yet ones. */
  queue: TriageRoundQueueEntry[];
  /** Live pieces already placed before this visit (the progress seed). */
  placedSeed: number;
  /** Where the phase hands off once every piece has passed through (…/sort?phase=pairs). */
  nextHref: string;
}
 
/**
 * The feelings phase (spec §4): "How do you feel about this one?" for every live
 * piece, once. Answers advance from local state (persist-before-advance); Not yet
 * sends a piece to the end of the phase, and a second Not yet lets it rest; Not
 * mine sets it aside (with Bring back). When every piece has passed through, the
 * answers are landed and the screen hands off to pairs.
 */
export function TriageRound({ curatorId, sessionId, collectionId, collectionSlugOrId, queue, placedSeed, nextHref }: TriageRoundProps) {
  const router = useRouter();
  const [seed] = useState(placedSeed);
  const [deferredAtMount] = useState(() => new Set(queue.filter((e) => e.deferred).map((e) => e.item.id)));
  const [entries, setEntries] = useState<TriageRoundQueueEntry[]>(queue);
  const [handled, setHandled] = useState<ReadonlySet<string>>(new Set());
  const [placed, setPlaced] = useState<ReadonlySet<string>>(new Set());
  const [escaped, setEscaped] = useState<ReadonlySet<string>>(new Set());
  const undoCtx = useOptionalTriageUndo();
 
  const current = entries.find((e) => !handled.has(e.item.id));
  const round = useOptimisticRound({ curatorId, sessionId, collectionId });
  const { key, revisit } = round;
  // Mounted empty so the region exists before its text changes (SC 4.1.3).
  const [announcement, setAnnouncement] = useState("");
  const heading = useRef<HTMLHeadingElement>(null);
  const backControl = useRef<HTMLButtonElement>(null);
  const focusAfterUndo = useRef(false);
 
  // Bring back (provider's undo cleared) returns the piece to this phase.
  const lastUndoRef = useRef(undoCtx?.undo ?? null);
  const undoNow = undoCtx?.undo ?? null;
  useEffect(() => {
    const prev = lastUndoRef.current;
    lastUndoRef.current = undoNow;
    if (prev === null || undoNow !== null || !handled.has(prev.itemId)) return;
    setHandled((h) => without(h, prev.itemId));
    setEscaped((e) => without(e, prev.itemId));
  }, [undoNow, handled]);
 
  const revisitEntry = revisit === null ? undefined : entries.find((e) => e.item.id === revisit.itemId);
  const shown = revisitEntry ?? current;
 
  // Undo of the only queued answer takes its control away; land on the question.
  useEffect(() => {
    if (!focusAfterUndo.current) return;
    focusAfterUndo.current = false;
    Eif (!backControl.current) heading.current?.focus();
  });
 
  // Every piece has passed through: land the answers, then on to pairs (once).
  const handedOff = useRef(false);
  useEffect(() => {
    if (shown !== undefined || handedOff.current) return;
    handedOff.current = true;
    void drain([key]).finally(() => router.replace(nextHref));
  }, [shown, key, nextHref, router]);
 
  async function handleTriage(target: Item, decision: { itemId: string; result: TriageResult }): Promise<boolean> {
    if (!(await round.submitDecision(target.id, decision))) return false;
    setPlaced((p) => add(p, target.id));
    setEscaped((e) => without(e, target.id));
    setHandled((h) => add(h, target.id));
    return true;
  }
 
  // The piece changes under the person's focus, so say which one is in front of them.
  function undo() {
    const back = round.goBack();
    if (back === null) return;
    const name = entries.find((e) => e.item.id === back.itemId)?.item.name ?? "";
    if (!back.fresh) {
      setAnnouncement(`Back to ${name}. You placed this with ${back.label} — change it?`);
      return;
    }
    setPlaced((p) => without(p, back.itemId));
    setHandled((h) => without(h, back.itemId));
    setAnnouncement(`Back to ${name}.`);
    focusAfterUndo.current = true;
  }
 
  function keepGoing() {
    round.clearRevisit();
    Eif (current) setAnnouncement(`Back to ${current.item.name}.`);
  }
 
  async function handleNotYet(target: TriageRoundQueueEntry) {
    setEscaped((e) => add(e, target.item.id));
    if (target.deferred) {
      // Already waited once this visit (or came back waiting): let it rest so the phase can end.
      setHandled((h) => add(h, target.item.id));
      return;
    }
    const entry: PendingEntry = {
      kind: "disposition",
      clientKey: newClientKey(),
      itemId: target.item.id,
      disposition: "deferred",
      enqueuedAt: new Date().toISOString(),
    };
    const persisted = await enqueue(key, entry, () => triggerFlush(key));
    // best-effort: the piece still waits at the end and returns next visit
    if (persisted === "needs_fallback") await directSubmit({ sessionId, collectionId }, entry);
    setEntries((prev) => [...prev.filter((e) => e.item.id !== target.item.id), { ...target, deferred: true }]);
  }
 
  function handleNotMine(target: Item) {
    Eif (undoCtx) void undoCtx.setAside(target.id, target.name);
    setEscaped((e) => add(e, target.id));
    setHandled((h) => add(h, target.id));
  }
 
  // Placed so far over what this visit can still place: pieces placed before it
  // (seed), plus this visit's active pieces, plus any piece that came back waiting
  // and has now been placed (a waiting piece only counts once it is answered, so a
  // second Not yet never leaves a hole), minus the active pieces that left without a
  // feeling (Not yet / Not mine) — they are not "to place" any more this visit.
  const done = seed + placed.size;
  const total =
    seed +
    (queue.length - deferredAtMount.size) +
    [...placed].filter((id) => deferredAtMount.has(id)).length -
    [...escaped].filter((id) => !deferredAtMount.has(id)).length;
 
  return (
    <div className="space-y-6">
      <SortProgress done={done} total={total} />
      <p aria-live="polite" className="sr-only">
        {announcement}
      </p>
      <PendingNotices collectionId={collectionId} sessionId={sessionId} />
      {shown === undefined ? (
        <p role="status" className="text-sm text-ink-soft">
          Just a moment…
        </p>
      ) : (
        <div className="space-y-6">
          <div>
            <h1 ref={heading} tabIndex={-1} className="text-2xl text-ink focus:outline-hidden sm:text-3xl">
              How do you feel about this one?
            </h1>
            {/* One button that swaps Undo ↔ Keep going, so focus stays put (SC 2.4.3). */}
            {(round.canGoBack || revisit !== null) && (
              <button
                ref={backControl}
                type="button"
                onClick={revisit !== null ? keepGoing : undo}
                className="mt-1 inline-flex h-11 items-center rounded-control text-sm text-ink-soft transition-colors hover:text-ink focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-ground focus-visible:ring-primary"
              >
                {revisit !== null ? "Keep going" : `Undo: ${round.lastLabel}`}
              </button>
            )}
          </div>
 
          {round.stumble && (
            <p role="status" className="text-sm text-ink-soft">
              {STUMBLE_LINE}
            </p>
          )}
 
          {shown.deferred && (
            <div className="flex items-center gap-2 rounded-control bg-content-mid px-4 py-3 text-ink">
              <span aria-hidden className="text-lg leading-none">☾</span>
              <p className="text-sm">
                <span className="font-medium">Ready when you are.</span> This one was waiting for you — no rush.
              </p>
            </div>
          )}
 
          <ItemCard item={shown.item} collectionId={collectionSlugOrId} heroPhotoUrl={shown.heroPhotoUrl} imageClassName="h-64" />
          <Card padding="lg">
            <div className="space-y-4">
              {revisit !== null && (
                <p className="text-sm text-ink-soft">You placed this with {revisit.label} — change it?</p>
              )}
              {/* NOT keyed per piece: a re-keyed region is never announced (SC 4.1.3). */}
              <DecisionControls
                onDecision={(d) => handleTriage(shown.item, d)}
                decisions={triageDecisions(shown.item.id)}
                priorAnswer={revisit?.label}
              />
              {revisit === null && (
                <div className="flex flex-wrap items-start gap-x-6 gap-y-3 border-t border-hairline pt-3">
                  <DeferControl onDecision={() => void handleNotYet(shown)} />
                  <SetAsideButton onDecision={() => handleNotMine(shown.item)} />
                </div>
              )}
            </div>
          </Card>
        </div>
      )}
    </div>
  );
}