feat(board): v2 viewmodel helpers + logo asset
This commit is contained in:
parent
f9c7c2ae3c
commit
a34d0ea870
10
assets/logo.svg
Normal file
10
assets/logo.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#38bdf8"/>
|
||||||
|
<stop offset="1" stop-color="#8b5cf6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#g)" d="M12 2l8.5 5v10L12 22l-8.5-5V7z"/>
|
||||||
|
<circle cx="12" cy="12" r="3.2" fill="#0e1226"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 368 B |
134
src/server/board/viewmodel.ts
Normal file
134
src/server/board/viewmodel.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Pure, dependency-free helpers for the board v2 UI.
|
||||||
|
* These run BOTH in vitest (server-side) and in the browser — board/index.ts
|
||||||
|
* injects them into the inline <script> via Function.prototype.toString().
|
||||||
|
* Therefore: no imports, no closures over module state, ES2019 syntax only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface KpiTask {
|
||||||
|
id: string;
|
||||||
|
status?: string;
|
||||||
|
assignedTo?: string;
|
||||||
|
reviewer?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
claimedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentChip {
|
||||||
|
name: string;
|
||||||
|
minutes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
/** Cap a chip list at `max` visible entries, reporting how many were hidden. */
|
||||||
|
export function capChips<T>(chips: T[], max: number): { visible: T[]; hidden: number } {
|
||||||
|
const visible = chips.slice(0, Math.max(0, max));
|
||||||
|
return { visible, hidden: chips.length - visible.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Done-card model: share of non-cancelled tasks done + done in the last 7 days. */
|
||||||
|
export function doneStats(
|
||||||
|
tasks: KpiTask[],
|
||||||
|
now?: number,
|
||||||
|
): { done: number; total: number; pct: number; doneThisWeek: number } {
|
||||||
|
const t0 = now ?? Date.now();
|
||||||
|
const relevant = tasks.filter((t) => t.status !== 'cancelled');
|
||||||
|
const doneTasks = relevant.filter((t) => t.status === 'done');
|
||||||
|
const weekAgo = t0 - 7 * DAY_MS;
|
||||||
|
const doneThisWeek = doneTasks.filter(
|
||||||
|
(t) => t.updatedAt && new Date(t.updatedAt).getTime() >= weekAgo,
|
||||||
|
).length;
|
||||||
|
const pct = relevant.length === 0 ? 0 : Math.round((doneTasks.length / relevant.length) * 100);
|
||||||
|
return { done: doneTasks.length, total: relevant.length, pct, doneThisWeek };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayStart(ts: number): number {
|
||||||
|
const d = new Date(ts);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backlog (open) count for each of the last `days` days, oldest first.
|
||||||
|
* Approximation: a task counts as backlog on day D when it existed by end of D
|
||||||
|
* and was not yet moved out of 'open' (non-open tasks use updatedAt as the
|
||||||
|
* transition timestamp; still-open tasks are backlog on every day since creation).
|
||||||
|
*/
|
||||||
|
export function backlogSeries(tasks: KpiTask[], days: number, now?: number): number[] {
|
||||||
|
const today = dayStart(now ?? Date.now());
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const endOfDay = today - i * DAY_MS + DAY_MS - 1;
|
||||||
|
let count = 0;
|
||||||
|
for (const t of tasks) {
|
||||||
|
const created = t.createdAt ? new Date(t.createdAt).getTime() : 0;
|
||||||
|
if (created > endOfDay) continue;
|
||||||
|
if (t.status === 'open') {
|
||||||
|
count++;
|
||||||
|
} else if (t.status !== 'cancelled') {
|
||||||
|
const left = t.updatedAt ? new Date(t.updatedAt).getTime() : created;
|
||||||
|
if (left > endOfDay) count++; // was still open on that day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(count);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Done-per-day counts for the last `days` days, oldest first. */
|
||||||
|
export function throughputSeries(tasks: KpiTask[], days: number, now?: number): number[] {
|
||||||
|
const today = dayStart(now ?? Date.now());
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const start = today - i * DAY_MS;
|
||||||
|
const end = start + DAY_MS - 1;
|
||||||
|
out.push(
|
||||||
|
tasks.filter(
|
||||||
|
(t) =>
|
||||||
|
t.status === 'done' &&
|
||||||
|
t.updatedAt &&
|
||||||
|
new Date(t.updatedAt).getTime() >= start &&
|
||||||
|
new Date(t.updatedAt).getTime() <= end,
|
||||||
|
).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build SVG line + area path data for a value series. Values are top-anchored (max = y 0). */
|
||||||
|
export function areaPath(
|
||||||
|
values: number[],
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): { line: string; area: string } {
|
||||||
|
if (values.length === 0) return { line: '', area: '' };
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const span = max - min;
|
||||||
|
const step = values.length > 1 ? w / (values.length - 1) : 0;
|
||||||
|
const pts = values.map((v, i) => {
|
||||||
|
const x = Math.round(i * step * 100) / 100;
|
||||||
|
const y = span === 0 ? h / 2 : Math.round(((max - v) / span) * h * 100) / 100;
|
||||||
|
return `${x},${y}`;
|
||||||
|
});
|
||||||
|
const line = `M${pts.join(' L')}`;
|
||||||
|
return { line, area: `${line} L${w},${h} L0,${h} Z` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chip model for one status lane (in_progress → assignedTo, review → reviewer). */
|
||||||
|
export function laneChips(tasks: KpiTask[], status: string, now?: number): AgentChip[] {
|
||||||
|
const t0 = now ?? Date.now();
|
||||||
|
return tasks
|
||||||
|
.filter((t) => t.status === status)
|
||||||
|
.map((t) => {
|
||||||
|
const name = status === 'review' ? t.reviewer ?? t.assignedTo : t.assignedTo;
|
||||||
|
const since = t.claimedAt ?? t.updatedAt;
|
||||||
|
return {
|
||||||
|
name: name ?? '?',
|
||||||
|
minutes: since ? Math.max(0, Math.round((t0 - new Date(since).getTime()) / 60000)) : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((c) => c.name !== '?');
|
||||||
|
}
|
||||||
81
tests/boardV2-viewmodel.test.ts
Normal file
81
tests/boardV2-viewmodel.test.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
capChips, doneStats, backlogSeries, throughputSeries, areaPath,
|
||||||
|
type KpiTask,
|
||||||
|
} from '../src/server/board/viewmodel.js';
|
||||||
|
|
||||||
|
const day = 24 * 3600 * 1000;
|
||||||
|
const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString();
|
||||||
|
|
||||||
|
describe('capChips', () => {
|
||||||
|
it('shows all chips when at most max', () => {
|
||||||
|
const chips = [{ name: 'claude', minutes: 5 }, { name: 'codex', minutes: 2 }];
|
||||||
|
expect(capChips(chips, 3)).toEqual({ visible: chips, hidden: 0 });
|
||||||
|
});
|
||||||
|
it('caps at max and reports the hidden count', () => {
|
||||||
|
const chips = ['a', 'b', 'c', 'd', 'e'].map((name) => ({ name, minutes: 1 }));
|
||||||
|
const r = capChips(chips, 3);
|
||||||
|
expect(r.visible.map((c) => c.name)).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(r.hidden).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('doneStats', () => {
|
||||||
|
it('computes share and weekly count, excluding cancelled from total', () => {
|
||||||
|
const tasks: KpiTask[] = [
|
||||||
|
{ id: '1', status: 'done', updatedAt: iso(2 * day) },
|
||||||
|
{ id: '2', status: 'done', updatedAt: iso(10 * day) },
|
||||||
|
{ id: '3', status: 'open' },
|
||||||
|
{ id: '4', status: 'cancelled' },
|
||||||
|
];
|
||||||
|
const r = doneStats(tasks);
|
||||||
|
expect(r.done).toBe(2);
|
||||||
|
expect(r.total).toBe(3); // cancelled excluded
|
||||||
|
expect(r.pct).toBe(67);
|
||||||
|
expect(r.doneThisWeek).toBe(1);
|
||||||
|
});
|
||||||
|
it('handles empty input', () => {
|
||||||
|
expect(doneStats([])).toEqual({ done: 0, total: 0, pct: 0, doneThisWeek: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backlogSeries', () => {
|
||||||
|
it('returns one value per day, oldest first, ending today', () => {
|
||||||
|
const tasks: KpiTask[] = [
|
||||||
|
{ id: '1', status: 'open', createdAt: iso(3 * day) },
|
||||||
|
{ id: '2', status: 'done', createdAt: iso(13 * day), updatedAt: iso(1 * day) },
|
||||||
|
];
|
||||||
|
const s = backlogSeries(tasks, 14);
|
||||||
|
expect(s).toHaveLength(14);
|
||||||
|
expect(s[13]).toBe(1); // today: only the open task is backlog
|
||||||
|
expect(s[0]).toBe(1); // 13 days ago: only task 2 existed and was not done yet
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('throughputSeries', () => {
|
||||||
|
it('counts done tasks per day', () => {
|
||||||
|
const tasks: KpiTask[] = [
|
||||||
|
{ id: '1', status: 'done', updatedAt: iso(0) },
|
||||||
|
{ id: '2', status: 'done', updatedAt: iso(0) },
|
||||||
|
{ id: '3', status: 'done', updatedAt: iso(5 * day) },
|
||||||
|
{ id: '4', status: 'open', updatedAt: iso(0) },
|
||||||
|
];
|
||||||
|
const s = throughputSeries(tasks, 14);
|
||||||
|
expect(s).toHaveLength(14);
|
||||||
|
expect(s[13]).toBe(2); // today
|
||||||
|
expect(s[8]).toBe(1); // 5 days ago
|
||||||
|
expect(s.reduce((a, b) => a + b, 0)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('areaPath', () => {
|
||||||
|
it('builds line and area paths scaled to width/height', () => {
|
||||||
|
const { line, area } = areaPath([0, 5, 10], 100, 50);
|
||||||
|
expect(line).toBe('M0,50 L50,25 L100,0');
|
||||||
|
expect(area).toBe('M0,50 L50,25 L100,0 L100,50 L0,50 Z');
|
||||||
|
});
|
||||||
|
it('flattens when all values are equal (no division by zero)', () => {
|
||||||
|
const { line } = areaPath([3, 3, 3], 90, 30);
|
||||||
|
expect(line).toBe('M0,15 L45,15 L90,15');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user