Add optimistic writeback flow with kanban drag-drop transitions
This commit is contained in:
parent
2c80265258
commit
cc616c1543
9 changed files with 403 additions and 45 deletions
24
src/app/api/beads/read/route.ts
Normal file
24
src/app/api/beads/read/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { readIssuesFromDisk } from '../../../../lib/read-issues';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
|
||||
|
||||
try {
|
||||
const issues = await readIssuesFromDisk({ projectRoot });
|
||||
return NextResponse.json({ ok: true, issues });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'unknown',
|
||||
message: error instanceof Error ? error.message : 'Failed to read issues.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,5 +3,5 @@ import { readIssuesFromDisk } from '../lib/read-issues';
|
|||
|
||||
export default async function Page() {
|
||||
const issues = await readIssuesFromDisk();
|
||||
return <KanbanPage issues={issues} />;
|
||||
return <KanbanPage issues={issues} projectRoot={process.cwd()} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
import { KANBAN_STATUSES } from '../../lib/kanban';
|
||||
import { KANBAN_STATUSES, type KanbanStatus } from '../../lib/kanban';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
import { KanbanCard } from './kanban-card';
|
||||
|
|
@ -10,47 +11,153 @@ import { KanbanCard } from './kanban-card';
|
|||
interface KanbanBoardProps {
|
||||
columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>;
|
||||
selectedIssueId: string | null;
|
||||
pendingIssueIds: Set<string>;
|
||||
activeStatus: KanbanStatus | null;
|
||||
onActivateStatus: (status: KanbanStatus | null) => void;
|
||||
onMoveIssue: (issue: BeadIssue, targetStatus: KanbanStatus) => void;
|
||||
onSelect: (issue: BeadIssue) => void;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot: string }> = {
|
||||
open: { label: 'Open', dot: 'bg-sky-300' },
|
||||
open: { label: 'Open', dot: 'bg-zinc-300' },
|
||||
in_progress: { label: 'In Progress', dot: 'bg-amber-300' },
|
||||
blocked: { label: 'Blocked', dot: 'bg-rose-300' },
|
||||
deferred: { label: 'Deferred', dot: 'bg-slate-300' },
|
||||
deferred: { label: 'Deferred', dot: 'bg-stone-400' },
|
||||
closed: { label: 'Done', dot: 'bg-emerald-300' },
|
||||
};
|
||||
|
||||
const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = {
|
||||
open: 'bg-sky-500/10',
|
||||
open: 'bg-zinc-500/10',
|
||||
in_progress: 'bg-amber-500/10',
|
||||
blocked: 'bg-rose-500/10',
|
||||
deferred: 'bg-slate-500/10',
|
||||
deferred: 'bg-stone-500/10',
|
||||
closed: 'bg-emerald-500/10',
|
||||
};
|
||||
|
||||
export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) {
|
||||
export function KanbanBoard({ columns, selectedIssueId, pendingIssueIds, activeStatus, onActivateStatus, onMoveIssue, onSelect }: KanbanBoardProps) {
|
||||
const allIssues = KANBAN_STATUSES.flatMap((status) => columns[status]);
|
||||
|
||||
const issueLookup = new Map(allIssues.map((issue) => [issue.id, issue]));
|
||||
|
||||
const handleExpandAndSelect = (status: KanbanStatus, issue: BeadIssue) => {
|
||||
onActivateStatus(status);
|
||||
onSelect(issue);
|
||||
};
|
||||
|
||||
const onDragStart = (issue: BeadIssue, event: DragEvent<HTMLButtonElement>) => {
|
||||
event.dataTransfer.setData('application/x-bead-id', issue.id);
|
||||
event.dataTransfer.setData('application/x-bead-status', issue.status);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const onDropLane = (targetStatus: KanbanStatus, event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const issueId = event.dataTransfer.getData('application/x-bead-id');
|
||||
const sourceStatus = event.dataTransfer.getData('application/x-bead-status') as KanbanStatus;
|
||||
if (!issueId || !sourceStatus || sourceStatus === targetStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = issueLookup.get(issueId);
|
||||
if (!issue) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMoveIssue(issue, targetStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex min-w-fit snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain pb-2">
|
||||
<section className="grid min-h-[58vh] gap-2.5">
|
||||
{KANBAN_STATUSES.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className={`w-[clamp(17rem,24vw,22rem)] shrink-0 snap-start rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => onDropLane(status, event)}
|
||||
className={`rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5 transition ${
|
||||
activeStatus === status ? 'shadow-card' : 'opacity-90'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
|
||||
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
|
||||
{STATUS_META[status].label}
|
||||
</strong>
|
||||
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={activeStatus === status}
|
||||
onClick={() => {
|
||||
onActivateStatus(status);
|
||||
const firstIssue = columns[status][0];
|
||||
if (firstIssue) {
|
||||
onSelect(firstIssue);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded-lg px-1 py-0.5 text-left"
|
||||
>
|
||||
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
|
||||
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
|
||||
{STATUS_META[status].label}
|
||||
</strong>
|
||||
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
|
||||
</button>
|
||||
{activeStatus === status ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Minimize ${STATUS_META[status].label} lane`}
|
||||
onClick={() => onActivateStatus(null)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border-soft bg-surface-muted/60 text-sm text-text-muted hover:border-border-strong hover:text-text-body"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid h-[clamp(24rem,60vh,48rem)] content-start gap-2 overflow-y-auto pr-1">
|
||||
<AnimatePresence initial={false}>
|
||||
{columns[status].map((issue) => (
|
||||
<KanbanCard key={issue.id} issue={issue} selected={selectedIssueId === issue.id} onSelect={onSelect} />
|
||||
{activeStatus === status ? (
|
||||
<div className="mt-2 grid max-h-[50vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
<AnimatePresence initial={false}>
|
||||
{columns[status].map((issue) => (
|
||||
<KanbanCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
pending={pendingIssueIds.has(issue.id)}
|
||||
selected={selectedIssueId === issue.id}
|
||||
draggable={!pendingIssueIds.has(issue.id)}
|
||||
onNativeDragStart={onDragStart}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{columns[status].length === 0 ? (
|
||||
<div className="flex h-24 w-full items-center justify-center rounded-xl border border-dashed border-border-soft/80 bg-surface/35 text-xs text-text-muted">
|
||||
No beads
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{columns[status].slice(0, 6).map((issue) => (
|
||||
<button
|
||||
key={issue.id}
|
||||
type="button"
|
||||
onClick={() => handleExpandAndSelect(status, issue)}
|
||||
className="max-w-full rounded-lg border border-border-soft bg-surface-muted/60 px-2 py-1 text-left hover:border-border-strong hover:bg-surface-raised/70"
|
||||
title={issue.title}
|
||||
>
|
||||
<div className="font-mono text-[10px] text-text-muted">{issue.id}</div>
|
||||
<div className="line-clamp-1 text-xs font-medium text-text-body">{issue.title}</div>
|
||||
</button>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{columns[status].length > 6 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivateStatus(status)}
|
||||
className="rounded-lg border border-border-soft bg-surface/50 px-2 py-1 text-xs text-text-muted hover:bg-surface-muted/70"
|
||||
>
|
||||
+{columns[status].length - 6} more
|
||||
</button>
|
||||
) : null}
|
||||
{columns[status].length === 0 ? (
|
||||
<span className="rounded-lg border border-dashed border-border-soft/80 bg-surface/30 px-2 py-1 text-xs text-text-muted">
|
||||
No beads
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
|
|
@ -9,6 +10,9 @@ import { Chip } from '../shared/chip';
|
|||
interface KanbanCardProps {
|
||||
issue: BeadIssue;
|
||||
selected: boolean;
|
||||
pending?: boolean;
|
||||
draggable?: boolean;
|
||||
onNativeDragStart?: (issue: BeadIssue, event: DragEvent<HTMLButtonElement>) => void;
|
||||
onSelect: (issue: BeadIssue) => void;
|
||||
}
|
||||
|
||||
|
|
@ -19,7 +23,7 @@ function priorityClass(priority: number): string {
|
|||
case 1:
|
||||
return 'border-amber-300/40 bg-amber-500/20 text-amber-50';
|
||||
case 2:
|
||||
return 'border-sky-300/40 bg-sky-500/20 text-sky-50';
|
||||
return 'border-teal-300/40 bg-teal-500/20 text-teal-50';
|
||||
case 3:
|
||||
return 'border-slate-300/35 bg-slate-500/22 text-slate-50';
|
||||
default:
|
||||
|
|
@ -27,9 +31,9 @@ function priorityClass(priority: number): string {
|
|||
}
|
||||
}
|
||||
|
||||
export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
|
||||
export function KanbanCard({ issue, selected, pending = false, draggable = false, onNativeDragStart, onSelect }: KanbanCardProps) {
|
||||
const selectedClass = selected
|
||||
? 'border-cyan-300/80 bg-surface-raised shadow-card ring-1 ring-cyan-300/35'
|
||||
? 'border-amber-200/60 bg-surface-raised shadow-card ring-1 ring-amber-200/20'
|
||||
: 'border-border-soft bg-surface/95 shadow-[0_6px_18px_rgba(4,8,17,0.5)] hover:border-border-strong hover:bg-surface-raised/95';
|
||||
|
||||
return (
|
||||
|
|
@ -37,8 +41,12 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
|
|||
layout
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
type="button"
|
||||
draggable={draggable}
|
||||
onDragStartCapture={(event) => onNativeDragStart?.(issue, event)}
|
||||
onClick={() => onSelect(issue)}
|
||||
className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass}`}
|
||||
className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass} ${
|
||||
pending ? 'opacity-70' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-mono text-[11px] text-text-muted break-all">{issue.id}</div>
|
||||
<div className="mt-1 text-sm font-semibold leading-5 text-text-strong break-words">{issue.title}</div>
|
||||
|
|
@ -51,7 +59,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
|
|||
<Chip>{issue.issue_type}</Chip>
|
||||
<Chip tone="status">deps {issue.dependencies.length}</Chip>
|
||||
</div>
|
||||
<div className="mt-2 break-words font-mono text-xs text-cyan-100/90">
|
||||
<div className="mt-2 break-words font-mono text-xs text-amber-100/90">
|
||||
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
|
||||
</div>
|
||||
{issue.labels.length > 0 ? (
|
||||
|
|
@ -61,6 +69,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{pending ? <div className="mt-2 text-[11px] font-medium text-amber-200">Saving…</div> : null}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { KanbanFilterOptions } from '../../lib/kanban';
|
||||
import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban';
|
||||
import { buildKanbanColumns, buildKanbanStats, filterKanbanIssues } from '../../lib/kanban';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import { applyOptimisticStatus, planStatusTransition } from '../../lib/writeback';
|
||||
|
||||
import { KanbanBoard } from './kanban-board';
|
||||
import { KanbanControls } from './kanban-controls';
|
||||
|
|
@ -13,49 +14,154 @@ import { KanbanDetail } from './kanban-detail';
|
|||
|
||||
interface KanbanPageProps {
|
||||
issues: BeadIssue[];
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export function KanbanPage({ issues }: KanbanPageProps) {
|
||||
type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
|
||||
|
||||
interface MutationErrorResponse {
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
async function postMutation(operation: MutationOperation, body: Record<string, unknown>) {
|
||||
const response = await fetch(`/api/beads/${operation}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = (await response.json()) as { ok: boolean; error?: { message?: string } };
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.error?.message ?? `${operation} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIssues(projectRoot: string): Promise<BeadIssue[]> {
|
||||
const response = await fetch(`/api/beads/read?projectRoot=${encodeURIComponent(projectRoot)}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = (await response.json()) as { ok: boolean; issues?: BeadIssue[] } & MutationErrorResponse;
|
||||
if (!response.ok || !payload.ok || !payload.issues) {
|
||||
throw new Error(payload.error?.message ?? 'Failed to refresh issues');
|
||||
}
|
||||
return payload.issues;
|
||||
}
|
||||
|
||||
export function KanbanPage({ issues, projectRoot }: KanbanPageProps) {
|
||||
const [localIssues, setLocalIssues] = useState<BeadIssue[]>(issues);
|
||||
const [filters, setFilters] = useState<KanbanFilterOptions>({
|
||||
query: '',
|
||||
type: '',
|
||||
priority: '',
|
||||
showClosed: false,
|
||||
showClosed: true,
|
||||
});
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(issues[0]?.id ?? null);
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(null);
|
||||
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
|
||||
const [activeStatus, setActiveStatus] = useState<KanbanStatus | null>('open');
|
||||
const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false);
|
||||
const [pendingIssueIds, setPendingIssueIds] = useState<Set<string>>(new Set());
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
|
||||
const filteredIssues = useMemo(() => filterKanbanIssues(issues, filters), [issues, filters]);
|
||||
useEffect(() => {
|
||||
setLocalIssues(issues);
|
||||
}, [issues]);
|
||||
|
||||
const filteredIssues = useMemo(() => filterKanbanIssues(localIssues, filters), [localIssues, filters]);
|
||||
const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]);
|
||||
const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]);
|
||||
|
||||
const selectedIssue = useMemo(
|
||||
() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? filteredIssues[0] ?? null,
|
||||
[filteredIssues, selectedIssueId],
|
||||
);
|
||||
const selectedIssue = useMemo(() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? null, [filteredIssues, selectedIssueId]);
|
||||
const showDesktopDetail = Boolean(selectedIssue) && !desktopDetailMinimized;
|
||||
|
||||
const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => {
|
||||
const steps = planStatusTransition(issue, targetStatus);
|
||||
if (steps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMutationError(null);
|
||||
const previous = localIssues;
|
||||
setPendingIssueIds((value) => new Set(value).add(issue.id));
|
||||
setLocalIssues((current) => applyOptimisticStatus(current, issue.id, targetStatus));
|
||||
|
||||
try {
|
||||
for (const step of steps) {
|
||||
await postMutation(step.operation, {
|
||||
projectRoot,
|
||||
...step.payload,
|
||||
});
|
||||
}
|
||||
|
||||
const reconciled = await fetchIssues(projectRoot);
|
||||
setLocalIssues(reconciled);
|
||||
} catch (error) {
|
||||
setLocalIssues(previous);
|
||||
setMutationError(error instanceof Error ? error.message : 'Mutation failed');
|
||||
} finally {
|
||||
setPendingIssueIds((value) => {
|
||||
const next = new Set(value);
|
||||
next.delete(issue.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto min-h-screen max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6">
|
||||
<header className="mb-4 rounded-2xl border border-border-soft bg-surface/90 px-4 py-4 shadow-card backdrop-blur md:px-5">
|
||||
<p className="font-mono text-xs uppercase tracking-[0.14em] text-cyan-100/80">BeadBoard</p>
|
||||
<p className="font-mono text-xs uppercase tracking-[0.14em] text-text-muted">BeadBoard</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-text-strong sm:text-3xl">Kanban Dashboard</h1>
|
||||
<p className="mt-2 text-sm text-text-muted">Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.</p>
|
||||
</header>
|
||||
<KanbanControls filters={filters} stats={stats} onFiltersChange={setFilters} />
|
||||
<section className="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,24rem)] xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
|
||||
<motion.div layout className="overflow-x-auto rounded-2xl border border-border-soft bg-surface/80 p-2.5 shadow-card">
|
||||
{mutationError ? (
|
||||
<div className="mt-3 rounded-xl border border-rose-300/40 bg-rose-950/40 px-3 py-2 text-sm text-rose-100">{mutationError}</div>
|
||||
) : null}
|
||||
<section
|
||||
className={`mt-3 overflow-hidden rounded-2xl border border-border-soft bg-surface/82 shadow-card ${
|
||||
showDesktopDetail ? 'lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]' : ''
|
||||
}`}
|
||||
>
|
||||
<motion.div layout className="p-2.5 sm:p-3">
|
||||
<KanbanBoard
|
||||
columns={columns}
|
||||
selectedIssueId={selectedIssue?.id ?? null}
|
||||
pendingIssueIds={pendingIssueIds}
|
||||
activeStatus={activeStatus}
|
||||
onActivateStatus={setActiveStatus}
|
||||
onMoveIssue={mutateStatus}
|
||||
onSelect={(issue) => {
|
||||
setSelectedIssueId(issue.id);
|
||||
setDesktopDetailMinimized(false);
|
||||
setMobileDetailOpen(true);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className="hidden lg:sticky lg:top-4 lg:block lg:self-start">
|
||||
<KanbanDetail issue={selectedIssue} />
|
||||
</div>
|
||||
{showDesktopDetail ? (
|
||||
<div className="hidden border-t border-border-soft bg-surface/72 p-3 lg:block lg:border-l lg:border-t-0">
|
||||
<aside className="rounded-xl border border-border-soft bg-surface/78 p-3">
|
||||
<div className="mb-2 flex items-center justify-end gap-2 border-b border-border-soft pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDesktopDetailMinimized(true)}
|
||||
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body"
|
||||
>
|
||||
Minimize
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIssueId(null)}
|
||||
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-muted"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-16rem)] overflow-y-auto pr-1">
|
||||
<KanbanDetail issue={selectedIssue} framed={false} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{mobileDetailOpen && selectedIssue ? (
|
||||
|
|
|
|||
56
src/lib/writeback.ts
Normal file
56
src/lib/writeback.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { BeadIssue, BeadStatus } from './types';
|
||||
|
||||
export type MutationStep =
|
||||
| { operation: 'close'; payload: { id: string; reason?: string } }
|
||||
| { operation: 'reopen'; payload: { id: string; reason?: string } }
|
||||
| { operation: 'update'; payload: { id: string; status: 'open' | 'in_progress' | 'blocked' | 'deferred' } };
|
||||
|
||||
function isBoardStatus(status: BeadStatus): status is 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed' {
|
||||
return ['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status);
|
||||
}
|
||||
|
||||
export function planStatusTransition(
|
||||
issue: Pick<BeadIssue, 'id' | 'status'>,
|
||||
targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed',
|
||||
): MutationStep[] {
|
||||
if (!isBoardStatus(issue.status) || issue.status === targetStatus) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (targetStatus === 'closed') {
|
||||
return [{ operation: 'close', payload: { id: issue.id, reason: 'Moved to closed via board drag-and-drop' } }];
|
||||
}
|
||||
|
||||
if (issue.status === 'closed') {
|
||||
if (targetStatus === 'open') {
|
||||
return [{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } }];
|
||||
}
|
||||
|
||||
return [
|
||||
{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } },
|
||||
{ operation: 'update', payload: { id: issue.id, status: targetStatus } },
|
||||
];
|
||||
}
|
||||
|
||||
return [{ operation: 'update', payload: { id: issue.id, status: targetStatus } }];
|
||||
}
|
||||
|
||||
export function applyOptimisticStatus(
|
||||
issues: BeadIssue[],
|
||||
issueId: string,
|
||||
targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed',
|
||||
atIso: string = new Date().toISOString(),
|
||||
): BeadIssue[] {
|
||||
return issues.map((issue) => {
|
||||
if (issue.id !== issueId) {
|
||||
return issue;
|
||||
}
|
||||
|
||||
return {
|
||||
...issue,
|
||||
status: targetStatus,
|
||||
updated_at: atIso,
|
||||
closed_at: targetStatus === 'closed' ? atIso : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue