feat: triage y estimación (Fase 4)

This commit is contained in:
Nurfog committed 2026-10-14 13:40:04 -03:00
1 parent 9acaad4f62
commit 8f33add874
11 files changed
+208

No files matched your search

+7
View File
@@ -93,6 +93,13 @@ export default function CyclesPage() {
</span>
<span className="ms-auto" style={{ fontSize: 12.5, color: "var(--lp-text-faint)" }}>
{doneCount}/{cycleIssues.length} issues
{(() => {
const total = cycleIssues.reduce((sum, i) => sum + (i.estimate ?? 0), 0);
const done = cycleIssues
.filter((i) => doneKeys.has(i.status))
.reduce((sum, i) => sum + (i.estimate ?? 0), 0);
return total > 0 ? ` · ${done}/${total} pts` : "";
})()}
</span>
</div>
+22
View File
@@ -240,6 +240,28 @@ function IssueDetail({ issueId }: { issueId: string }) {
</select>
</div>
<div className="prop-row">
<span className="prop-label">Estimación</span>
<select
className="prop-select"
value={issue.estimate ?? ""}
onChange={(event) =>
patch(
event.target.value === ""
? { estimate: null }
: { estimate: Number(event.target.value) },
)
}
>
<option value="">Sin estimar</option>
{[0, 1, 2, 3, 5, 8, 13].map((points) => (
<option key={points} value={points}>
{points} pts
</option>
))}
</select>
</div>
<div className="prop-row">
<span className="prop-label">Asignado</span>
<select
+126
View File
@@ -0,0 +1,126 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, ApiError } from "@/lib/api";
import type { Issue, Label, User } from "@/lib/types";
import { useReference } from "@/lib/store";
import { useAuth } from "@/lib/auth";
import { useIssues } from "@/hooks/useIssues";
import { AppShell } from "@/components/layout/AppShell";
import { Avatar } from "@/components/ui/Avatar";
import { EmptyState } from "@/components/ui/EmptyState";
import { PriorityIcon, TypeIcon } from "@/components/ui/Icons";
import { LabelChip } from "@/components/ui/LabelChip";
/**
* Triage (Fase 4): cola de issues nuevos sin revisar.
*
* Cada fila permite asignar estado y responsable, y el botón "Triar"
* marca needsTriage = false. Triar es "ya lo miró un humano", no un
* estado más: funciona con cualquier workflow configurado.
*/
export default function TriagePage() {
const { user } = useAuth();
const { users, labels } = useReference();
const [refreshKey, setRefreshKey] = useState(0);
const [error, setError] = useState<string | null>(null);
const { issues, loading } = useIssues({ needsTriage: true }, refreshKey);
const userById = new Map(users.map((u) => [u.id, u]));
const labelById = new Map(labels.map((l) => [l.id, l]));
async function triage(issueId: string) {
setError(null);
try {
await api.updateIssue(issueId, {
needsTriage: false,
...(user ? { actorId: user.id } : {}),
});
setRefreshKey((k) => k + 1);
} catch (err) {
setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error");
}
}
return (
<AppShell
title="Triage"
subtitle={`${issues.length} pendiente${issues.length === 1 ? "" : "s"}`}
icon="bx-filter-alt"
>
<div className="page-pad pt-3" style={{ maxWidth: 900 }}>
{error && (
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
)}
{loading ? (
<div className="skeleton" style={{ height: 220, borderRadius: 10 }} />
) : issues.length === 0 ? (
<EmptyState
icon="bx-check-circle"
title="Nada por triar"
description="Todos los issues ya fueron revisados."
/>
) : (
<div className="d-flex flex-column gap-2">
{issues.map((issue) => (
<TriageRow
key={issue.id}
issue={issue}
userById={userById}
labelById={labelById}
onTriaged={() => void triage(issue.id)}
/>
))}
</div>
)}
</div>
</AppShell>
);
}
function TriageRow({
issue,
userById,
labelById,
onTriaged,
}: {
issue: Issue;
userById: Map<string, User>;
labelById: Map<string, Label>;
onTriaged: () => void;
}) {
const assignee = issue.assigneeId ? userById.get(issue.assigneeId) : undefined;
return (
<div className="lp-card p-3 d-flex align-items-center gap-3 flex-wrap">
<PriorityIcon priority={issue.priority} />
<TypeIcon type={issue.type} />
<div className="min-w-0 flex-grow-1">
<Link href={`/issues/${issue.id}`} className="d-flex gap-2 align-items-baseline">
<span className="issue-identifier">{issue.identifier}</span>
<span className="issue-title text-truncate">{issue.title}</span>
</Link>
<div className="d-flex gap-1 mt-1 flex-wrap">
{issue.labelIds
.map((id) => labelById.get(id))
.filter((l): l is NonNullable<typeof l> => Boolean(l))
.map((label) => (
<LabelChip key={label.id} label={label} />
))}
</div>
</div>
<Avatar user={assignee} />
<button type="button" className="btn btn-accent btn-sm" onClick={onTriaged}>
<i className="bx bx-check me-1" />
Triar
</button>
</div>
);
}
+8
View File
@@ -55,6 +55,14 @@ export function IssueRow({ issue, assignee, labels, showProject = true }: IssueR
<span className="issue-identifier">{issue.createdAt.slice(0, 10)}</span>
</td>
<td className="issue-cell issue-cell-secondary" style={{ width: 52 }}>
{issue.estimate != null && (
<span className="label-chip" title={`${issue.estimate} puntos`}>
{issue.estimate}
</span>
)}
</td>
<td className="issue-cell" style={{ width: 44 }}>
<Avatar user={assignee} />
</td>
+1
View File
@@ -82,6 +82,7 @@ export function AppShell({ title, subtitle, actions, icon = "bx-briefcase", chil
board: openIssues.length,
cycles: openIssues.filter((i) => i.cycleId).length,
projects: projects.length,
triage: issues.filter((i) => i.needsTriage).length,
};
}, [issues, projects, user]);
+1
View File
@@ -49,6 +49,7 @@ export function CommandPalette({ open, onClose, issues }: CommandPaletteProps) {
{ id: "nav-cycles", icon: "bx-reset", label: "Ir a Ciclos", hint: "C", run: () => router.push("/cycles") },
{ id: "nav-projects", icon: "bx-folder", label: "Ir a Proyectos", hint: "P", run: () => router.push("/projects") },
{ id: "nav-settings", icon: "bx-cog", label: "Ir a Configuración", hint: "S", run: () => router.push("/settings") },
{ id: "nav-triage", icon: "bx-filter-alt", label: "Ir a Triage", hint: "T", run: () => router.push("/triage") },
{
id: "new-issue",
icon: "bx-plus-circle",
+2
View File
@@ -11,6 +11,7 @@ interface SidebarProps {
board: number;
cycles: number;
projects: number;
triage: number;
};
projects: Array<{ id: string; name: string }>;
/** Controla el estado abierto/colapsado del shell. */
@@ -33,6 +34,7 @@ export function Sidebar({ counts, projects, open }: SidebarProps) {
{ href: "/cycles", icon: "bx-reset", label: "Ciclos", count: counts.cycles },
{ href: "/projects", icon: "bx-folder", label: "Proyectos", count: counts.projects },
{ href: "/settings", icon: "bx-cog", label: "Configuración", count: 0 },
{ href: "/triage", icon: "bx-filter-alt", label: "Triage", count: counts.triage },
];
const isActive = (href: string) =>
+6
View File
@@ -95,6 +95,12 @@ function buildQuery(filters?: IssueFilters): string {
// El backend llama `q` a la búsqueda de texto.
if (filters.query?.trim()) params.append("q", filters.query.trim());
// Triage y estimación (Fase 4 en sam).
if (filters.needsTriage !== undefined)
params.append("needsTriage", String(filters.needsTriage));
if (filters.estimated !== undefined)
params.append("estimated", String(filters.estimated));
const qs = params.toString();
return qs ? `?${qs}` : "";
}
+11
View File
@@ -86,6 +86,10 @@ function matchesFilters(issue: Issue, filters?: IssueFilters): boolean {
if (filters.cycleId?.length && !filters.cycleId.includes(issue.cycleId ?? "")) return false;
if (filters.labelIds?.length && !filters.labelIds.some((id) => issue.labelIds.includes(id)))
return false;
if (filters.needsTriage !== undefined && issue.needsTriage !== filters.needsTriage)
return false;
if (filters.estimated === true && issue.estimate == null) return false;
if (filters.estimated === false && issue.estimate != null) return false;
if (filters.query?.trim()) {
const q = filters.query.trim().toLowerCase();
@@ -206,6 +210,8 @@ export const mockApi: ProyectosApi = {
const status = input.status ?? "todo";
const known = state.states.find((s) => s.key === status && s.isActive);
if (!known) throw new Error(`Estado desconocido o inactivo: '${status}'.`);
if (input.estimate != null && input.estimate < 0)
throw new Error("La estimación no puede ser negativa.");
const project = state.projects.find((p) => p.id === input.projectId);
const now = new Date().toISOString();
@@ -216,6 +222,8 @@ export const mockApi: ProyectosApi = {
description: input.description ?? "",
status,
parentId: null,
needsTriage: true,
estimate: input.estimate ?? null,
priority: input.priority ?? 0,
type: input.type ?? "task",
assigneeId: input.assigneeId ?? null,
@@ -255,6 +263,9 @@ export const mockApi: ProyectosApi = {
if (patch.projectId !== undefined) track("project", issue.projectId, patch.projectId);
if (patch.cycleId !== undefined) track("cycle", issue.cycleId, patch.cycleId);
if (patch.parentId !== undefined) track("parent", issue.parentId ?? null, patch.parentId);
if (patch.needsTriage !== undefined)
track("triage", issue.needsTriage ? "pendiente" : "triado", patch.needsTriage ? "pendiente" : "triado");
if (patch.estimate !== undefined) track("estimate", issue.estimate, patch.estimate);
if (patch.labelIds !== undefined)
track("labels", issue.labelIds.join(","), patch.labelIds.join(","));
+16
View File
@@ -114,6 +114,8 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c3",
parentId: null,
needsTriage: false,
estimate: 5,
labelIds: ["l2"],
createdAt: "2026-10-01T09:15:00Z",
updatedAt: "2026-10-12T15:40:00Z",
@@ -131,6 +133,8 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c3",
parentId: null,
needsTriage: false,
estimate: 3,
labelIds: ["l1", "l4"],
createdAt: "2026-10-03T11:00:00Z",
updatedAt: "2026-10-10T08:20:00Z",
@@ -148,6 +152,8 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c3",
parentId: null,
needsTriage: false,
estimate: 8,
labelIds: ["l3"],
createdAt: "2026-09-28T14:30:00Z",
updatedAt: "2026-10-11T17:05:00Z",
@@ -165,6 +171,8 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c3",
parentId: null,
needsTriage: false,
estimate: null,
labelIds: ["l2"],
createdAt: "2026-09-20T10:00:00Z",
updatedAt: "2026-10-08T12:00:00Z",
@@ -182,6 +190,8 @@ export const issues: Issue[] = [
projectId: "p2",
cycleId: null,
parentId: null,
needsTriage: false,
estimate: null,
labelIds: ["l1", "l3"],
createdAt: "2026-10-06T09:00:00Z",
updatedAt: "2026-10-06T09:00:00Z",
@@ -199,6 +209,8 @@ export const issues: Issue[] = [
projectId: "p2",
cycleId: "c4",
parentId: "i5",
needsTriage: false,
estimate: 5,
labelIds: ["l2"],
createdAt: "2026-10-07T16:45:00Z",
updatedAt: "2026-10-09T10:10:00Z",
@@ -216,6 +228,8 @@ export const issues: Issue[] = [
projectId: "p3",
cycleId: "c2",
parentId: null,
needsTriage: false,
estimate: null,
labelIds: ["l5"],
createdAt: "2026-08-15T13:20:00Z",
updatedAt: "2026-09-30T09:00:00Z",
@@ -233,6 +247,8 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c4",
parentId: null,
needsTriage: false,
estimate: null,
labelIds: ["l5"],
createdAt: "2026-10-09T08:00:00Z",
updatedAt: "2026-10-09T08:00:00Z",
+8
View File
@@ -120,6 +120,10 @@ export interface Issue {
cycleId: ID | null;
/** Id del issue padre (sub-issues, Fase 3 en sam). Null/ausente = raíz. */
parentId: ID | null;
/** TRUE = pendiente de triage (Fase 4 en sam). */
needsTriage: boolean;
/** Puntos de historia, null = sin estimar (Fase 4 en sam). */
estimate: number | null;
labelIds: ID[];
createdAt: string;
updatedAt: string;
@@ -128,6 +132,8 @@ export interface Issue {
/** Filtros soportados por `listIssues`. */
export interface IssueFilters {
needsTriage?: boolean;
estimated?: boolean;
status?: IssueStatus[];
priority?: IssuePriority[];
assigneeId?: ID[];
@@ -152,6 +158,8 @@ export interface IssueInput {
projectId?: ID | null;
cycleId?: ID | null;
parentId?: ID | null;
needsTriage?: boolean;
estimate?: number | null;
labelIds?: ID[];
actorId?: ID;
}