diff --git a/src/app/(app)/issues/page.tsx b/src/app/(app)/issues/page.tsx
index 81819a4..2651b38 100644
--- a/src/app/(app)/issues/page.tsx
+++ b/src/app/(app)/issues/page.tsx
@@ -31,6 +31,7 @@ import {
} from "@/components/issue-empty";
import { SelectMenu } from "@/components/select-menu";
import { WatchQueue } from "@/components/watch-queue";
+import { FixQueue } from "@/components/fix-queue";
import { WATCH_EMPTY } from "@/lib/watch-copy";
/**
@@ -155,11 +156,21 @@ export default function IssuesPage() {
return
+ {notes} +
+{conflicting diff --git a/src/components/copy-ticket-button.tsx b/src/components/copy-ticket-button.tsx new file mode 100644 index 0000000..3d6f491 --- /dev/null +++ b/src/components/copy-ticket-button.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { FIX_ACTION_COPY_TICKET, FIX_TICKET_COPIED } from "@/lib/fix-copy"; + +/** + * "Copy as ticket" — the case, as plain markdown, on the clipboard. + * + * The whole integration surface. There is no tracker connection behind this and + * there is not going to be one: a connection is a second place the work's state + * lives, and a second place the work's state lives is a place it disagrees with + * the first. That is the defect the case object exists to remove, and adding it + * back across a network boundary would put it somewhere nobody can reconcile it. + * A person pressing this knows they took a copy and knows when. + * + * The confirmation is inline and it expires. A toast would appear somewhere else + * on the screen, which for an action this local means the reader has to look + * away from the thing they just acted on to find out whether it worked. + * + * The failure message is not from the locked list because the locked list has no + * failure message. A clipboard write can be refused — an insecure origin, a + * permissions policy, a browser that has never had the API — and a button that + * says "Copied" when nothing was copied is worse than one that says it could + * not: the reader pastes an empty ticket into a message and finds out later. + */ + +/** How long the confirmation stays up. Long enough to read, short enough to not be furniture. */ +const CONFIRMATION_MS = 4000; + +const COPY_FAILED = "Couldn't reach the clipboard — copy it from the case instead."; + +export interface CopyTicketButtonProps { + /** + * The ticket, built by the caller. + * + * A string rather than the case, so this component knows nothing about what a + * ticket contains and cannot grow a second opinion about it. `ticketMarkdown` + * is the one writer. + */ + ticket: string; +} + +export function CopyTicketButton({ ticket }: CopyTicketButtonProps) { + const [result, setResult] = useState<"copied" | "failed" | null>(null); + + useEffect(() => { + if (!result) return; + const timer = setTimeout(() => setResult(null), CONFIRMATION_MS); + return () => clearTimeout(timer); + }, [result]); + + const copy = useCallback(() => { + void (async () => { + try { + await navigator.clipboard.writeText(ticket); + setResult("copied"); + } catch { + setResult("failed"); + } + })(); + }, [ticket]); + + return ( + // A column, because the header stacks its actions and a confirmation beside + // the button would widen the whole column by a sentence. + + + + {/* Announced when it arrives, because a reader who pressed the button with + the keyboard has no other way to learn that it worked. */} + + {result === "copied" ? FIX_TICKET_COPIED : result === "failed" ? COPY_FAILED : ""} + + + ); +} diff --git a/src/components/fix-queue.tsx b/src/components/fix-queue.tsx new file mode 100644 index 0000000..1771ddb --- /dev/null +++ b/src/components/fix-queue.tsx @@ -0,0 +1,96 @@ +import { byWorstMeasured, type IssueCase } from "@/lib/issue-case"; +import { FIX_GROUPS, FIX_QUEUE_NOTE } from "@/lib/fix-copy"; +import { WORK_STATE_LABEL } from "@/lib/vocabulary"; +import { FixRow } from "@/components/fix-row"; + +/** + * The Fix tab: what has been committed to, and what is being worked on. + * + * Two groups, To do above In progress, in the order the registry holds them. + * That order is the reading order of the queue rather than a ranking: To do is + * the shelf you take from and In progress is what is off the shelf, so the + * choice comes before the check. + * + * Impact-ordered inside each group, worst measured first, with unmeasured + * findings moved as a block rather than sorted by their zero (rule 18). It does + * not take the list's sort control. The list sorts because it is being triaged + * and there are several honest ways to read a queue of undecided things; this is + * a queue of decided things, and the only question left is which to do next. + * + * One nudge, stated once at the top and rendered once per row: the started date, + * amber after thirty days. Nothing escalates after it. That is written down + * where the reader can see it, because a reader who has been told there is no + * escalation can leave a case for thirty-one days on purpose — and a reader who + * suspects there might be one starts managing the queue instead of the work. + */ + +export interface FixQueueProps { + cases: readonly IssueCase[]; + basePath: string; + now?: Date; +} + +export function FixQueue({ cases, basePath, now }: FixQueueProps) { + const groups = FIX_GROUPS.map((state) => ({ + state, + // A group is impact-ordered within itself and never against the other one. + // The two are answering different questions, so interleaving them would put + // a job nobody has started above one somebody is holding on the strength of + // a number that says nothing about either. + cases: cases.filter((item) => item.state === state).sort(byWorstMeasured), + })).filter((group) => group.cases.length > 0); + + return ( +
+ {FIX_QUEUE_NOTE} +
+ +