Files
kanban/frontend/src/api.ts
T
egutierrez fc7e6a34a7 feat(kanban): reporte diario al click en dia del calendario (issue 0093)
Adds a daily report dashboard accessible by clicking a day number in the
calendar view. Renders inside a full-width modal (90% width).

Backend (new file backend/reports.go):
- Type DailyReport with KPIs, rankings, done_cards list, reopened cards,
  3-bucket stale list (7/14/30d), lead time avg+p50+p95, 24-hour
  movement histogram, deadlines met/missed list, tag distribution and
  archived count.
- DB.DailyReportFor(date, tz) uses Europe/Madrid by default; computes
  [start,end) in local time, converts to UTC and queries:
  * cards.completed_at in range  -> done list
  * card_events kind=created in range -> created counts
  * card_column_history.entered_at in range -> moves + hourly
  * previousColumnWasDone() -> reopened detection
  * card_lock_history overlapping the day -> blocked_ms
  * stale buckets: open history entries on non-done columns aged >=7d
- New route GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid.

Frontend:
- api.ts: DailyReport type + dailyReport(date, tz?) call.
- New component DailyReportView (components/DailyReport.tsx):
  * 6 KPI cards (Hechas, Creadas, Movimientos, Bloqueado, Reabiertas,
    Deadlines on-time %).
  * 4 ranking cards (Top assignees done, Top assignees created,
    Top requesters atendidas, Top requesters aportadas).
  * Done cards table with click-to-jump (links open the card in board).
  * Mantine BarChart with movements per hour.
  * Tag chips, reopened list, deadlines list with late_ms, stale buckets.
- CalendarView wraps the day number in UnstyledButton with data-test
  attribute and forwards onOpenDailyReport.
- App.handleOpenDailyReport opens modals.open size 90% with the view;
  click on a card title closes the modal and jumps to the board with
  highlight (reuses existing handleJumpToCard).

Tests (e2e/daily-report.spec.ts):
- Endpoint shape: kpis, done_cards, hourly_moves[24], stale buckets.
- Calendar day click opens the modal with "Reporte diario" title and
  KPI labels visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00

362 lines
9.6 KiB
TypeScript

import type {
Board,
Card,
CardHistoryResponse,
CardMessage,
Column,
Metrics,
MetricsFilter,
Sticker,
User,
} from "./types";
import { fetchJSON as registryFetchJSON, HTTPError } from "@fn_library/infra/fetch_json";
export { HTTPError };
const BASE = "/api";
function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
return registryFetchJSON<T>(path, init, BASE);
}
export function getBoard(): Promise<Board> {
return fetchJSON("/board");
}
export function getFlags(): Promise<Record<string, boolean>> {
return fetchJSON("/flags");
}
export function createColumn(name: string): Promise<Column> {
return fetchJSON("/columns", { method: "POST", body: JSON.stringify({ name }) });
}
export interface UpdateColumnInput {
name?: string;
position?: number;
location?: "board" | "sidebar";
width?: number;
wip_limit?: number;
is_done?: boolean;
max_time_minutes?: number;
}
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
return fetchJSON(`/columns/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
export function deleteColumn(id: string): Promise<void> {
return fetchJSON(`/columns/${id}`, { method: "DELETE" });
}
export function reorderColumns(ids: string[]): Promise<void> {
return fetchJSON("/columns/reorder", { method: "POST", body: JSON.stringify({ ids }) });
}
export interface CreateCardInput {
column_id: string;
requester?: string;
title: string;
description?: string;
assignee_id?: string | null;
tags?: string[];
}
export function createCard(input: CreateCardInput): Promise<Card> {
return fetchJSON("/cards", { method: "POST", body: JSON.stringify(input) });
}
export interface UpdateCardInput {
requester?: string;
title?: string;
description?: string;
color?: string;
locked?: boolean;
assignee_id?: string | null;
tags?: string[];
deadline?: string | null;
}
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
return fetchJSON(`/cards/${id}`, { method: "PATCH", body: JSON.stringify(patch) });
}
export function deleteCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}`, { method: "DELETE" });
}
export function updateCardStickers(id: string, stickers: Sticker[]): Promise<void> {
return fetchJSON(`/cards/${id}/stickers`, {
method: "PUT",
body: JSON.stringify({ stickers }),
});
}
export function listTrash(): Promise<Card[]> {
return fetchJSON("/trash");
}
export function restoreCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/restore`, { method: "POST" });
}
export function purgeCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
}
export function listArchive(): Promise<Card[]> {
return fetchJSON("/archive");
}
export function archiveCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/archive`, { method: "POST" });
}
export function unarchiveCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/unarchive`, { method: "POST" });
}
export interface DailyReport {
date: string;
tz: string;
start_ts: string;
end_ts: string;
kpis: {
done: number;
created: number;
moves: number;
blocked_ms: number;
deadlines_met: number;
deadlines_missed: number;
reopened: number;
archived_auto: number;
archived_manual: number;
};
top_assignees_done: { user_id: string; name: string; count: number }[];
top_assignees_created: { user_id: string; name: string; count: number }[];
top_requesters_added: { name: string; count: number }[];
top_requesters_done: { name: string; count: number }[];
done_cards: {
id: string;
seq_num: number;
title: string;
requester: string;
assignee_id: string | null;
assignee_name: string | null;
tags: string[];
column_id: string;
column_name: string;
completed_at: string;
created_at: string;
lead_time_ms: number;
color: string;
}[];
reopened_cards: {
card_id: string;
title: string;
seq_num: number;
from_column: string;
to_column: string;
ts: string;
actor_id: string | null;
actor_name: string | null;
}[];
stale_cards: {
d7: StaleEntry[];
d14: StaleEntry[];
d30: StaleEntry[];
};
lead_time: { avg_ms: number; p50_ms: number; p95_ms: number; samples: number };
hourly_moves: number[];
deadlines: {
met: number;
missed: number;
list: {
card_id: string;
title: string;
seq_num: number;
deadline: string;
completed_at: string;
late_ms: number;
}[];
};
tags_done: { name: string; count: number }[];
archived_today: number;
}
export interface StaleEntry {
card_id: string;
title: string;
seq_num: number;
column_id: string;
column_name: string;
entered_at: string;
days: number;
}
export function dailyReport(date: string, tz?: string): Promise<DailyReport> {
const params = new URLSearchParams({ date });
if (tz) params.set("tz", tz);
return fetchJSON(`/reports/daily?${params.toString()}`);
}
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
return fetchJSON(`/cards/${id}/move`, {
method: "POST",
body: JSON.stringify({ column_id, ordered_ids }),
});
}
export function cardHistory(id: string): Promise<CardHistoryResponse> {
return fetchJSON(`/cards/${id}/history`);
}
export function listCardMessages(id: string): Promise<CardMessage[]> {
return fetchJSON(`/cards/${id}/messages`);
}
export function createCardMessage(id: string, body: string): Promise<CardMessage> {
return fetchJSON(`/cards/${id}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
}
export function deleteCardMessage(cardId: string, messageId: string): Promise<void> {
return fetchJSON(`/cards/${cardId}/messages/${messageId}`, { method: "DELETE" });
}
export function duplicateCard(id: string): Promise<Card> {
return fetchJSON(`/cards/${id}/duplicate`, { method: "POST" });
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;
}
export interface ChatToolCall {
tool: string;
ok: boolean;
error?: string;
input?: unknown;
}
// WebSocket streaming events emitted by /api/chat/ws.
export type ChatStreamEvent =
| { type: "delta"; text: string }
| { type: "tool_use"; tool_id: string; tool: string; input?: unknown }
| { type: "tool_result"; tool_id: string; result?: string; is_error?: boolean }
| { type: "result"; text?: string; is_error?: boolean }
| { type: "done"; board_changed?: boolean }
| { type: "error"; error: string };
// chatWSURL builds the absolute ws:// or wss:// URL of the streaming endpoint.
export function chatWSURL(): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/api/chat/ws`;
}
// streamChat opens a WebSocket, sends the message history, and streams events
// to onEvent. Returns a Promise that resolves when the server closes the
// connection (after a "done" event) and rejects on transport errors.
export function streamChat(
messages: ChatMessage[],
onEvent: (ev: ChatStreamEvent) => void,
signal?: AbortSignal
): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(chatWSURL());
let settled = false;
const finish = (err?: Error) => {
if (settled) return;
settled = true;
try {
ws.close();
} catch {
/* ignore */
}
if (err) reject(err);
else resolve();
};
if (signal) {
const abort = () => finish(new Error("aborted"));
if (signal.aborted) {
abort();
return;
}
signal.addEventListener("abort", abort, { once: true });
}
ws.onopen = () => {
ws.send(JSON.stringify({ messages }));
};
ws.onmessage = (e) => {
try {
const ev = JSON.parse(typeof e.data === "string" ? e.data : "") as ChatStreamEvent;
onEvent(ev);
if (ev.type === "done" || ev.type === "error") {
finish(ev.type === "error" ? new Error(ev.error) : undefined);
}
} catch (err) {
finish(err as Error);
}
};
ws.onerror = () => finish(new Error("websocket error"));
ws.onclose = () => finish();
});
}
export function login(username: string, password: string): Promise<User> {
return fetchJSON("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export function register(username: string, password: string, display_name?: string): Promise<User> {
return fetchJSON("/auth/register", {
method: "POST",
body: JSON.stringify({ username, password, display_name }),
});
}
export function logout(): Promise<void> {
return fetchJSON("/auth/logout", { method: "POST" });
}
export function getMe(): Promise<User> {
return fetchJSON("/me");
}
export function updateMe(patch: { color?: string }): Promise<User> {
return fetchJSON("/me", { method: "PATCH", body: JSON.stringify(patch) });
}
export function listUsers(): Promise<User[]> {
return fetchJSON("/users");
}
export function listTags(): Promise<string[]> {
return fetchJSON("/tags");
}
export function listRequesters(): Promise<string[]> {
return fetchJSON("/requesters");
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from);
if (f.to) qs.set("to", f.to);
if (f.assignee_id) qs.set("assignee_id", f.assignee_id);
if (f.requester) qs.set("requester", f.requester);
if (f.tags && f.tags.length > 0) qs.set("tags", f.tags.join(","));
const q = qs.toString();
return fetchJSON(`/metrics${q ? `?${q}` : ""}`);
}