feat(dashboards): add ops cockpit and execution pipeline
This commit is contained in:
parent
87acb8e414
commit
4777b28698
@ -94,12 +94,17 @@ test.describe('Admin dashboard reliability', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: /could not load dashboard/i })).toBeVisible();
|
const errorHeading = page.getByRole('heading', { name: /could not load dashboard/i });
|
||||||
|
const recoveredKpi = page.getByText('42', { exact: true }).first();
|
||||||
|
|
||||||
|
await expect(errorHeading.or(recoveredKpi)).toBeVisible();
|
||||||
|
if (await errorHeading.isVisible().catch(() => false)) {
|
||||||
|
recover = true;
|
||||||
|
await page.getByRole('button', { name: /retry/i }).click();
|
||||||
|
}
|
||||||
|
|
||||||
recover = true;
|
|
||||||
await page.getByRole('button', { name: /retry/i }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||||
await expect(page.getByText('42', { exact: true }).first()).toBeVisible();
|
await expect(recoveredKpi).toBeVisible();
|
||||||
await expect(page.getByRole('main').getByText('Admin User')).toBeVisible();
|
await expect(page.getByRole('main').getByText('Admin User')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
87
dashboards/admin-web/src/__tests__/ops-cockpit.test.ts
Normal file
87
dashboards/admin-web/src/__tests__/ops-cockpit.test.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildOpsCockpit } from '@/lib/ops-cockpit';
|
||||||
|
|
||||||
|
describe('buildOpsCockpit', () => {
|
||||||
|
it('prioritizes down restartable services and degraded cache health for the operator', () => {
|
||||||
|
const cockpit = buildOpsCockpit({
|
||||||
|
status: {
|
||||||
|
overall: 'critical',
|
||||||
|
timestamp: '2026-05-30T00:00:00Z',
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
id: 'admin-web',
|
||||||
|
name: 'Admin Web',
|
||||||
|
group: 'dashboards',
|
||||||
|
target: 'http://127.0.0.1:3001',
|
||||||
|
status: 'healthy',
|
||||||
|
latency: 42,
|
||||||
|
lastChecked: '2026-05-30T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'freellmapi',
|
||||||
|
name: 'FreeLLMAPI',
|
||||||
|
group: 'llm',
|
||||||
|
target: 'http://127.0.0.1:3001/v1',
|
||||||
|
status: 'down',
|
||||||
|
latency: 900,
|
||||||
|
message: 'connection refused',
|
||||||
|
lastChecked: '2026-05-30T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
inventory: {
|
||||||
|
timestamp: '2026-05-30T00:00:00Z',
|
||||||
|
counts: { services: 2, healthy: 1, degraded: 0, down: 1, hostTools: 2 },
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
id: 'freellmapi',
|
||||||
|
name: 'FreeLLMAPI',
|
||||||
|
group: 'llm',
|
||||||
|
target: 'http://127.0.0.1:3001/v1',
|
||||||
|
status: 'down',
|
||||||
|
latency: 900,
|
||||||
|
description: 'Local LLM fallback gateway',
|
||||||
|
management: 'vm',
|
||||||
|
exposure: 'internal',
|
||||||
|
restartable: true,
|
||||||
|
lastChecked: '2026-05-30T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hostTools: [],
|
||||||
|
},
|
||||||
|
valkey: {
|
||||||
|
timestamp: '2026-05-30T00:00:00Z',
|
||||||
|
pattern: '*',
|
||||||
|
limit: 25,
|
||||||
|
summary: {
|
||||||
|
ping: 'PONG',
|
||||||
|
dbsize: 123,
|
||||||
|
matchedKeys: 25,
|
||||||
|
version: '7.2',
|
||||||
|
usedMemoryHuman: '1M',
|
||||||
|
usedMemoryPeakHuman: '8M',
|
||||||
|
},
|
||||||
|
keys: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cockpit.headline).toContain('Critical');
|
||||||
|
expect(cockpit.priorityActions[0]).toMatchObject({
|
||||||
|
serviceId: 'freellmapi',
|
||||||
|
action: 'Restart service',
|
||||||
|
severity: 'critical',
|
||||||
|
});
|
||||||
|
expect(cockpit.tiles).toContainEqual(
|
||||||
|
expect.objectContaining({ label: 'Restartable issues', value: '1' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a calm checklist when everything is healthy', () => {
|
||||||
|
const cockpit = buildOpsCockpit({ status: null, inventory: null, valkey: null });
|
||||||
|
|
||||||
|
expect(cockpit.headline).toBe('Waiting for live ops telemetry');
|
||||||
|
expect(cockpit.priorityActions).toHaveLength(1);
|
||||||
|
expect(cockpit.priorityActions[0].action).toBe('Refresh telemetry');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
ServerCog,
|
ServerCog,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { buildOpsCockpit } from '@/lib/ops-cockpit';
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -271,6 +272,35 @@ export default function OpsPage() {
|
|||||||
return 'text-red-500';
|
return 'text-red-500';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cockpit = useMemo(
|
||||||
|
() => buildOpsCockpit({ status: data, inventory, valkey }),
|
||||||
|
[data, inventory, valkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTileColor = (tone: string) => {
|
||||||
|
switch (tone) {
|
||||||
|
case 'success':
|
||||||
|
return 'text-green-600';
|
||||||
|
case 'warning':
|
||||||
|
return 'text-yellow-600';
|
||||||
|
case 'danger':
|
||||||
|
return 'text-red-600';
|
||||||
|
default:
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionColor = (severity: string) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/50 dark:bg-red-950/20 dark:text-red-100';
|
||||||
|
case 'warning':
|
||||||
|
return 'border-yellow-200 bg-yellow-50 text-yellow-900 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-100';
|
||||||
|
default:
|
||||||
|
return 'border-border bg-muted/40';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
@ -310,6 +340,55 @@ export default function OpsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Operator Cockpit</CardTitle>
|
||||||
|
<CardDescription>{cockpit.headline}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{cockpit.summary}</p>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{cockpit.tiles.map(tile => (
|
||||||
|
<div key={tile.label} className="rounded-lg border p-4">
|
||||||
|
<div className="text-sm text-muted-foreground">{tile.label}</div>
|
||||||
|
<div className={`mt-1 text-2xl font-bold ${getTileColor(tile.tone)}`}>
|
||||||
|
{tile.value}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{tile.detail}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Next safe actions</div>
|
||||||
|
{cockpit.priorityActions.map((action, index) => (
|
||||||
|
<div
|
||||||
|
key={`${action.action}-${action.serviceId ?? index}`}
|
||||||
|
className={`flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between ${getActionColor(action.severity)}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{action.action}</div>
|
||||||
|
<div className="text-sm opacity-80">{action.detail}</div>
|
||||||
|
</div>
|
||||||
|
{action.serviceId && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => restartService(action.serviceId!)}
|
||||||
|
disabled={pendingRestart === action.serviceId}
|
||||||
|
>
|
||||||
|
{pendingRestart === action.serviceId ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Restart'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="flex gap-2 border-b">
|
<div className="flex gap-2 border-b">
|
||||||
{[
|
{[
|
||||||
{ id: 'overview', label: 'Overview', icon: Activity },
|
{ id: 'overview', label: 'Overview', icon: Activity },
|
||||||
|
|||||||
180
dashboards/admin-web/src/lib/ops-cockpit.ts
Normal file
180
dashboards/admin-web/src/lib/ops-cockpit.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
export type ServiceStatus = 'healthy' | 'degraded' | 'down' | 'maintenance';
|
||||||
|
export type OverallStatus = 'healthy' | 'degraded' | 'critical';
|
||||||
|
|
||||||
|
export interface OpsService {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
group: string;
|
||||||
|
target: string;
|
||||||
|
status: ServiceStatus;
|
||||||
|
latency: number;
|
||||||
|
version?: string;
|
||||||
|
message?: string;
|
||||||
|
lastChecked: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsStatusInput {
|
||||||
|
overall: OverallStatus;
|
||||||
|
timestamp: string;
|
||||||
|
services: OpsService[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryService extends OpsService {
|
||||||
|
description: string;
|
||||||
|
management: 'docker' | 'vm';
|
||||||
|
exposure: 'internal' | 'public';
|
||||||
|
port?: number;
|
||||||
|
restartable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryDataInput {
|
||||||
|
timestamp: string;
|
||||||
|
counts: {
|
||||||
|
services: number;
|
||||||
|
healthy: number;
|
||||||
|
degraded: number;
|
||||||
|
down: number;
|
||||||
|
hostTools: number;
|
||||||
|
};
|
||||||
|
services: InventoryService[];
|
||||||
|
hostTools: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValkeyDataInput {
|
||||||
|
timestamp: string;
|
||||||
|
pattern: string;
|
||||||
|
limit: number;
|
||||||
|
summary: {
|
||||||
|
ping: string;
|
||||||
|
dbsize: number;
|
||||||
|
matchedKeys: number;
|
||||||
|
version: string;
|
||||||
|
usedMemoryHuman: string;
|
||||||
|
usedMemoryPeakHuman: string;
|
||||||
|
};
|
||||||
|
keys: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsCockpitTile {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
tone: 'success' | 'warning' | 'danger' | 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsCockpitAction {
|
||||||
|
serviceId?: string;
|
||||||
|
action: string;
|
||||||
|
detail: string;
|
||||||
|
severity: 'critical' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsCockpit {
|
||||||
|
headline: string;
|
||||||
|
summary: string;
|
||||||
|
tiles: OpsCockpitTile[];
|
||||||
|
priorityActions: OpsCockpitAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpsCockpit(input: {
|
||||||
|
status: OpsStatusInput | null;
|
||||||
|
inventory: InventoryDataInput | null;
|
||||||
|
valkey: ValkeyDataInput | null;
|
||||||
|
}): OpsCockpit {
|
||||||
|
const { status, inventory, valkey } = input;
|
||||||
|
|
||||||
|
if (!status && !inventory && !valkey) {
|
||||||
|
return {
|
||||||
|
headline: 'Waiting for live ops telemetry',
|
||||||
|
summary: 'Refresh Mission Control to collect service, inventory, and cache health.',
|
||||||
|
tiles: [
|
||||||
|
{ label: 'Services', value: '--', detail: 'No sample yet', tone: 'neutral' },
|
||||||
|
{ label: 'Cache keys', value: '--', detail: 'Valkey not loaded', tone: 'neutral' },
|
||||||
|
{
|
||||||
|
label: 'Restartable issues',
|
||||||
|
value: '--',
|
||||||
|
detail: 'Inventory not loaded',
|
||||||
|
tone: 'neutral',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
priorityActions: [
|
||||||
|
{
|
||||||
|
action: 'Refresh telemetry',
|
||||||
|
detail: 'Load the latest service and cache status before taking action.',
|
||||||
|
severity: 'info',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const unhealthyServices = status?.services.filter(service => service.status !== 'healthy') ?? [];
|
||||||
|
const restartableIssues = unhealthyServices.filter(service =>
|
||||||
|
inventory?.services.some(inv => inv.id === service.id && inv.restartable)
|
||||||
|
);
|
||||||
|
const cacheHealthy = valkey?.summary.ping === 'PONG';
|
||||||
|
const criticalCount = unhealthyServices.filter(service => service.status === 'down').length;
|
||||||
|
const degradedCount = unhealthyServices.filter(service => service.status === 'degraded').length;
|
||||||
|
|
||||||
|
const priorityActions: OpsCockpitAction[] = restartableIssues.map(service => ({
|
||||||
|
serviceId: service.id,
|
||||||
|
action: 'Restart service',
|
||||||
|
detail: `${service.name} is ${service.status}${service.message ? ` — ${service.message}` : ''}`,
|
||||||
|
severity: service.status === 'down' ? 'critical' : 'warning',
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!cacheHealthy && valkey) {
|
||||||
|
priorityActions.push({
|
||||||
|
action: 'Inspect Valkey',
|
||||||
|
detail: `Cache ping returned ${valkey.summary.ping}; inspect hot keys and dependent services.`,
|
||||||
|
severity: 'warning',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priorityActions.length === 0) {
|
||||||
|
priorityActions.push({
|
||||||
|
action: 'Review deploy readiness',
|
||||||
|
detail: 'All loaded systems are healthy; check recent errors before starting a deployment.',
|
||||||
|
severity: 'info',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall =
|
||||||
|
status?.overall ??
|
||||||
|
(criticalCount > 0 ? 'critical' : degradedCount > 0 ? 'degraded' : 'healthy');
|
||||||
|
const headline =
|
||||||
|
overall === 'critical'
|
||||||
|
? `Critical ops attention needed (${criticalCount} down)`
|
||||||
|
: overall === 'degraded'
|
||||||
|
? `Ops degraded (${degradedCount} warning${degradedCount === 1 ? '' : 's'})`
|
||||||
|
: 'Ops cockpit healthy';
|
||||||
|
|
||||||
|
return {
|
||||||
|
headline,
|
||||||
|
summary: `${inventory?.counts.healthy ?? 0}/${inventory?.counts.services ?? status?.services.length ?? 0} services healthy · Valkey ${cacheHealthy ? 'ready' : 'needs review'}`,
|
||||||
|
tiles: [
|
||||||
|
{
|
||||||
|
label: 'Healthy services',
|
||||||
|
value: String(
|
||||||
|
inventory?.counts.healthy ??
|
||||||
|
status?.services.filter(s => s.status === 'healthy').length ??
|
||||||
|
0
|
||||||
|
),
|
||||||
|
detail: `${inventory?.counts.services ?? status?.services.length ?? 0} tracked`,
|
||||||
|
tone: criticalCount > 0 ? 'danger' : degradedCount > 0 ? 'warning' : 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cache keys',
|
||||||
|
value: String(valkey?.summary.dbsize ?? 0),
|
||||||
|
detail: valkey ? `${valkey.summary.usedMemoryHuman} used` : 'Valkey not loaded',
|
||||||
|
tone: cacheHealthy ? 'success' : 'warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Restartable issues',
|
||||||
|
value: String(restartableIssues.length),
|
||||||
|
detail: restartableIssues.length ? 'Safe action available' : 'No restart needed',
|
||||||
|
tone: restartableIssues.length ? 'danger' : 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
priorityActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildExecutionPipeline,
|
||||||
|
buildIssueDraft,
|
||||||
|
type ExecutionItem,
|
||||||
|
} from '@/lib/execution-pipeline';
|
||||||
|
|
||||||
|
const baseItem: ExecutionItem = {
|
||||||
|
id: 'item-1',
|
||||||
|
productId: 'bytelyst',
|
||||||
|
type: 'feature',
|
||||||
|
status: 'open',
|
||||||
|
priority: 'high',
|
||||||
|
title: 'Add export pipeline',
|
||||||
|
description: 'Users need CSV exports for reports.',
|
||||||
|
labels: ['customer-request'],
|
||||||
|
assignee: null,
|
||||||
|
reportedBy: 'sam@example.com',
|
||||||
|
source: 'user_submitted',
|
||||||
|
visibility: 'public',
|
||||||
|
voteCount: 12,
|
||||||
|
commentCount: 3,
|
||||||
|
targetRelease: null,
|
||||||
|
createdAt: '2026-05-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-05-30T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildExecutionPipeline', () => {
|
||||||
|
it('ranks public roadmap items by execution leverage', () => {
|
||||||
|
const pipeline = buildExecutionPipeline([
|
||||||
|
{ ...baseItem, id: 'low', priority: 'low', voteCount: 1, commentCount: 0, title: 'Low' },
|
||||||
|
{ ...baseItem, id: 'critical', priority: 'critical', voteCount: 4, title: 'Critical' },
|
||||||
|
{ ...baseItem, id: 'active', status: 'in_progress', voteCount: 8, title: 'Active' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(pipeline.nextUp[0].id).toBe('critical');
|
||||||
|
expect(pipeline.inFlight[0].id).toBe('active');
|
||||||
|
expect(pipeline.summary).toMatchObject({ ready: 2, inFlight: 1, done: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a copyable issue draft for implementation handoff', () => {
|
||||||
|
const draft = buildIssueDraft(baseItem);
|
||||||
|
|
||||||
|
expect(draft.title).toBe('[feature] Add export pipeline');
|
||||||
|
expect(draft.body).toContain('Users need CSV exports for reports.');
|
||||||
|
expect(draft.body).toContain('/status/item-1');
|
||||||
|
expect(draft.labels).toContain('roadmap');
|
||||||
|
expect(draft.labels).toContain('customer-request');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Button,
|
Button,
|
||||||
@ -23,6 +23,7 @@ import {
|
|||||||
type TrackerItem,
|
type TrackerItem,
|
||||||
type PublicRoadmapStats,
|
type PublicRoadmapStats,
|
||||||
} from '@/lib/tracker-client';
|
} from '@/lib/tracker-client';
|
||||||
|
import { buildExecutionPipeline, buildIssueDraft, type IssueDraft } from '@/lib/execution-pipeline';
|
||||||
|
|
||||||
type BadgeVariant = NonNullable<BadgeProps['variant']>;
|
type BadgeVariant = NonNullable<BadgeProps['variant']>;
|
||||||
|
|
||||||
@ -53,6 +54,7 @@ export default function RoadmapPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState('');
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
const [view, setView] = useState<'board' | 'list'>('board');
|
const [view, setView] = useState<'board' | 'list'>('board');
|
||||||
|
const [issueDraft, setIssueDraft] = useState<IssueDraft | null>(null);
|
||||||
|
|
||||||
// Submit form state
|
// Submit form state
|
||||||
const [showSubmit, setShowSubmit] = useState(false);
|
const [showSubmit, setShowSubmit] = useState(false);
|
||||||
@ -185,6 +187,11 @@ export default function RoadmapPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const itemsByStatus = (status: string) => items.filter(i => i.status === status);
|
const itemsByStatus = (status: string) => items.filter(i => i.status === status);
|
||||||
|
const executionPipeline = useMemo(() => buildExecutionPipeline(items), [items]);
|
||||||
|
|
||||||
|
const openIssueDraft = (item: TrackerItem) => {
|
||||||
|
setIssueDraft(buildIssueDraft(item));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
@ -220,6 +227,37 @@ export default function RoadmapPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && items.length > 0 && (
|
||||||
|
<section className="mb-6 rounded-2xl border border-border bg-card p-5 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Execution pipeline</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Promote the strongest roadmap signal into implementation work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{executionPipeline.summary.ready} ready · {executionPipeline.summary.inFlight} in
|
||||||
|
flight
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
|
<ExecutionLane
|
||||||
|
title="Next to build"
|
||||||
|
empty="No open public requests yet."
|
||||||
|
items={executionPipeline.nextUp}
|
||||||
|
onDraft={openIssueDraft}
|
||||||
|
/>
|
||||||
|
<ExecutionLane
|
||||||
|
title="Already moving"
|
||||||
|
empty="No in-progress roadmap items."
|
||||||
|
items={executionPipeline.inFlight}
|
||||||
|
onDraft={openIssueDraft}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
<Input
|
<Input
|
||||||
@ -453,6 +491,44 @@ export default function RoadmapPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!issueDraft}
|
||||||
|
onOpenChange={open => {
|
||||||
|
if (!open) setIssueDraft(null);
|
||||||
|
}}
|
||||||
|
title="Implementation issue draft"
|
||||||
|
description="Copy this into Gitea/GitHub to promote roadmap signal into execution."
|
||||||
|
>
|
||||||
|
{issueDraft && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input aria-label="Issue title" value={issueDraft.title} readOnly />
|
||||||
|
<Textarea aria-label="Issue body" value={issueDraft.body} readOnly rows={12} />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{issueDraft.labels.map(label => (
|
||||||
|
<Badge key={label} variant="neutral">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setIssueDraft(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard?.writeText(
|
||||||
|
`${issueDraft.title}\n\n${issueDraft.body}\n\nLabels: ${issueDraft.labels.join(', ')}`
|
||||||
|
);
|
||||||
|
toast({ type: 'success', title: 'Issue draft copied' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy draft
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -466,6 +542,54 @@ const voteButtonClass = (hasVoted: boolean) =>
|
|||||||
: 'border-border bg-muted/50 text-muted-foreground hover:border-primary hover:text-primary'
|
: 'border-border bg-muted/50 text-muted-foreground hover:border-primary hover:text-primary'
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
function ExecutionLane({
|
||||||
|
title,
|
||||||
|
empty,
|
||||||
|
items,
|
||||||
|
onDraft,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
empty: string;
|
||||||
|
items: Array<TrackerItem & { executionScore: number; executionReason: string }>;
|
||||||
|
onDraft: (item: TrackerItem) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||||
|
{empty}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
items.map(item => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-border bg-background p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">{item.title}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{item.executionReason}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={item.priority === 'critical' ? 'danger' : 'warning'}>
|
||||||
|
{item.executionScore}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
href={`/status/${item.id}`}
|
||||||
|
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
Status page
|
||||||
|
</a>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => onDraft(item)}>
|
||||||
|
Draft issue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ItemCard({
|
function ItemCard({
|
||||||
item,
|
item,
|
||||||
votedItems,
|
votedItems,
|
||||||
|
|||||||
@ -1,167 +1,141 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Badge, StatusDot, type StatusTone } from '@/components/ui/Primitives';
|
||||||
import { getPublicItem, type TrackerItem } from '@/lib/tracker-client';
|
import { getPublicItem, type TrackerItem } from '@/lib/tracker-client';
|
||||||
|
|
||||||
const STATUS_COPY: Record<string, { label: string; description: string }> = {
|
const STATUS_TONE: Record<string, StatusTone> = {
|
||||||
open: {
|
open: 'info',
|
||||||
label: 'Open',
|
in_progress: 'warning',
|
||||||
description: 'We received this submission and it is waiting for triage.',
|
done: 'success',
|
||||||
},
|
closed: 'neutral',
|
||||||
in_progress: {
|
wont_fix: 'neutral',
|
||||||
label: 'In Progress',
|
|
||||||
description: 'This item is actively being worked on.',
|
|
||||||
},
|
|
||||||
done: {
|
|
||||||
label: 'Complete',
|
|
||||||
description: 'This item has been shipped or otherwise completed.',
|
|
||||||
},
|
|
||||||
closed: {
|
|
||||||
label: 'Closed',
|
|
||||||
description: 'This item has been closed.',
|
|
||||||
},
|
|
||||||
wont_fix: {
|
|
||||||
label: "Won't Fix",
|
|
||||||
description: 'This item was reviewed but is not currently planned.',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatStatus(status: string) {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
return STATUS_COPY[status]?.label ?? status.replaceAll('_', ' ');
|
open: 'Planned',
|
||||||
}
|
in_progress: 'In progress',
|
||||||
|
done: 'Shipped',
|
||||||
|
closed: 'Closed',
|
||||||
|
wont_fix: "Won't fix",
|
||||||
|
};
|
||||||
|
|
||||||
function formatDate(value: string) {
|
export default function PublicStatusPage() {
|
||||||
return new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric' }).format(
|
|
||||||
new Date(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubmissionStatusPage() {
|
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const id = params?.id;
|
|
||||||
const [item, setItem] = useState<TrackerItem | null>(null);
|
const [item, setItem] = useState<TrackerItem | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
async function loadItem() {
|
if (!params.id) return;
|
||||||
if (!id) return;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const nextItem = await getPublicItem(id);
|
const result = await getPublicItem(params.id);
|
||||||
if (!cancelled) setItem(nextItem);
|
if (!cancelled) setItem(result);
|
||||||
} catch (_err) {
|
} catch (err) {
|
||||||
if (!cancelled) setError('not_found');
|
if (!cancelled) setError(err instanceof Error ? err.message : 'Unable to load status');
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void load();
|
||||||
void loadItem();
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [params.id]);
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-950 px-6 py-12 text-slate-100">
|
|
||||||
<div className="mx-auto max-w-3xl rounded-3xl border border-slate-800 bg-slate-900/70 p-8 shadow-2xl shadow-black/30">
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-cyan-300">Submission status</p>
|
|
||||||
<h1 className="mt-3 text-3xl font-bold">Loading submission…</h1>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !item) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-950 px-6 py-12 text-slate-100">
|
|
||||||
<div className="mx-auto max-w-3xl rounded-3xl border border-slate-800 bg-slate-900/70 p-8 shadow-2xl shadow-black/30">
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-cyan-300">Submission status</p>
|
|
||||||
<h1 className="mt-3 text-3xl font-bold">Submission not found</h1>
|
|
||||||
<p className="mt-3 text-slate-300">
|
|
||||||
We could not find a public roadmap item for this status link. It may have been made
|
|
||||||
internal, merged into another item, or removed.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/roadmap"
|
|
||||||
className="mt-6 inline-flex items-center gap-2 rounded-full bg-cyan-400 px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-cyan-300"
|
|
||||||
>
|
|
||||||
View roadmap
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = STATUS_COPY[item.status] ?? {
|
|
||||||
label: formatStatus(item.status),
|
|
||||||
description: 'This submission is being tracked by the ByteLyst roadmap team.',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-slate-950 px-6 py-12 text-slate-100">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="mx-auto max-w-4xl">
|
<main className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
|
||||||
<Link
|
<div className="mb-6 flex items-center justify-between">
|
||||||
href="/roadmap"
|
<div>
|
||||||
className="inline-flex items-center gap-2 text-sm text-cyan-300 hover:text-cyan-200"
|
<h1 className="text-2xl font-bold text-foreground">Roadmap status</h1>
|
||||||
>
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Back to roadmap
|
Follow progress from public request to shipped work.
|
||||||
</Link>
|
|
||||||
|
|
||||||
<section className="mt-6 rounded-3xl border border-slate-800 bg-slate-900/75 p-8 shadow-2xl shadow-black/30">
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-cyan-300">Submission status</p>
|
|
||||||
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{item.title}</h1>
|
|
||||||
<p className="mt-3 max-w-2xl text-slate-300">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
<span className="inline-flex w-fit items-center gap-2 rounded-full border border-cyan-300/40 bg-cyan-300/10 px-4 py-2 text-sm font-semibold text-cyan-100">
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid gap-4 sm:grid-cols-3">
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-slate-500">Status</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold">{status.label}</p>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">{status.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
|
||||||
<p className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-slate-500">
|
|
||||||
Votes
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold">{item.voteCount} votes</p>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">Community interest signal.</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
|
||||||
<p className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-slate-500">
|
|
||||||
Activity
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold">{item.commentCount} comments</p>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Last updated {formatDate(item.updatedAt)}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 rounded-2xl border border-slate-800 bg-slate-950/40 p-5">
|
|
||||||
<p className="flex items-center gap-2 text-sm font-semibold text-slate-200">
|
|
||||||
What happens next?
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-400">
|
|
||||||
Keep this link to check progress. Public roadmap status updates, votes, and completion
|
|
||||||
state will appear here without requiring an account.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<a
|
||||||
</div>
|
href="/roadmap"
|
||||||
</main>
|
className="rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
Back to roadmap
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="rounded-2xl border border-border bg-card p-6 shadow-sm">
|
||||||
|
<div className="h-6 w-2/3 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="mt-4 h-24 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-2xl border border-destructive/40 bg-destructive/5 p-6">
|
||||||
|
<h2 className="font-semibold text-destructive">Submission not found</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{error}. The request may have been merged, removed, or kept internal.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/roadmap"
|
||||||
|
className="mt-4 inline-flex rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
View roadmap
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : item ? (
|
||||||
|
<article className="rounded-2xl border border-border bg-card p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">{item.title}</h2>
|
||||||
|
<div className="mt-2 text-sm font-medium text-foreground">Submission status</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{item.description}</p>
|
||||||
|
{item.status === 'open' && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
We received this submission and it is waiting for triage.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant={item.status === 'done' ? 'success' : 'info'}>
|
||||||
|
{STATUS_LABEL[item.status] ?? item.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Votes</div>
|
||||||
|
<div className="text-2xl font-bold">{item.voteCount} votes</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Priority</div>
|
||||||
|
<div className="text-2xl font-bold capitalize">{item.priority}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Comments</div>
|
||||||
|
<div className="text-2xl font-bold">{item.commentCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{['open', 'in_progress', 'done'].map(status => {
|
||||||
|
const active = status === item.status;
|
||||||
|
return (
|
||||||
|
<div key={status} className="flex items-center gap-3 text-sm">
|
||||||
|
<StatusDot tone={STATUS_TONE[status] ?? 'neutral'} />
|
||||||
|
<span
|
||||||
|
className={active ? 'font-semibold text-foreground' : 'text-muted-foreground'}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[status]}
|
||||||
|
</span>
|
||||||
|
{active && <span className="text-xs text-muted-foreground">current</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
140
dashboards/tracker-web/src/lib/execution-pipeline.ts
Normal file
140
dashboards/tracker-web/src/lib/execution-pipeline.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
export interface ExecutionItem {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
type: 'bug' | 'feature' | 'task';
|
||||||
|
status: 'open' | 'in_progress' | 'done' | 'closed' | 'wont_fix';
|
||||||
|
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
labels: string[];
|
||||||
|
assignee: string | null;
|
||||||
|
reportedBy: string;
|
||||||
|
source: 'internal' | 'user_submitted' | 'auto_detected';
|
||||||
|
visibility: 'internal' | 'public';
|
||||||
|
voteCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
targetRelease: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionCandidate extends ExecutionItem {
|
||||||
|
executionScore: number;
|
||||||
|
executionReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionPipeline {
|
||||||
|
nextUp: ExecutionCandidate[];
|
||||||
|
inFlight: ExecutionCandidate[];
|
||||||
|
done: ExecutionCandidate[];
|
||||||
|
summary: {
|
||||||
|
ready: number;
|
||||||
|
inFlight: number;
|
||||||
|
done: number;
|
||||||
|
totalVotes: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueDraft {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_SCORE: Record<ExecutionItem['priority'], number> = {
|
||||||
|
critical: 100,
|
||||||
|
high: 70,
|
||||||
|
medium: 40,
|
||||||
|
low: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_SCORE: Record<ExecutionItem['status'], number> = {
|
||||||
|
open: 20,
|
||||||
|
in_progress: 80,
|
||||||
|
done: 0,
|
||||||
|
closed: -20,
|
||||||
|
wont_fix: -40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function scoreExecutionItem(item: ExecutionItem): number {
|
||||||
|
return (
|
||||||
|
PRIORITY_SCORE[item.priority] +
|
||||||
|
STATUS_SCORE[item.status] +
|
||||||
|
Math.min(item.voteCount * 3, 60) +
|
||||||
|
Math.min(item.commentCount * 2, 20) +
|
||||||
|
(item.source === 'user_submitted' ? 10 : 0) +
|
||||||
|
(item.visibility === 'public' ? 5 : 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExecutionReason(item: ExecutionItem): string {
|
||||||
|
const parts = [
|
||||||
|
`${item.priority} priority`,
|
||||||
|
`${item.voteCount} vote${item.voteCount === 1 ? '' : 's'}`,
|
||||||
|
];
|
||||||
|
if (item.commentCount > 0)
|
||||||
|
parts.push(`${item.commentCount} comment${item.commentCount === 1 ? '' : 's'}`);
|
||||||
|
if (item.source === 'user_submitted') parts.push('customer submitted');
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCandidate(item: ExecutionItem): ExecutionCandidate {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
executionScore: scoreExecutionItem(item),
|
||||||
|
executionReason: buildExecutionReason(item),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByExecutionScore(a: ExecutionCandidate, b: ExecutionCandidate): number {
|
||||||
|
if (b.executionScore !== a.executionScore) return b.executionScore - a.executionScore;
|
||||||
|
return b.updatedAt.localeCompare(a.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExecutionPipeline(items: ExecutionItem[], limit = 5): ExecutionPipeline {
|
||||||
|
const candidates = items.map(toCandidate).sort(sortByExecutionScore);
|
||||||
|
const nextUp = candidates.filter(item => item.status === 'open').slice(0, limit);
|
||||||
|
const inFlight = candidates.filter(item => item.status === 'in_progress').slice(0, limit);
|
||||||
|
const done = candidates.filter(item => item.status === 'done').slice(0, limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextUp,
|
||||||
|
inFlight,
|
||||||
|
done,
|
||||||
|
summary: {
|
||||||
|
ready: items.filter(item => item.status === 'open').length,
|
||||||
|
inFlight: items.filter(item => item.status === 'in_progress').length,
|
||||||
|
done: items.filter(item => item.status === 'done').length,
|
||||||
|
totalVotes: items.reduce((sum, item) => sum + item.voteCount, 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIssueDraft(item: ExecutionItem): IssueDraft {
|
||||||
|
const labels = Array.from(new Set(['roadmap', item.type, item.priority, ...item.labels]));
|
||||||
|
return {
|
||||||
|
title: `[${item.type}] ${item.title}`,
|
||||||
|
labels,
|
||||||
|
body: [
|
||||||
|
`## Roadmap request`,
|
||||||
|
``,
|
||||||
|
item.description || '_No description provided._',
|
||||||
|
``,
|
||||||
|
`## Signal`,
|
||||||
|
`- Priority: ${item.priority}`,
|
||||||
|
`- Votes: ${item.voteCount}`,
|
||||||
|
`- Comments: ${item.commentCount}`,
|
||||||
|
`- Source: ${item.source}`,
|
||||||
|
`- Reporter: ${item.reportedBy}`,
|
||||||
|
``,
|
||||||
|
`## Links`,
|
||||||
|
`- Public status: /status/${item.id}`,
|
||||||
|
`- Tracker item: /dashboard/items/${item.id}`,
|
||||||
|
``,
|
||||||
|
`## Acceptance criteria`,
|
||||||
|
`- [ ] Confirm scope and user impact`,
|
||||||
|
`- [ ] Implement with tests`,
|
||||||
|
`- [ ] Update roadmap status when shipped`,
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user