Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion packages/dashboard/app/orders/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { AuthExpiredError, listOrders } from '@/lib/api';
import {
AuthExpiredError,
listOrders,
listOrderEvents,
type OrderEvent,
} from '@/lib/api';
import { txUrl } from '@/lib/explorer';
import { OrderTimeline } from '@/components/order-timeline';

type OrderRow = {
id: string;
Expand Down Expand Up @@ -40,6 +46,16 @@ export default async function OrderDetailPage({
if (!order) {
return <main>order not found</main>;
}

// Lifecycle events drive the timeline + AI-review evidence. Best-effort: the
// page still renders if the events endpoint is unavailable.
let events: OrderEvent[] = [];
try {
({ events } = await listOrderEvents(id));
} catch (err) {
if (err instanceof AuthExpiredError) redirect('/api/auth/signin');
// non-fatal: fall through with an empty timeline
}
return (
<main className="space-y-4">
<h2 className="text-xl font-semibold">{order.description}</h2>
Expand Down Expand Up @@ -86,6 +102,9 @@ export default async function OrderDetailPage({
</>
)}
</dl>

{events.length > 0 && <OrderTimeline events={events} />}

{order.bookkeepingArtifacts && (
<section className="rounded-md border border-emerald-200 bg-emerald-50 p-4 text-sm">
<h3 className="mb-2 font-medium">経理処理</h3>
Expand Down
112 changes: 112 additions & 0 deletions packages/dashboard/components/order-timeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { OrderEvent } from '@/lib/api';
import {
buildTimeline,
mergeToSettledSeconds,
latestReview,
} from '@/lib/order-timeline';

function fmt(at?: string): string {
return at ? new Date(at).toLocaleString('ja-JP') : '';
}

export function OrderTimeline({ events }: { events: OrderEvent[] }) {
const steps = buildTimeline(events);
const latency = mergeToSettledSeconds(events);
const review = latestReview(events);

return (
<div className="space-y-5">
{/* Headline metric: merge → JPYC arrival */}
{latency !== undefined && (
<div className="rounded-md border border-[var(--gigflow-pink)]/40 bg-pink-50/50 p-4">
<div className="text-xs text-neutral-500">PR マージ → JPYC 着金</div>
<div className="mt-1 text-3xl font-extrabold tabular-nums text-[var(--gigflow-pink)]">
{latency.toFixed(1)}
<span className="ml-1 text-base font-bold text-neutral-500">秒</span>
</div>
<div className="mt-1 text-xs text-neutral-500">
銀行振込の「翌月末 + 3〜5日」を、約 {latency.toFixed(1)} 秒に。
</div>
</div>
)}

{/* Lifecycle timeline */}
<div>
<div className="mb-2 text-xs font-semibold text-neutral-700">
ライフサイクル
</div>
<ol className="relative ml-2 border-l border-neutral-200">
{steps.map((s) => (
<li key={s.key} className="mb-4 ml-4">
<span
className={`absolute -left-[7px] mt-1 h-3 w-3 rounded-full border-2 ${
s.done
? 'border-emerald-500 bg-emerald-400'
: 'border-neutral-300 bg-white'
}`}
aria-hidden
/>
<div className="flex items-center justify-between">
<span
className={`text-sm font-medium ${
s.done ? 'text-neutral-800' : 'text-neutral-400'
}`}
>
{s.label}
</span>
{s.at && (
<span className="text-xs tabular-nums text-neutral-400">
{fmt(s.at)}
</span>
)}
</div>
</li>
))}
</ol>
</div>

{/* AI review evidence (criteria × met × cited evidence) */}
{review && (
<div className="rounded-md border border-neutral-200 p-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-semibold text-neutral-700">
AI 検収結果
</span>
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${
review.verdict === 'approve'
? 'bg-emerald-100 text-emerald-700'
: 'bg-red-100 text-red-700'
}`}
>
{review.verdict === 'approve' ? '合格' : '差し戻し'}
{review.qualityScore !== undefined && ` ・ ${review.qualityScore}/100`}
</span>
</div>
<ul className="space-y-2">
{review.criteriaResults.map((c, i) => (
<li key={i} className="text-sm">
<div className="flex items-start gap-2">
<span className={c.met ? 'text-emerald-600' : 'text-red-600'}>
{c.met ? '✅' : '❌'}
</span>
<span className="font-medium text-neutral-800">
{c.criterion}
</span>
</div>
{c.evidence && (
<p className="ml-6 mt-0.5 whitespace-pre-wrap break-words text-xs text-neutral-500">
{c.evidence}
</p>
)}
</li>
))}
</ul>
<p className="mt-3 text-xs text-neutral-400">
各基準の合否を diff から根拠引用つきで判定(Foundry gpt-5.1)。
</p>
</div>
)}
</div>
);
}
20 changes: 20 additions & 0 deletions packages/dashboard/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ export async function createOrder(body: {
return res.json();
}

export type OrderEvent = {
id: string;
orderId: string;
agent?: string;
type: string;
payload?: Record<string, unknown>;
createdAt: string;
};

export async function listOrderEvents(
orderId: string,
): Promise<{ events: OrderEvent[] }> {
const res = await authedFetch(
`/api/orders/${encodeURIComponent(orderId)}/events`,
);
if (res.status === 404) return { events: [] };
if (!res.ok) throw new Error(`events failed: ${res.status}`);
return res.json();
}

export function functionsBaseUrl(): string {
return FUNCTIONS_BASE_URL;
}
59 changes: 59 additions & 0 deletions packages/dashboard/lib/order-timeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import {
buildTimeline,
mergeToSettledSeconds,
latestReview,
} from './order-timeline';
import type { OrderEvent } from './api';

function ev(type: string, createdAt: string, payload?: Record<string, unknown>): OrderEvent {
return { id: type + createdAt, orderId: 'o1', type, createdAt, payload };
}

describe('order-timeline', () => {
it('marks steps done when their event exists, pending otherwise', () => {
const events = [
ev('order_created', '2026-06-14T00:00:00Z'),
ev('pr_opened', '2026-06-14T00:01:00Z'),
];
const steps = buildTimeline(events);
const doneOf = (key: string) => steps.find((s) => s.key === key)?.done;
expect(doneOf('created')).toBe(true);
expect(doneOf('pr')).toBe(true);
expect(doneOf('settled')).toBe(false);
expect(doneOf('bookkept')).toBe(false);
});

it('computes merge→settled latency in seconds', () => {
const events = [
ev('settlement_started', '2026-06-14T00:00:00Z'),
ev('settlement_completed', '2026-06-14T00:00:03Z'),
];
expect(mergeToSettledSeconds(events)).toBe(3);
});

it('returns undefined latency when settlement is missing', () => {
expect(mergeToSettledSeconds([ev('pr_opened', '2026-06-14T00:00:00Z')])).toBeUndefined();
});

it('extracts the latest review verdict + criteria', () => {
const events = [
ev('review_failed', '2026-06-14T00:01:00Z', {
qualityScore: 60,
criteriaResults: [{ criterion: 'tests', met: false, evidence: 'none' }],
}),
ev('review_completed', '2026-06-14T00:05:00Z', {
qualityScore: 92,
criteriaResults: [{ criterion: 'tests', met: true, evidence: 'x.test.ts' }],
}),
];
const r = latestReview(events);
expect(r?.verdict).toBe('approve');
expect(r?.qualityScore).toBe(92);
expect(r?.criteriaResults[0]?.met).toBe(true);
});

it('returns undefined review when none has run', () => {
expect(latestReview([ev('order_created', '2026-06-14T00:00:00Z')])).toBeUndefined();
});
});
79 changes: 79 additions & 0 deletions packages/dashboard/lib/order-timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { OrderEvent } from './api';

// A single step shown in the order lifecycle timeline.
export type TimelineStep = {
key: string;
label: string;
at?: string; // ISO timestamp, if this step has happened
done: boolean;
};

// The canonical lifecycle order. Each step maps to one or more event types.
const STEPS: { key: string; label: string; types: string[] }[] = [
{ key: 'created', label: '発注(Issue 起票)', types: ['order_created', 'issue_created'] },
{ key: 'pr', label: 'PR 提出', types: ['pr_opened'] },
{ key: 'review', label: 'AI 検収', types: ['review_completed', 'review_failed', 'review_started'] },
{ key: 'settled', label: 'JPYC 着金', types: ['settlement_completed'] },
{ key: 'bookkept', label: '記帳', types: ['bookkeeping_completed'] },
];

function firstTime(events: OrderEvent[], types: string[]): string | undefined {
const hit = events.find((e) => types.includes(e.type));
return hit?.createdAt;
}

export function buildTimeline(events: OrderEvent[]): TimelineStep[] {
return STEPS.map((s) => {
const at = firstTime(events, s.types);
return { key: s.key, label: s.label, at, done: Boolean(at) };
});
}

/**
* The headline metric: seconds from PR merge to JPYC arrival on-chain.
* Uses pr_merged (or pr_opened as a fallback) → settlement_completed. Returns
* undefined if either end is missing.
*/
export function mergeToSettledSeconds(events: OrderEvent[]): number | undefined {
const mergedAt =
firstTime(events, ['pr_merged']) ?? firstTime(events, ['settlement_started']);
const settledAt = firstTime(events, ['settlement_completed']);
if (!mergedAt || !settledAt) return undefined;
const ms = Date.parse(settledAt) - Date.parse(mergedAt);
if (!Number.isFinite(ms) || ms < 0) return undefined;
return Math.round((ms / 1000) * 10) / 10;
}

export type CriterionResult = {
criterion: string;
met: boolean;
evidence: string;
};

export type ReviewSummary = {
verdict: 'approve' | 'reject' | string;
qualityScore?: number;
criteriaResults: CriterionResult[];
};

/**
* Pull the most recent review verdict + cited criteria out of the events.
* Review Agent writes criteriaResults into the review_completed / review_failed
* event payload. Returns undefined if no review has run yet.
*/
export function latestReview(events: OrderEvent[]): ReviewSummary | undefined {
const reviews = events.filter(
(e) => e.type === 'review_completed' || e.type === 'review_failed',
);
const last = reviews[reviews.length - 1];
if (!last?.payload) return undefined;
const p = last.payload as {
qualityScore?: number;
criteriaResults?: CriterionResult[];
};
return {
verdict: last.type === 'review_completed' ? 'approve' : 'reject',
qualityScore: typeof p.qualityScore === 'number' ? p.qualityScore : undefined,
criteriaResults: Array.isArray(p.criteriaResults) ? p.criteriaResults : [],
};
}
Loading
Loading