All files / src/features/items InYourLifeChip.tsx

97.56% Statements 40/41
95% Branches 19/20
100% Functions 4/4
100% Lines 36/36

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 793x             3x 3x               27x 27x 27x 27x   27x 27x 1x 1x   27x 27x   8x 8x 8x 8x 8x 8x 8x 8x 3x   3x 3x 2x 2x   7x           7x 7x   3x 3x   8x     27x         54x 54x 8x                        
"use client";
import { useId, useState } from "react";
import { useRouter } from "next/navigation";
import type { UsageAnswer } from "@/lib/derive-usage";
import { CheckIcon } from "@/components/ui/CheckIcon";
import { IN_LIFE_CHIP, RESTING_CHIP } from "./usage-chip-copy";
 
const OPTIONS: [UsageAnswer, string][] = [["uses", "I use this"], ["not_uses", RESTING_CHIP]];
const JSON_HEADERS = { "Content-Type": "application/json" };
 
interface Props { collectionId: string; itemId: string; sessionId: string | null; initial?: UsageAnswer }
 
// Unset until tapped (untouched means no decision); once set it toggles between the two
// answers. Writes a `usage` event through the decision route, which needs the curator's
// in-progress session — with none open, the first tap starts one.
export function InYourLifeChip({ collectionId, itemId, sessionId, initial }: Props) {
  const labelId = useId();
  const router = useRouter();
  const [value, setValue] = useState(initial);
  const [sid, setSid] = useState(sessionId);
  // A refresh that brings a different open session re-seeds the local id.
  const [seenSessionId, setSeenSessionId] = useState(sessionId);
  if (sessionId !== seenSessionId) {
    setSeenSessionId(sessionId);
    setSid(sessionId);
  }
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  async function choose(next: UsageAnswer) {
    Iif (busy || next === value) return;
    const prev = value;
    setValue(next);
    setBusy(true);
    setError("");
    try {
      let id = sid;
      if (!id) {
        const res = await fetch("/api/sessions", { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ collectionId }) });
        // 409: a session opened elsewhere holds the slot — re-read it so the next tap uses it.
        if (res.status === 409) router.refresh();
        if (!res.ok) throw new Error("session");
        id = ((await res.json()) as { id: string }).id;
        setSid(id);
      }
      const res = await fetch(`/api/sessions/${id}/decisions`, {
        method: "POST",
        headers: JSON_HEADERS,
        body: JSON.stringify({ type: "usage", decision: { itemId, result: next } }),
      });
      // 409: that session finished elsewhere — forget it so the next tap starts a fresh one.
      if (res.status === 409) setSid(null);
      if (!res.ok) throw new Error("decision");
    } catch {
      setValue(prev);
      setError("That didn't save — try again.");
    } finally {
      setBusy(false);
    }
  }
  return (
    <div role="group" aria-labelledby={labelId} className="space-y-2">
      <p id={labelId} className="text-sm font-semibold text-ink">{IN_LIFE_CHIP}</p>
      <div className="flex flex-wrap gap-2">
        {OPTIONS.map(([v, label]) => {
          const on = value === v;
          return (
            <button key={v} type="button" aria-pressed={on} onClick={() => choose(v)}
              className={`inline-flex h-11 items-center gap-2 rounded-pill border px-4 text-sm transition-colors ${on ? "border-primary bg-chip font-semibold text-chip-ink" : "border-hairline text-ink"}`}>
              {on && <CheckIcon />}
              {label}
            </button>
          );
        })}
      </div>
      <p role="status" className="text-sm text-ink-soft">{error}</p>
    </div>
  );
}