feat: línea de tiempo mezclada en el detalle (Fase 2)

This commit is contained in:
Nurfog committed 2026-10-14 13:16:30 -03:00
1 parent e15dc3926e
commit bbb5fd2b1a
6 files changed
+307 -32

No files matched your search

+26 -31
View File
@@ -4,13 +4,13 @@ import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { Comment, Issue, IssueStatus } from "@/lib/types";
import type { Activity, Comment, Issue, IssueStatus } from "@/lib/types";
import { PRIORITY_META, TYPE_META } from "@/lib/meta";
import { formatLongDate, formatRelative } from "@/lib/format";
import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/store";
import { useAuth } from "@/lib/auth";
import { AppShell } from "@/components/layout/AppShell";
import { Avatar } from "@/components/ui/Avatar";
import { ActivityFeed } from "@/components/issues/ActivityFeed";
import { EmptyState } from "@/components/ui/EmptyState";
import { TypeIcon } from "@/components/ui/Icons";
@@ -36,22 +36,25 @@ function IssueDetail({ issueId }: { issueId: string }) {
const { user } = useAuth();
const { meta, order } = useWorkflowStates();
const { users, projects, cycles, labels } = useReference();
const { projectById, userById } = useReferenceLookups();
const { projectById } = useReferenceLookups();
const [issue, setIssue] = useState<Issue | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [activity, setActivity] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
const [draft, setDraft] = useState("");
const load = useCallback(async () => {
setLoading(true);
const [found, issueComments] = await Promise.all([
const [found, issueComments, issueActivity] = await Promise.all([
api.getIssue(issueId),
api.listComments(issueId),
api.listActivity(issueId),
]);
setIssue(found);
setComments(issueComments);
setActivity(issueActivity);
setLoading(false);
}, [issueId]);
@@ -67,8 +70,13 @@ function IssueDetail({ issueId }: { issueId: string }) {
setIssue((current) => (current ? { ...current, ...partial } : current));
try {
const updated = await api.updateIssue(issue.id, partial);
const updated = await api.updateIssue(issue.id, {
...partial,
...(user ? { actorId: user.id } : {}),
});
setIssue(updated);
// Refrescar la línea de tiempo con los eventos recién creados.
void api.listActivity(issue.id).then(setActivity);
} catch {
// Revertir si el backend rechaza el cambio.
void load();
@@ -84,6 +92,7 @@ function IssueDetail({ issueId }: { issueId: string }) {
setDraft("");
const created = await api.addComment(issueId, body, user.id);
setComments((current) => [...current, created]);
void api.listActivity(issueId).then(setActivity);
}
if (loading) {
@@ -150,35 +159,21 @@ function IssueDetail({ issueId }: { issueId: string }) {
)}
</div>
{/* Comentarios */}
{/* Línea de tiempo: comentarios + eventos del log (Fase 2) */}
<section>
<div className="detail-section-title">Actividad</div>
{comments.length === 0 && (
<p style={{ color: "var(--lp-text-faint)", fontSize: 13 }}>
Sin comentarios todavía.
</p>
)}
{comments.map((comment) => {
const author = userById.get(comment.authorId);
return (
<div key={comment.id} className="comment">
<Avatar user={author} size="lg" />
<div className="flex-grow-1">
<div className="d-flex align-items-baseline gap-2 mb-1">
<span style={{ fontSize: 13, fontWeight: 600 }}>
{author?.name ?? "Desconocido"}
</span>
<span style={{ fontSize: 12, color: "var(--lp-text-faint)" }}>
{formatRelative(comment.createdAt)}
</span>
</div>
<div className="comment-body">{comment.body}</div>
</div>
</div>
);
})}
<ActivityFeed
comments={comments}
activity={activity}
users={new Map(users.map((u) => [u.id, u]))}
states={Object.fromEntries(
order.map((key) => [key, { label: meta[key]?.label ?? key }]),
)}
projects={new Map(projects.map((p) => [p.id, p]))}
cycles={new Map(cycles.map((c) => [c.id, c]))}
labels={new Map(labels.map((l) => [l.id, l]))}
/>
<form onSubmit={handleComment} className="mt-3">
<textarea
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { useMemo } from "react";
import type { Activity, Comment, Cycle, Label, Project, User } from "@/lib/types";
import { formatRelative } from "@/lib/format";
import { Avatar } from "@/components/ui/Avatar";
interface ActivityFeedProps {
comments: Comment[];
activity: Activity[];
users: Map<string, User>;
states: Record<string, { label: string }>;
projects: Map<string, Project>;
cycles: Map<string, Cycle>;
labels: Map<string, Label>;
}
type FeedItem =
| { kind: "comment"; at: string; comment: Comment }
| { kind: "event"; at: string; event: Activity };
/**
* Línea de tiempo mezclada de un issue (Fase 2): comentarios y eventos
* del log en orden cronológico, con descripciones legibles.
*
* Los valores guardados son claves/ids; aquí se resuelven a nombres
* usando los datos de referencia (usuarios, estados, proyectos...).
*/
export function ActivityFeed({ comments, activity, users, states, projects, cycles, labels }: ActivityFeedProps) {
const items = useMemo<FeedItem[]>(() => {
const list: FeedItem[] = [
...comments.map((comment) => ({ kind: "comment" as const, at: comment.createdAt, comment })),
...activity
// Los comentarios ya se muestran como tales; no duplicarlos.
.filter((event) => event.action !== "comment_added")
.map((event) => ({ kind: "event" as const, at: event.createdAt, event })),
];
return list.sort((a, b) => a.at.localeCompare(b.at));
}, [comments, activity]);
if (items.length === 0) {
return (
<p style={{ color: "var(--inv-muted)", fontSize: 13 }}>
Sin actividad todavía.
</p>
);
}
return (
<div>
{items.map((item) =>
item.kind === "comment" ? (
<CommentRow
key={`c-${item.comment.id}`}
comment={item.comment}
author={users.get(item.comment.authorId)}
/>
) : (
<EventRow
key={`e-${item.event.id}`}
event={item.event}
actor={item.event.actorId ? users.get(item.event.actorId) : undefined}
states={states}
projects={projects}
cycles={cycles}
labels={labels}
users={users}
/>
),
)}
</div>
);
}
function CommentRow({ comment, author }: { comment: Comment; author?: User }) {
return (
<div className="comment">
<Avatar user={author} size="lg" />
<div className="flex-grow-1">
<div className="d-flex align-items-baseline gap-2 mb-1">
<span style={{ fontSize: 13, fontWeight: 600 }}>{author?.name ?? "Desconocido"}</span>
<span style={{ fontSize: 12, color: "var(--inv-muted)" }}>
{formatRelative(comment.createdAt)}
</span>
</div>
<div className="comment-body">{comment.body}</div>
</div>
</div>
);
}
/** Nombre legible de un valor según la acción (id → nombre cuando aplica). */
function resolveValue(
action: string,
value: string | null,
ctx: {
states: Record<string, { label: string }>;
projects: Map<string, Project>;
cycles: Map<string, Cycle>;
labels: Map<string, Label>;
users: Map<string, User>;
},
): string {
if (value == null || value === "") return "—";
switch (action) {
case "status":
return ctx.states[value]?.label ?? value;
case "assignee":
return ctx.users.get(value)?.name ?? value;
case "project":
return ctx.projects.get(value)?.name ?? value;
case "cycle":
return ctx.cycles.get(value)?.name ?? value;
case "labels":
if (value === "") return "ninguna";
return value
.split(",")
.map((id) => ctx.labels.get(id)?.name ?? id)
.join(", ");
case "priority": {
const names: Record<string, string> = {
"0": "sin prioridad",
"1": "baja",
"2": "media",
"3": "alta",
"4": "urgente",
};
return names[value] ?? value;
}
case "parent":
return value === "" ? "raíz" : `issue #${value}`;
default:
return value.length > 120 ? `${value.slice(0, 120)}…` : value;
}
}
const ACTION_VERB: Record<string, string> = {
created: "creó el issue",
status: "cambió el estado",
assignee: "reasignó el issue",
priority: "cambió la prioridad",
type: "cambió el tipo",
title: "renombró el issue",
description: "editó la descripción",
project: "movió el issue de proyecto",
cycle: "movió el issue de ciclo",
labels: "actualizó las etiquetas",
parent: "movió el issue en la jerarquía",
relation_added: "vinculó el issue",
relation_removed: "quitó un vínculo",
subscribed: "se suscribió",
unsubscribed: "dejó de seguir",
};
function EventRow({
event,
actor,
states,
projects,
cycles,
labels,
users,
}: {
event: Activity;
actor?: User;
states: Record<string, { label: string }>;
projects: Map<string, Project>;
cycles: Map<string, Cycle>;
labels: Map<string, Label>;
users: Map<string, User>;
}) {
const ctx = { states, projects, cycles, labels, users };
const verb = ACTION_VERB[event.action] ?? event.action;
// Acciones con antes/después; el resto muestra el valor nuevo o nada.
const hasTransition = event.oldValue != null || event.newValue != null;
const detail =
event.action === "created"
? ` (${event.newValue})`
: hasTransition
? `: ${resolveValue(event.action, event.oldValue, ctx)} → ${resolveValue(event.action, event.newValue, ctx)}`
: "";
return (
<div className="comment" style={{ alignItems: "center" }}>
<Avatar user={actor} />
<div className="flex-grow-1" style={{ fontSize: 13, color: "var(--inv-muted)" }}>
<span style={{ fontWeight: 600, color: "var(--bs-body-color)" }}>
{actor?.name ?? "Sistema"}
</span>{" "}
{verb}
{detail}
<span style={{ fontSize: 12, color: "var(--inv-muted)" }}>
{" "}
· {formatRelative(event.createdAt)}
</span>
</div>
</div>
);
}
+4
View File
@@ -1,4 +1,5 @@
import type {
Activity,
Comment,
Cycle,
ID,
@@ -41,6 +42,9 @@ export interface ProyectosApi {
createIssue(input: IssueInput): Promise<Issue>;
updateIssue(id: ID, patch: Partial<IssueInput>): Promise<Issue>;
// Línea de tiempo (Fase 2 en sam): eventos de creación, cambios y comentarios.
listActivity(issueId: ID): Promise<Activity[]>;
// Comentarios
listComments(issueId: ID): Promise<Comment[]>;
/** `authorId` es el idUsuario del usuario en sesión. */
+3
View File
@@ -1,4 +1,5 @@
import type {
Activity,
Comment,
Cycle,
ID,
@@ -158,6 +159,8 @@ export const httpApi: ProyectosApi = {
body: JSON.stringify(patch),
}),
listActivity: (issueId: ID) => request<Activity[]>(`/api/proyectos/issues/${issueId}/activity`),
listComments: (issueId: ID) => request<Comment[]>(`/api/proyectos/issues/${issueId}/comments`),
addComment: (issueId: ID, body: string, authorId: ID) =>
+51
View File
@@ -1,4 +1,5 @@
import type {
Activity,
Comment,
Cycle,
ID,
@@ -25,6 +26,7 @@ import type { ProyectosApi } from "./client";
const state = {
issues: structuredClone(seed.issues),
comments: structuredClone(seed.comments),
activity: [] as Activity[],
projects: structuredClone(seed.projects),
cycles: structuredClone(seed.cycles),
users: structuredClone(seed.users),
@@ -34,6 +36,25 @@ const state = {
};
/** Claves que cuentan como terminadas (bandera is_done de la API). */
/** Registra un evento en la línea de tiempo (réplica del backend). */
function logActivity(
issueId: ID,
actorId: ID | null,
action: string,
oldValue: string | null,
newValue: string | null,
) {
state.activity.push({
id: nextId("ev"),
issueId,
actorId,
action,
oldValue,
newValue,
createdAt: new Date().toISOString(),
});
}
function doneKeys(): Set<string> {
return new Set(state.states.filter((s) => s.isDone).map((s) => s.key));
}
@@ -203,6 +224,7 @@ export const mockApi: ProyectosApi = {
};
state.issues.unshift(issue);
logActivity(issue.id, null, "created", null, issue.identifier);
return issue;
},
@@ -211,6 +233,26 @@ export const mockApi: ProyectosApi = {
const issue = state.issues.find((i) => i.id === id);
if (!issue) throw new Error(`Issue ${id} no encontrado`);
// Solo se registra lo que cambió de verdad (igual que el backend).
const actor = (patch as { actorId?: ID }).actorId ?? null;
const changed: Array<[string, string | null, string | null]> = [];
const track = (action: string, oldV: unknown, newV: unknown) => {
const o = oldV == null ? null : String(oldV);
const n = newV == null ? null : String(newV);
if (o !== n) changed.push([action, o, n]);
};
if (patch.title !== undefined) track("title", issue.title, patch.title);
if (patch.description !== undefined) track("description", issue.description, patch.description);
if (patch.status !== undefined) track("status", issue.status, patch.status);
if (patch.type !== undefined) track("type", issue.type, patch.type);
if (patch.priority !== undefined) track("priority", issue.priority, patch.priority);
if (patch.assigneeId !== undefined) track("assignee", issue.assigneeId, patch.assigneeId);
if (patch.projectId !== undefined) track("project", issue.projectId, patch.projectId);
if (patch.cycleId !== undefined) track("cycle", issue.cycleId, patch.cycleId);
if (patch.labelIds !== undefined)
track("labels", issue.labelIds.join(","), patch.labelIds.join(","));
Object.assign(issue, patch, { updatedAt: new Date().toISOString() });
// Mantener `completedAt` coherente con el estado final
@@ -218,9 +260,17 @@ export const mockApi: ProyectosApi = {
const isFinished = doneKeys().has(issue.status);
issue.completedAt = isFinished ? (issue.completedAt ?? new Date().toISOString()) : null;
for (const [action, o, n] of changed) logActivity(id, actor, action, o, n);
return issue;
},
async listActivity(issueId: ID): Promise<Activity[]> {
await delay(60);
return state.activity
.filter((e) => e.issueId === issueId)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
async listComments(issueId: ID): Promise<Comment[]> {
await delay(60);
return state.comments.filter((c) => c.issueId === issueId);
@@ -236,6 +286,7 @@ export const mockApi: ProyectosApi = {
createdAt: new Date().toISOString(),
};
state.comments.push(comment);
logActivity(issueId, authorId, "comment_added", null, body.slice(0, 200));
return comment;
},
};
+22 -1
View File
@@ -82,6 +82,22 @@ export interface Cycle {
status: "upcoming" | "active" | "completed";
}
/**
* Evento del log de actividad (Fase 2 en sam).
* action: created, status, assignee, priority, type, title, description,
* project, cycle, labels, comment_added, parent, relation_added,
* relation_removed, subscribed, unsubscribed.
*/
export interface Activity {
id: ID;
issueId: ID;
actorId: ID | null;
action: string;
oldValue: string | null;
newValue: string | null;
createdAt: string;
}
export interface Comment {
id: ID;
issueId: ID;
@@ -119,7 +135,11 @@ export interface IssueFilters {
query?: string;
}
/** Payload para crear/actualizar un issue. */
/**
* Payload para crear/actualizar un issue.
* actorId es transitorio: el backend lo usa como autor del log y no lo
* persiste en el issue (ver BuildPatch en sam).
*/
export interface IssueInput {
title: string;
description?: string;
@@ -130,4 +150,5 @@ export interface IssueInput {
projectId?: ID | null;
cycleId?: ID | null;
labelIds?: ID[];
actorId?: ID;
}