diff --git a/apps/web/app/dashboard/projects/[projectId]/actions.ts b/apps/web/app/dashboard/projects/[projectId]/actions.ts index 903b7d0..fa94f87 100644 --- a/apps/web/app/dashboard/projects/[projectId]/actions.ts +++ b/apps/web/app/dashboard/projects/[projectId]/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache" import { Status } from "@/generated/prisma/enums" import { createAuditLog, formatChangedFields } from "@/lib/api/audit-log" +import { findReviewReader } from "@/lib/api/review-reader" import { invalidateProjectAndCompanyCache } from "@/lib/api/cache" import { projectAgentSelect, @@ -16,6 +17,7 @@ import { import { getTaskFreshnessUpdate } from "@/lib/api/task-freshness" import { userTaskUpdater } from "@/lib/api/task-updater" import { getSession } from "@/lib/auth" +import { getDoneSummaryReviewReadMarkerWhere } from "@/lib/dashboard/note-read-state" import { prisma } from "@/lib/prisma" import type { ProjectDetailData, ProjectTaskDetail } from "./types" @@ -248,6 +250,65 @@ export async function updateTaskStatusAction( return updateTaskAction(taskId, { status } as TaskMutationPayload) } +export async function markProjectTaskSummaryReadAction( + taskId: string +): Promise> { + const session = await getSession() + + if (!session) { + return { ok: false, error: "Unauthorized" } + } + + const task = await prisma.task.findFirst({ + where: { + id: taskId, + status: Status.done, + archivedAt: null, + note: { not: null }, + project: { company: { userId: session.userId } }, + }, + select: { + id: true, + projectId: true, + project: { select: { companyId: true } }, + }, + }) + + if (!task) { + return { ok: false, error: "Done task note not found." } + } + + const reviewReader = await findReviewReader(task.project.companyId) + + if (!reviewReader) { + return { ok: false, error: "Review reader agent not found." } + } + + const readAt = new Date() + + await prisma.taskReadMarker.upsert({ + where: { + taskId_agentId_status: { + taskId: task.id, + agentId: reviewReader.id, + status: Status.done, + }, + }, + create: { + taskId: task.id, + agentId: reviewReader.id, + status: Status.done, + readAt, + }, + update: { readAt }, + }) + + await invalidateAndRevalidate(task.project.companyId, task.projectId) + revalidatePath("/dashboard/notes") + + return { ok: true, taskId: task.id, readBy: reviewReader.AgentId } +} + export async function updateTaskAction( taskId: string, payload: Partial @@ -581,6 +642,11 @@ async function loadProjectDetail(projectId: string, userId: string) { } export async function loadProjectTaskCards(projectId: string) { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { companyId: true }, + }) + const reviewReader = project ? await findReviewReader(project.companyId) : null const tasks = await prisma.task.findMany({ where: { projectId, archivedAt: null }, orderBy: { name: "asc" }, @@ -596,7 +662,7 @@ export async function loadProjectTaskCards(projectId: string) { status: true, blockingReason: true, readMarkers: { - where: { status: Status.done, agent: { AgentId: "main" } }, + where: getDoneSummaryReviewReadMarkerWhere(reviewReader), select: { readAt: true }, }, assigned: { select: { id: true, name: true, position: true } }, diff --git a/apps/web/app/dashboard/projects/[projectId]/task-kanban.tsx b/apps/web/app/dashboard/projects/[projectId]/task-kanban.tsx index 17ac6e0..30e3bc4 100644 --- a/apps/web/app/dashboard/projects/[projectId]/task-kanban.tsx +++ b/apps/web/app/dashboard/projects/[projectId]/task-kanban.tsx @@ -50,6 +50,7 @@ import { deleteTaskAction, getProjectDetailAction, getTaskDetailAction, + markProjectTaskSummaryReadAction, updateTaskAction, updateTaskStatusAction, } from "./actions" @@ -235,6 +236,17 @@ export function TaskKanban({ queryClient.invalidateQueries({ queryKey: ["agents", companyId] }) }, }) + const markSummaryReadMutation = useMutation({ + mutationFn: (taskId: string) => + unwrapActionResult(markProjectTaskSummaryReadAction(taskId)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["project", projectId] }) + queryClient.invalidateQueries({ + queryKey: ["dashboard-summary", companyId], + }) + }, + }) + const deleteMutation = useMutation({ mutationFn: (taskId: string) => unwrapActionResult(deleteTaskAction(taskId)), @@ -430,6 +442,15 @@ export function TaskKanban({ label="Done summary" text={task.notePreview} tone={task.isUnreadDoneSummary ? "unread" : "default"} + summaryUpdatedAt={task.summaryUpdatedAt} + isUnread={task.isUnreadDoneSummary} + isMarkingRead={ + markSummaryReadMutation.isPending && + markSummaryReadMutation.variables === task.id + } + onMarkRead={() => + markSummaryReadMutation.mutate(task.id) + } /> ) : task.summaryUpdatedAt ? (

@@ -772,16 +793,25 @@ function LatencyDiagnostics({ } function TaskCardPreview({ + isMarkingRead = false, + isUnread = false, label, + onMarkRead, + summaryUpdatedAt, text, tone = "default", }: { + isMarkingRead?: boolean + isUnread?: boolean label: string + onMarkRead?: () => void + summaryUpdatedAt?: string | Date | null text: string tone?: "default" | "unread" }) { const [expanded, setExpanded] = useState(false) - const compact = text.length > 120 + const summaryDate = summaryUpdatedAt ? new Date(summaryUpdatedAt) : null + const hasSummaryDate = summaryDate && !Number.isNaN(summaryDate.getTime()) return (

{label} - {compact ? (expanded ? "Less" : "More") : "View"} + {expanded ? "Less" : "More"} + {hasSummaryDate ? ( +

+ Updated {formatRelativeTime(summaryDate)} +

+ ) : null}

{text}

+ {isUnread && onMarkRead ? ( + + ) : null}
) } diff --git a/apps/web/lib/dashboard/note-read-state.test.ts b/apps/web/lib/dashboard/note-read-state.test.ts index 6976347..470fa54 100644 --- a/apps/web/lib/dashboard/note-read-state.test.ts +++ b/apps/web/lib/dashboard/note-read-state.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { isDoneSummaryUnread } from "./note-read-state" +import { getDoneSummaryReviewReadMarkerWhere, isDoneSummaryUnread } from "./note-read-state" describe("dashboard done summary read state", () => { it("treats noted done tasks with unknown summary freshness as unread", () => { @@ -32,4 +32,10 @@ describe("dashboard done summary read state", () => { false ) }) + it("scopes project-card done summary reads to the resolved review reader", () => { + assert.deepEqual(getDoneSummaryReviewReadMarkerWhere({ id: "agent-natsuki" }), { + status: "done", + agentId: "agent-natsuki", + }) + }) }) diff --git a/apps/web/lib/dashboard/note-read-state.ts b/apps/web/lib/dashboard/note-read-state.ts index 4e494f9..52e0595 100644 --- a/apps/web/lib/dashboard/note-read-state.ts +++ b/apps/web/lib/dashboard/note-read-state.ts @@ -1,3 +1,11 @@ +import { Status } from "@/generated/prisma/enums" + +const missingReviewReaderAgentId = "__no_review_reader__" + +type DoneSummaryReviewReader = { + id: string +} | null + export function isDoneSummaryUnread({ readAt, summaryUpdatedAt, @@ -7,3 +15,14 @@ export function isDoneSummaryUnread({ }) { return !readAt || !summaryUpdatedAt || readAt < summaryUpdatedAt } + +export function getDoneSummaryReviewReadMarkerWhere( + reviewReader: DoneSummaryReviewReader +) { + return { + status: Status.done, + ...(reviewReader + ? { agentId: reviewReader.id } + : { agentId: missingReviewReaderAgentId }), + } +}