Skip to content

Commit e0874c2

Browse files
committed
feat(builder): AIC-163/164 — wire risk signals and "see alternatives" into slot list
1 parent f9b9602 commit e0874c2

16 files changed

Lines changed: 1334 additions & 65 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ rebuild: ## Full rebuild: install packages, build image, wipe volumes, start fre
2626
$(COMPOSE) build
2727
$(COMPOSE) down -v
2828
$(COMPOSE) up -d
29-
@echo "→ http://localhost:3000"
29+
@echo "→ http://localhost:3002"
3030

3131
logs: ## Tail container logs
3232
$(COMPOSE) logs -f

app/api/pulse/categories/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { NextResponse } from "next/server";
2+
import { getCategoryMomentum } from "@/lib/pulse";
3+
4+
export { type CategoryMomentum } from "@/lib/pulse";
5+
6+
export const revalidate = 3600;
7+
8+
export async function GET() {
9+
const categories = await getCategoryMomentum();
10+
return NextResponse.json({ categories });
11+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCategoryTools } from "@/lib/pulse";
3+
import { CATEGORIES } from "@/lib/types";
4+
import type { CategoryId } from "@/lib/types";
5+
6+
export const revalidate = 3600;
7+
8+
const validCategoryIds = new Set(CATEGORIES.map((c) => c.id));
9+
10+
export async function GET(
11+
_req: NextRequest,
12+
{ params }: { params: Promise<{ categoryId: string }> }
13+
) {
14+
const { categoryId } = await params;
15+
16+
if (!validCategoryIds.has(categoryId as CategoryId)) {
17+
return NextResponse.json({ error: "Category not found" }, { status: 404 });
18+
}
19+
20+
const tools = await getCategoryTools(categoryId as CategoryId);
21+
return NextResponse.json({ tools });
22+
}

app/api/pulse/events/route.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
export const dynamic = "force-dynamic";
2+
3+
import { createClient } from "@supabase/supabase-js";
4+
import { NextRequest, NextResponse } from "next/server";
5+
import type { ToolEventType, ToolEventMetadata } from "@/lib/types";
6+
7+
export type RiskSignal = "at_risk" | "pricing_changed" | "gaining_traction";
8+
9+
export interface ToolRiskSignal {
10+
tool_id: string;
11+
signal: RiskSignal | null;
12+
event_type: ToolEventType | null;
13+
detected_at: string | null;
14+
metadata: ToolEventMetadata | null;
15+
}
16+
17+
interface EventsResponse {
18+
signals: ToolRiskSignal[];
19+
}
20+
21+
// Signal priority: higher index = lower priority (at_risk wins)
22+
const SIGNAL_PRIORITY: RiskSignal[] = ["at_risk", "pricing_changed", "gaining_traction"];
23+
24+
function classifyEvent(type: ToolEventType, metadata: ToolEventMetadata): RiskSignal | null {
25+
switch (type) {
26+
case "stale_transition":
27+
case "archived_detected":
28+
return "at_risk";
29+
case "health_score_change": {
30+
const delta = (metadata as { delta?: number }).delta ?? 0;
31+
if (delta < -10) return "at_risk";
32+
if (delta > 10) return "gaining_traction";
33+
return null;
34+
}
35+
case "pricing_change":
36+
return "pricing_changed";
37+
case "star_milestone":
38+
return "gaining_traction";
39+
default:
40+
return null;
41+
}
42+
}
43+
44+
function getAnonClient() {
45+
const url = process.env.NEXT_PUBLIC_POSTGRES_SUPABASE_URL;
46+
const anon = process.env.NEXT_PUBLIC_POSTGRES_SUPABASE_ANON_KEY;
47+
if (!url || !anon) return null;
48+
return createClient(url, anon);
49+
}
50+
51+
export async function POST(req: NextRequest) {
52+
let body: unknown;
53+
try {
54+
body = await req.json();
55+
} catch {
56+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
57+
}
58+
59+
const tool_ids = (body as { tool_ids?: unknown }).tool_ids;
60+
if (!Array.isArray(tool_ids) || tool_ids.length === 0 || tool_ids.length > 30) {
61+
return NextResponse.json(
62+
{ error: "tool_ids must be a non-empty array of at most 30 items" },
63+
{ status: 400 }
64+
);
65+
}
66+
67+
const db = getAnonClient();
68+
if (!db) {
69+
const signals: ToolRiskSignal[] = tool_ids.map((id) => ({
70+
tool_id: id as string,
71+
signal: null,
72+
event_type: null,
73+
detected_at: null,
74+
metadata: null,
75+
}));
76+
return NextResponse.json({ signals } satisfies EventsResponse);
77+
}
78+
79+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
80+
81+
const { data: events, error } = await db
82+
.from("tool_events")
83+
.select("tool_id, type, detected_at, metadata")
84+
.in("tool_id", tool_ids)
85+
.gte("detected_at", thirtyDaysAgo)
86+
.order("detected_at", { ascending: false });
87+
88+
if (error) {
89+
const signals: ToolRiskSignal[] = tool_ids.map((id) => ({
90+
tool_id: id as string,
91+
signal: null,
92+
event_type: null,
93+
detected_at: null,
94+
metadata: null,
95+
}));
96+
return NextResponse.json({ signals } satisfies EventsResponse);
97+
}
98+
99+
// Build signal map: for each tool, keep the highest-priority signal
100+
const signalMap = new Map<string, ToolRiskSignal>();
101+
102+
for (const ev of events ?? []) {
103+
const signal = classifyEvent(
104+
ev.type as ToolEventType,
105+
(ev.metadata ?? {}) as ToolEventMetadata
106+
);
107+
if (!signal) continue;
108+
109+
const existing = signalMap.get(ev.tool_id);
110+
if (!existing) {
111+
signalMap.set(ev.tool_id, {
112+
tool_id: ev.tool_id as string,
113+
signal,
114+
event_type: ev.type as ToolEventType,
115+
detected_at: ev.detected_at as string,
116+
metadata: (ev.metadata ?? {}) as ToolEventMetadata,
117+
});
118+
} else {
119+
// Replace only if the new signal has higher priority
120+
const existingPriority = SIGNAL_PRIORITY.indexOf(existing.signal!);
121+
const newPriority = SIGNAL_PRIORITY.indexOf(signal);
122+
if (newPriority < existingPriority) {
123+
signalMap.set(ev.tool_id, {
124+
tool_id: ev.tool_id as string,
125+
signal,
126+
event_type: ev.type as ToolEventType,
127+
detected_at: ev.detected_at as string,
128+
metadata: (ev.metadata ?? {}) as ToolEventMetadata,
129+
});
130+
}
131+
}
132+
}
133+
134+
const signals: ToolRiskSignal[] = tool_ids.map((id) => {
135+
return (
136+
signalMap.get(id as string) ?? {
137+
tool_id: id as string,
138+
signal: null,
139+
event_type: null,
140+
detected_at: null,
141+
metadata: null,
142+
}
143+
);
144+
});
145+
146+
return NextResponse.json({ signals } satisfies EventsResponse);
147+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { createClient } from "@supabase/supabase-js";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import toolsJson from "@/data/tools.json";
4+
import type { Tool, ToolEventType, ToolEventMetadata } from "@/lib/types";
5+
6+
export const revalidate = 3600;
7+
8+
export interface SnapshotPoint {
9+
recorded_at: string;
10+
stars: number | null;
11+
health_score: number | null;
12+
stars_delta: number | null;
13+
}
14+
15+
export interface ToolEventSummary {
16+
type: ToolEventType;
17+
detected_at: string;
18+
metadata: ToolEventMetadata;
19+
}
20+
21+
export interface SnapshotsResponse {
22+
tool_id: string;
23+
snapshots: SnapshotPoint[];
24+
latest_event: ToolEventSummary | null;
25+
}
26+
27+
function getAnonClient() {
28+
const url = process.env.NEXT_PUBLIC_POSTGRES_SUPABASE_URL;
29+
const anon = process.env.NEXT_PUBLIC_POSTGRES_SUPABASE_ANON_KEY;
30+
if (!url || !anon) return null;
31+
return createClient(url, anon);
32+
}
33+
34+
const tools = toolsJson as Tool[];
35+
const toolSet = new Set(tools.map((t) => t.id));
36+
37+
export async function GET(req: NextRequest, { params }: { params: Promise<{ toolId: string }> }) {
38+
const { toolId } = await params;
39+
40+
if (!toolSet.has(toolId)) {
41+
return NextResponse.json({ error: "Tool not found" }, { status: 404 });
42+
}
43+
44+
const daysParam = req.nextUrl.searchParams.get("days");
45+
const days = Math.min(365, Math.max(7, parseInt(daysParam ?? "90", 10) || 90));
46+
47+
const db = getAnonClient();
48+
if (!db) {
49+
return NextResponse.json({
50+
tool_id: toolId,
51+
snapshots: [],
52+
latest_event: null,
53+
} satisfies SnapshotsResponse);
54+
}
55+
56+
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
57+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
58+
59+
const [snapshotResult, eventResult] = await Promise.all([
60+
db
61+
.from("tool_snapshots")
62+
.select("recorded_at, stars, health_score, stars_delta")
63+
.eq("tool_id", toolId)
64+
.gte("recorded_at", since)
65+
.order("recorded_at", { ascending: true }),
66+
db
67+
.from("tool_events")
68+
.select("type, detected_at, metadata")
69+
.eq("tool_id", toolId)
70+
.gte("detected_at", thirtyDaysAgo)
71+
.order("detected_at", { ascending: false })
72+
.limit(1)
73+
.maybeSingle(),
74+
]);
75+
76+
const snapshots: SnapshotPoint[] = (snapshotResult.data ?? []).map((row) => ({
77+
recorded_at: row.recorded_at as string,
78+
stars: row.stars as number | null,
79+
health_score: row.health_score as number | null,
80+
stars_delta: row.stars_delta as number | null,
81+
}));
82+
83+
const latest_event: ToolEventSummary | null = eventResult.data
84+
? {
85+
type: eventResult.data.type as ToolEventType,
86+
detected_at: eventResult.data.detected_at as string,
87+
metadata: (eventResult.data.metadata ?? {}) as ToolEventMetadata,
88+
}
89+
: null;
90+
91+
return NextResponse.json({
92+
tool_id: toolId,
93+
snapshots,
94+
latest_event,
95+
} satisfies SnapshotsResponse);
96+
}

app/builder/BuilderClient.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,32 @@ function BuilderPageContent({
183183
clearCompare,
184184
} = useBuilderState(slots, allTools);
185185

186+
function handleSeeAlternatives(slotId: string, selectedToolId: string) {
187+
const slot = slots.find((s) => s.id === slotId);
188+
if (!slot) return;
189+
190+
const slotTools = slot.tools
191+
.map((id) => allTools.find((t) => t.id === id))
192+
.filter((t): t is Tool => t != null);
193+
194+
const alternatives = slotTools
195+
.filter((t) => t.id !== selectedToolId)
196+
.sort((a, b) => {
197+
if (a.health_score == null && b.health_score == null)
198+
return (b.github_stars ?? 0) - (a.github_stars ?? 0);
199+
if (a.health_score == null) return 1;
200+
if (b.health_score == null) return -1;
201+
return b.health_score - a.health_score;
202+
});
203+
204+
const currentTool = allTools.find((t) => t.id === selectedToolId);
205+
const topAlternative = alternatives[0];
206+
if (currentTool && topAlternative) {
207+
setCompareA(currentTool);
208+
setCompareB(topAlternative);
209+
}
210+
}
211+
186212
const badgeUrl = `${SITE_URL}/badge?s=${stackParam}`;
187213
const badgeMarkdown = `[![AI Stack](${badgeUrl})](${SITE_URL}/builder?s=${stackParam})`;
188214

@@ -210,6 +236,7 @@ function BuilderPageContent({
210236
onCompareClick={handleCompareClick}
211237
onClearCompare={clearCompare}
212238
onOpenQuiz={() => setQuizOpen(true)}
239+
onSeeAlternatives={handleSeeAlternatives}
213240
/>
214241

215242
{/* Builder graph */}

0 commit comments

Comments
 (0)