feat(card): progress narrative and humanised commit history

This commit is contained in:
Dave 2026-09-16 16:25:19 +02:00
parent 30446153c8
commit 8711c36833
4 changed files with 135 additions and 0 deletions

View file

@ -6,6 +6,8 @@ import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { CardHeader } from "@/components/card/card-header";
import { CardActions } from "@/components/card/card-actions";
import { CardNarrative } from "@/components/card/card-narrative";
import { CommitHistory } from "@/components/card/commit-history";
import { SubtaskList } from "@/components/card/subtask-list";
import { CommentThread } from "@/components/card/comment-thread";
import { ActivityTimeline } from "@/components/card/activity-timeline";
@ -64,6 +66,9 @@ export default async function CardPage({
projectSlug={projectSlug}
/>
<CardNarrative narrative={detail.card.narrative ?? ""} />
<CommitHistory commits={detail.commits} />
<SubtaskList
cardId={detail.card.id}
subtasks={detail.subtasks}

View file

@ -0,0 +1,34 @@
/**
* The generated progress update.
*
* Labelled as written from the commit history — the same honesty the dashboard's
* methodology panel applies to the effort figures. A client should never have to
* wonder whether a person wrote this.
*/
export function CardNarrative({ narrative }: { narrative: string }) {
if (!narrative.trim()) return null;
return (
<section className="space-y-2 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Progress
</h2>
<div className="space-y-2 text-sm text-text-secondary">
{narrative
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph, index) => (
<p key={index} className="whitespace-pre-wrap">
{paragraph}
</p>
))}
</div>
<p className="text-[11px] text-text-muted">
Written automatically from the change history for this item.
</p>
</section>
);
}

View file

@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { Code2 } from "lucide-react";
import { relativeTime } from "@/lib/time/relative";
import type { CommitRecord } from "@/lib/pb/types";
/**
* A card's commits, in the client's language.
*
* Two rules, both from the spec:
* - A commit with no summary shows its raw subject. Never a gap — a card that
* renders blank because the provider was down looks like no work happened.
* - The raw commit is always one toggle away. The humanised text is a
* translation, not a replacement, and a client who wants to check it must be
* able to.
*/
export function CommitHistory({ commits }: { commits: CommitRecord[] }) {
const [showRaw, setShowRaw] = useState(false);
if (commits.length === 0) return null;
return (
<section className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Work done
</h2>
<span className="tabular text-xs text-text-muted">
{commits.length} {commits.length === 1 ? "change" : "changes"}
</span>
<button
type="button"
onClick={() => setShowRaw((value) => !value)}
aria-pressed={showRaw}
className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-text-muted hover:text-text-secondary"
>
<Code2 size={12} strokeWidth={2} />
{showRaw ? "Hide technical detail" : "Show technical detail"}
</button>
</div>
<ol className="space-y-2.5">
{commits.map((commit) => (
<li key={commit.id} className="flex gap-2.5">
<span
aria-hidden
className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-border-strong"
/>
<div className="min-w-0 flex-1">
<p className="text-sm text-text-secondary">
{/* Fall back to the raw subject — never render nothing. */}
{commit.summary || commit.subject}
</p>
<p className="mt-0.5 text-[11px] text-text-muted">
{relativeTime(commit.authored_at)}
</p>
{showRaw && (
<p className="mt-1.5 rounded bg-surface px-2 py-1.5 font-mono text-[11px] text-text-muted">
{commit.url ? (
<a
href={commit.url}
target="_blank"
rel="noreferrer"
className="hover:text-brand"
>
{commit.short_sha}
</a>
) : (
commit.short_sha
)}{" "}
{commit.subject}
</p>
)}
</div>
</li>
))}
</ol>
</section>
);
}

View file

@ -7,6 +7,7 @@ import type {
ClientRecord,
ColumnRecord,
CommentRecord,
CommitRecord,
ProjectRecord,
SubtaskRecord,
UserRecord,
@ -23,6 +24,10 @@ export interface CardDetail {
activity: ActivityRecord[];
/** Display names by user id, for comments and the timeline. */
names: Record<string, string>;
/** Non-merge commits linked to this card, oldest first, with AI summaries
* when present and the raw subject otherwise. Used by the Progress section
* and the humanised commit history on the card page. */
commits: CommitRecord[];
}
/**
@ -83,6 +88,13 @@ export async function loadCardDetail(
sort: "-created",
});
// Linked commits, oldest first, in plain English where available. Used by
// the Progress section and the humanised commit history on the card page.
const commits = await pb.collection("commits").getFullList<CommitRecord>({
filter: `card = "${cardId}" && is_merge = false`,
sort: "authored_at",
});
const userIds = new Set<string>();
for (const c of comments) if (c.author) userIds.add(c.author);
for (const a of activity) if (a.actor) userIds.add(a.actor);
@ -107,6 +119,7 @@ export async function loadCardDetail(
comments,
activity,
names,
commits,
};
} catch {
return null;