Skip to content
Open
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
1 change: 1 addition & 0 deletions app/_components/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ITEMS: { href: string; label: string }[] = [
{ href: "/pipeline/extract", label: "Extract" },
{ href: "/pipeline/runs", label: "Runs" },
{ href: "/pipeline/audit", label: "Audit" },
{ href: "/compare", label: "Compare" },
{ href: "/usage", label: "Usage" }
];

Expand Down
74 changes: 74 additions & 0 deletions app/api/compare/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* GET /api/compare?left=<slug>&right=<slug>
*
* Returns the pairwise diff of two `extraction.json` files. Both query
* params accept a product slug — the latest extraction for each is loaded
* from disk and run through diffExtractions().
*
* Run-id resolution (compare two runs of the same product) is intentionally
* out of scope for this iteration; see the linked issue for the follow-up.
*
* Response shape:
* { left: ExtractionIdentity, right: ExtractionIdentity, diff: ExtractionDiff }
*
* Errors:
* 400 — missing query params
* 404 — either slug doesn't resolve to an extraction.json on disk
* 409 — both sides resolve but to incompatible categories
*/
import { NextRequest, NextResponse } from "next/server";
import { getExtraction } from "@/lib/extractions";
import { diffExtractions } from "@/lib/pipeline/extraction-diff";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

export async function GET(req: NextRequest) {
const url = new URL(req.url);
const leftSlug = (url.searchParams.get("left") ?? "").trim();
const rightSlug = (url.searchParams.get("right") ?? "").trim();

if (!leftSlug || !rightSlug) {
return NextResponse.json(
{ error: "left and right query params are required" },
{ status: 400 }
);
}
if (leftSlug === rightSlug) {
return NextResponse.json(
{ error: "left and right must reference different slugs" },
{ status: 400 }
);
}

const left = getExtraction(leftSlug);
if (!left) {
return NextResponse.json(
{ error: `no extraction.json found for slug "${leftSlug}"` },
{ status: 404 }
);
}
const right = getExtraction(rightSlug);
if (!right) {
return NextResponse.json(
{ error: `no extraction.json found for slug "${rightSlug}"` },
{ status: 404 }
);
}

const leftCategory = typeof left.category === "string" ? left.category : null;
const rightCategory = typeof right.category === "string" ? right.category : null;
if (leftCategory && rightCategory && leftCategory !== rightCategory) {
return NextResponse.json(
{
error: "cross-category comparison is not supported",
leftCategory,
rightCategory,
},
{ status: 409 }
);
}

const diff = diffExtractions(left, right);
return NextResponse.json(diff);
}
122 changes: 122 additions & 0 deletions app/compare/_components/ComparePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";

export interface PickerOption {
slug: string;
label: string;
category: string;
}

interface Props {
options: PickerOption[];
leftSlug: string | null;
rightSlug: string | null;
}

export default function ComparePicker({ options, leftSlug, rightSlug }: Props) {
const router = useRouter();
const searchParams = useSearchParams();

const setSide = useCallback(
(side: "left" | "right", slug: string) => {
const params = new URLSearchParams(searchParams?.toString() ?? "");
if (slug) {
params.set(side, slug);
} else {
params.delete(side);
}
const qs = params.toString();
router.push(qs ? `/compare?${qs}` : "/compare");
},
[router, searchParams]
);

const swap = useCallback(() => {
if (!leftSlug || !rightSlug) return;
const params = new URLSearchParams(searchParams?.toString() ?? "");
params.set("left", rightSlug);
params.set("right", leftSlug);
router.push(`/compare?${params.toString()}`);
}, [leftSlug, rightSlug, router, searchParams]);

const optionsByCategory = groupByCategory(options);

return (
<div className="flex flex-wrap items-end gap-3">
<SideSelect
label="Left"
value={leftSlug}
groups={optionsByCategory}
excluded={rightSlug}
onChange={(slug) => setSide("left", slug)}
/>
<button
type="button"
onClick={swap}
disabled={!leftSlug || !rightSlug}
className="px-2 py-1 rounded text-xs border border-white/10 bg-white/[0.04] text-white/80 hover:bg-white/[0.08] disabled:opacity-40 disabled:hover:bg-white/[0.04]"
title="Swap left and right"
>
⇄ swap
</button>
<SideSelect
label="Right"
value={rightSlug}
groups={optionsByCategory}
excluded={leftSlug}
onChange={(slug) => setSide("right", slug)}
/>
</div>
);
}

interface SideSelectProps {
label: string;
value: string | null;
groups: Map<string, PickerOption[]>;
excluded: string | null;
onChange: (slug: string) => void;
}

function SideSelect({ label, value, groups, excluded, onChange }: SideSelectProps) {
return (
<label className="flex flex-col gap-1 text-xs uppercase tracking-wider text-white/50">
{label}
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
className="rounded border border-white/10 bg-black/40 px-2 py-1 text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30 min-w-[280px]"
>
<option value="">— pick a product —</option>
{Array.from(groups.entries()).map(([category, items]) => (
<optgroup key={category} label={category}>
{items.map((opt) => (
<option
key={opt.slug}
value={opt.slug}
disabled={excluded === opt.slug}
>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
</label>
);
}

function groupByCategory(options: PickerOption[]): Map<string, PickerOption[]> {
const out = new Map<string, PickerOption[]>();
for (const opt of options) {
const list = out.get(opt.category) ?? [];
list.push(opt);
out.set(opt.category, list);
}
for (const list of out.values()) {
list.sort((a, b) => a.label.localeCompare(b.label));
}
return new Map(Array.from(out.entries()).sort((a, b) => a[0].localeCompare(b[0])));
}
Loading