feat: add threaded comments, activity feeds, and task dependencies

This commit is contained in:
2026-08-10 21:59:10 +00:00
parent 1059512888
commit a3e4d3c868
35 changed files with 10283 additions and 578 deletions
+4
View File
@@ -18,11 +18,13 @@ import { dashboardRoutes } from "./routes/dashboard";
import { agentRoutes } from "./routes/agents";
import { webhookRoutes } from "./routes/webhooks";
import { canvasRoutes } from "./routes/canvas";
import { commentRoutes } from "./routes/comments";
import { dailyNoteRoutes } from "./routes/daily-notes";
import { tagRoutes } from "./routes/tags";
import { customFieldRoutes } from "./routes/custom-fields";
import { errorLogRoutes } from "./routes/error-log";
import { analyticsRoutes } from "./routes/analytics";
import { activityRoutes } from "./routes/activity";
import { importExportRoutes } from "./routes/import-export";
import { notificationRoutes } from "./routes/notifications";
import { healthHandler } from "./routes/health";
@@ -54,11 +56,13 @@ app.route("/api/dashboard", dashboardRoutes);
app.route("/api/agents", agentRoutes);
app.route("/api/webhooks", webhookRoutes);
app.route("/api/canvas", canvasRoutes);
app.route("/api/comments", commentRoutes);
app.route("/api/daily-notes", dailyNoteRoutes);
app.route("/api/tags", tagRoutes);
app.route("/api/custom-fields", customFieldRoutes);
app.route("/api/error-log", errorLogRoutes);
app.route("/api/analytics", analyticsRoutes);
app.route("/api/activity", activityRoutes);
app.route("/api/notifications", notificationRoutes);
app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes);
+104
View File
@@ -0,0 +1,104 @@
import { Hono } from "hono";
import {
db,
activityFeed,
tasks,
projects,
habits,
notes,
canvases,
dailyNotes,
calendarEvents,
} from "@project-e/db";
import { and, desc, eq, sql } from "drizzle-orm";
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
import { z } from "zod";
export const activityRoutes = new Hono();
// Entity types that participate in the generic activity feed / comments API.
// The comments table stores the same entityType strings, so keep this enum and
// the entityWorkspaceLookups map below in sync.
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]);
interface EntityWorkspaceLookup {
table: AnyPgTable;
idColumn: AnyPgColumn;
workspaceColumn: AnyPgColumn;
}
// Maps an entityType (as recorded in activity_feed) to the table + column that
// holds its owning workspace/domain. All of these tables use domain_id.
const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId },
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
};
async function resolveEntityWorkspaceId(entityType: string, entityId: string): Promise<string | null> {
const lookup = entityWorkspaceLookups[entityType];
if (!lookup) return null;
const [row] = await db
.select({ workspaceId: lookup.workspaceColumn })
.from(lookup.table)
.where(eq(lookup.idColumn, entityId))
.limit(1);
// AnyPgColumn erases the concrete column type, so the selected value is unknown.
return (row?.workspaceId as string | undefined) ?? null;
}
// GET /api/activity?entityType=&entityId=&limit= — Activity feed for one entity.
activityRoutes.get("/", async (c) => {
try {
await requireAuth(c);
const entityType = c.req.query("entityType") || "";
const entityId = c.req.query("entityId") || "";
const entityTypeResult = entityTypeEnum.safeParse(entityType);
if (!entityTypeResult.success) {
return c.json(createErrorResponse("VALIDATION_ERROR", "Invalid entityType"), 400);
}
if (!isUuid(entityId)) {
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
}
const domainId = await resolveEntityWorkspaceId(entityType, entityId);
if (!domainId) {
return c.json(createErrorResponse("NOT_FOUND", "Entity not found"), 404);
}
await requireWorkspaceAccess(c, domainId);
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "50", 10) || 50, 1), 200);
const conditions = [
eq(activityFeed.entityType, entityType),
eq(activityFeed.entityId, entityId),
eq(activityFeed.workspaceId, domainId),
];
const [items, countResult] = await Promise.all([
db.select()
.from(activityFeed)
.where(and(...conditions))
.orderBy(desc(activityFeed.createdAt))
.limit(limit),
db.select({ count: sql<number>`count(*)` })
.from(activityFeed)
.where(and(...conditions)),
]);
return c.json({ items, totalItems: Number(countResult[0]?.count || 0) });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[activity] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get activity" } }, 500);
}
});
+222
View File
@@ -0,0 +1,222 @@
import { Hono } from "hono";
import {
db,
comments,
tasks,
projects,
habits,
notes,
canvases,
dailyNotes,
calendarEvents,
} from "@project-e/db";
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
export const commentRoutes = new Hono();
// Entity types that support comments. Keep in sync with entityWorkspaceLookups.
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]);
interface EntityWorkspaceLookup {
table: AnyPgTable;
idColumn: AnyPgColumn;
workspaceColumn: AnyPgColumn;
}
// Maps an entityType (as recorded in comments.entityType) to the table + column
// that holds its owning workspace/domain. All of these tables use domain_id.
const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId },
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
};
async function resolveEntityWorkspaceId(entityType: string, entityId: string): Promise<string | null> {
const lookup = entityWorkspaceLookups[entityType];
if (!lookup) return null;
const [row] = await db
.select({ workspaceId: lookup.workspaceColumn })
.from(lookup.table)
.where(eq(lookup.idColumn, entityId))
.limit(1);
// AnyPgColumn erases the concrete column type, so the selected value is unknown.
return (row?.workspaceId as string | undefined) ?? null;
}
const createCommentSchema = z.object({
entityType: entityTypeEnum,
entityId: z.string().uuid(),
content: z.string().min(1, "Content is required"),
parentId: z.string().uuid().optional(),
});
// GET /api/comments?entityType=&entityId= — Flat list of non-deleted comments
// for an entity, ordered oldest-first. The frontend assembles the reply tree
// from parentId.
commentRoutes.get("/", async (c) => {
try {
await requireAuth(c);
const entityType = c.req.query("entityType") || "";
const entityId = c.req.query("entityId") || "";
const entityTypeResult = entityTypeEnum.safeParse(entityType);
if (!entityTypeResult.success) {
return c.json(createErrorResponse("VALIDATION_ERROR", "Invalid entityType"), 400);
}
if (!isUuid(entityId)) {
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
}
const domainId = await resolveEntityWorkspaceId(entityType, entityId);
if (!domainId) {
return c.json(createErrorResponse("NOT_FOUND", "Entity not found"), 404);
}
await requireWorkspaceAccess(c, domainId);
const items = await db.select()
.from(comments)
.where(and(
eq(comments.entityType, entityType),
eq(comments.entityId, entityId),
eq(comments.workspaceId, domainId),
isNull(comments.deletedAt),
))
.orderBy(asc(comments.createdAt));
return c.json({ items });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[comments] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get comments" } }, 500);
}
});
// POST /api/comments — Create a comment (or a reply via parentId).
commentRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createCommentSchema.parse(body);
const domainId = await resolveEntityWorkspaceId(data.entityType, data.entityId);
if (!domainId) {
return c.json(createErrorResponse("NOT_FOUND", "Entity not found"), 404);
}
await requireWorkspaceAccess(c, domainId);
if (data.parentId) {
const [parent] = await db.select({
id: comments.id,
entityType: comments.entityType,
entityId: comments.entityId,
deletedAt: comments.deletedAt,
})
.from(comments)
.where(eq(comments.id, data.parentId))
.limit(1);
if (!parent || parent.deletedAt !== null || parent.entityType !== data.entityType || parent.entityId !== data.entityId) {
return c.json(createErrorResponse("VALIDATION_ERROR", "Parent comment not found or does not belong to this entity"), 400);
}
}
const [newComment] = await db.insert(comments).values({
entityType: data.entityType,
entityId: data.entityId,
workspaceId: domainId,
parentId: data.parentId ?? null,
author: user.name,
content: data.content.trim(),
}).returning();
await recordActivity({
actor: user.name,
action: "commented",
entityType: data.entityType,
entityId: data.entityId,
changes: { commentId: newComment.id },
workspaceId: domainId,
});
return c.json(newComment, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[comments] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create comment" } }, 500);
}
});
// DELETE /api/comments/:id — Soft-delete a comment and all of its descendants.
commentRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
}
const [comment] = await db.select()
.from(comments)
.where(eq(comments.id, id))
.limit(1);
if (!comment) {
return c.json(createErrorResponse("NOT_FOUND", "Comment not found"), 404);
}
await requireWorkspaceAccess(c, comment.workspaceId);
// Soft-delete the comment and every descendant. Comment volume is low, so a
// loop over parentId is fine.
const deletedAt = new Date();
const idsToDelete = [id];
let frontier: string[] = [id];
while (frontier.length > 0) {
const children = await db.select({ id: comments.id })
.from(comments)
.where(inArray(comments.parentId, frontier));
frontier = children
.map(child => child.id)
.filter(childId => !idsToDelete.includes(childId));
idsToDelete.push(...frontier);
}
await db.update(comments)
.set({ deletedAt })
.where(inArray(comments.id, idsToDelete));
await recordActivity({
actor: user.name,
action: "deleted_comment",
entityType: comment.entityType,
entityId: comment.entityId,
changes: { commentId: id },
workspaceId: comment.workspaceId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[comments] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete comment" } }, 500);
}
});
+8 -4
View File
@@ -311,15 +311,19 @@ habitRoutes.get("/:id", async (c) => {
await requireWorkspaceAccess(c, habit.domainId);
// Fetch recent completions (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Fetch recent completions (default last 30 days; ?days= extends up to a
// year so the frontend heatmap can show longer history)
const url = new URL(c.req.url);
const daysParam = url.searchParams.get("days");
const days = Math.min(365, Math.max(1, parseInt(daysParam || "30") || 30));
const since = new Date();
since.setDate(since.getDate() - days);
const recentCompletions = await db.select()
.from(habitCompletions)
.where(and(
eq(habitCompletions.habitId, id),
gte(habitCompletions.date, thirtyDaysAgo),
gte(habitCompletions.date, since),
))
.orderBy(desc(habitCompletions.date));
+124 -86
View File
@@ -714,6 +714,130 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
}
});
// POST /api/tasks/:id/dependencies — Make this task depend on another task
taskRoutes.post("/:id/dependencies", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { dependsOnTaskId } = z.object({
dependsOnTaskId: z.string().uuid("Invalid task id"),
}).parse(body);
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// A task cannot depend on itself
if (dependsOnTaskId === id) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot depend on itself" } }, 400);
}
const [depTask] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, dependsOnTaskId), isNull(tasks.deletedAt)))
.limit(1);
if (!depTask) {
return c.json({ error: { code: "NOT_FOUND", message: "Dependency task not found" } }, 404);
}
if (depTask.domainId !== task.domainId) {
return c.json({ error: { code: "FORBIDDEN", message: "Dependency task does not belong to this workspace" } }, 403);
}
// Cycle guard: walk the dependency chain (X depends on Y, Y on Z, ...) from
// dependsOnTaskId; reaching id means adding this edge would create a cycle.
let currentId: string | null = dependsOnTaskId;
const visited = new Set<string>([id]);
while (currentId) {
if (visited.has(currentId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Circular dependency detected" } }, 400);
}
visited.add(currentId);
const [next] = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
.from(taskDependencies)
.where(eq(taskDependencies.taskId, currentId))
.limit(1);
currentId = next?.dependsOnTaskId ?? null;
}
// Junction table has a composite PK — ignore duplicate edges
await db.insert(taskDependencies).values({ taskId: id, dependsOnTaskId }).onConflictDoNothing();
await recordActivity({
actor: user.name,
action: "dependency_added",
entityType: "task",
entityId: id,
changes: { dependsOnTaskId },
workspaceId: task.domainId,
});
await enqueueWebhooks({ workspaceId: task.domainId, event: "task.updated", entityType: "task", entityId: id, data: { dependsOnTaskId } });
return c.json({ success: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[tasks] POST /:id/dependencies error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add dependency" } }, 500);
}
});
// DELETE /api/tasks/:id/dependencies/:depId — Remove a dependency
taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const depId = c.req.param("depId");
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// Junction table has no deleted_at — hard delete is correct here
await db.delete(taskDependencies).where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, depId)));
await recordActivity({
actor: user.name,
action: "dependency_removed",
entityType: "task",
entityId: id,
changes: { removedDependsOnTaskId: depId },
workspaceId: task.domainId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[tasks] DELETE /:id/dependencies/:depId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove dependency" } }, 500);
}
});
// POST /api/tasks/:id/status — Change task status (Kanban drag)
taskRoutes.post("/:id/status", async (c) => {
try {
@@ -813,92 +937,6 @@ taskRoutes.get("/:id/history", async (c) => {
}
});
// GET /api/tasks/:id/comments — Comment thread (stored in activity feed as entityType=comment)
taskRoutes.get("/:id/comments", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
const comments = await db.select()
.from(activityFeed)
.where(and(
eq(activityFeed.entityId, id),
eq(activityFeed.entityType, "comment"),
eq(activityFeed.workspaceId, task.domainId),
))
.orderBy(asc(activityFeed.createdAt));
return c.json({ items: comments, totalItems: comments.length });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[tasks] GET /:id/comments error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get comments" } }, 500);
}
});
// POST /api/tasks/:id/comments — Add a comment
taskRoutes.post("/:id/comments", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { content } = z.object({
content: z.string().min(1, "Content is required"),
}).parse(body);
// Verify task exists
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
await recordActivity({
actor: user.name,
action: "commented",
entityType: "comment",
entityId: id,
changes: { content },
workspaceId: task.domainId,
});
return c.json({ success: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[tasks] POST /:id/comments error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add comment" } }, 500);
}
});
// GET /api/tasks/:id/attachments — File attachments metadata
taskRoutes.get("/:id/attachments", async (c) => {
try {
@@ -0,0 +1,28 @@
/**
* Lightweight SVG bar chart part of the shared chart set intentionally
* implemented as plain SVG instead of pulling in the recharts dependency.
*/
export function BarChart({ data, xKey, yKey, yKey2, color = "#3b82f6", color2 = "#f97316", height = 120 }: { data: any[]; xKey: string; yKey: string; yKey2?: string; color?: string; color2?: string; height?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const valOf = (d: any, key?: string) => (key ? ((d[key] as number) ?? 0) : 0);
const maxVal = Math.max(...data.map((d) => Math.max(valOf(d, yKey), valOf(d, yKey2))), 1);
const series = yKey2 ? 2 : 1;
const barWidth = Math.max(20, Math.min(40, (300 / data.length) / series));
const width = Math.max(data.length * (barWidth * series + 4) + 40, 200);
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Bar chart">
{data.map((d, i) => {
const barH = (valOf(d, yKey) / maxVal) * (height - 30);
const x = i * (barWidth * series + 4) + 20;
const y = height - 20 - barH;
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color} rx="2" />;
})}
{yKey2 && data.map((d, i) => {
const barH = (valOf(d, yKey2) / maxVal) * (height - 30);
const x = i * (barWidth * series + 4) + 20 + barWidth;
const y = height - 20 - barH;
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color2} rx="2" />;
})}
</svg>
);
}
@@ -0,0 +1,23 @@
import { cn } from "@/lib/utils";
import { format, subDays } from "date-fns";
/**
* Lightweight calendar heatmap part of the shared chart set intentionally
* implemented as plain divs instead of pulling in the recharts dependency.
*/
export function CalendarHeatmap({ data, days = 30 }: { data: any[]; days?: number }) {
const today = new Date();
const dateMap = new Map(data.map((d) => [d.date?.slice(0, 10), d.count || 0]));
const cells = [];
for (let i = days - 1; i >= 0; i--) {
const d = subDays(today, i);
const key = format(d, "yyyy-MM-dd");
const count = dateMap.get(key) || 0;
const intensity = count > 0 ? Math.min(count / 5, 1) : 0;
const color = intensity > 0.75 ? "bg-green-600" : intensity > 0.5 ? "bg-green-500" : intensity > 0.25 ? "bg-green-400" : intensity > 0 ? "bg-green-200" : "bg-muted";
cells.push(
<div key={key} className={cn("w-3 h-3 rounded-sm", color)} title={key + ": " + count + " completions"} />
);
}
return <div className="flex flex-wrap gap-0.5">{cells}</div>;
}
@@ -0,0 +1,21 @@
/**
* Lightweight horizontal bar chart part of the shared chart set intentionally
* implemented as plain SVG/divs instead of pulling in the recharts dependency.
*/
export function HorizontalBar({ data, xKey, yKey, height = 100 }: { data: any[]; xKey: string; yKey: string; height?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
return (
<div className="space-y-2" style={{ height }}>
{data.map((d, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs w-20 truncate text-right">{d[xKey]}</span>
<div className="flex-1 bg-muted rounded-full h-4 overflow-hidden">
<div className="h-full bg-primary rounded-full transition-all" style={{ width: (d[yKey] / maxVal) * 100 + "%" }} />
</div>
<span className="text-xs w-8 text-right">{d[yKey]}</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,24 @@
/**
* Lightweight SVG line chart part of the shared chart set intentionally
* implemented as plain SVG instead of pulling in the recharts dependency.
*/
export function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
const width = Math.max(data.length * 30, 200);
const points = data.map((d, i) => {
const x = (i / (data.length - 1 || 1)) * (width - 40) + 20;
const y = height - 20 - ((d[yKey] / maxVal) * (height - 40));
return `${x},${y}`;
}).join(" ");
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Line chart">
<polyline fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" points={points} />
{data.map((d, i) => {
const x = (i / (data.length - 1 || 1)) * (width - 40) + 20;
const y = height - 20 - ((d[yKey] / maxVal) * (height - 40));
return <circle key={i} cx={x} cy={y} r="3" fill={color} />;
})}
</svg>
);
}
@@ -0,0 +1,42 @@
/**
* Lightweight SVG pie/donut chart part of the shared chart set intentionally
* implemented as plain SVG instead of pulling in the recharts dependency.
*/
export function PieChartSimple({ data, labelKey, valueKey, size = 120 }: { data: any[]; labelKey: string; valueKey: string; size?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const total = data.reduce((s, d) => s + d[valueKey], 0) || 1;
const colors = ["#3b82f6", "#22c55e", "#f97316", "#a855f7", "#e11d48", "#14b8a6"];
let cumulative = 0;
const slices = data.map((d, i) => {
const pct = d[valueKey] / total;
const startAngle = cumulative * 360;
cumulative += pct;
const endAngle = cumulative * 360;
const startRad = (startAngle - 90) * Math.PI / 180;
const endRad = (endAngle - 90) * Math.PI / 180;
const r = size / 2 - 4;
const cx = size / 2;
const cy = size / 2;
const x1 = cx + r * Math.cos(startRad);
const y1 = cy + r * Math.sin(startRad);
const x2 = cx + r * Math.cos(endRad);
const y2 = cy + r * Math.sin(endRad);
const largeArc = pct > 0.5 ? 1 : 0;
return { path: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`, color: colors[i % colors.length], label: d[labelKey], pct: Math.round(pct * 100) };
});
return (
<div className="flex items-center gap-4">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{slices.map((s, i) => <path key={i} d={s.path} fill={s.color} />)}
</svg>
<div className="space-y-1">
{slices.map((s, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: s.color }} />
<span>{s.label} ({s.pct}%)</span>
</div>
))}
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Shared lightweight SVG chart components.
*
* These are intentionally implemented as plain SVG/divs rather than pulling in
* the recharts dependency the visualizations needed here are simple enough
* that a full charting library is overkill.
*/
export { LineChart } from "./LineChart";
export { BarChart } from "./BarChart";
export { HorizontalBar } from "./HorizontalBar";
export { PieChartSimple } from "./PieChartSimple";
export { CalendarHeatmap } from "./CalendarHeatmap";
@@ -0,0 +1,87 @@
import type { ReactNode } from "react";
import { useNavigate } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
export interface DetailTab {
value: string;
label: string;
content: ReactNode;
}
export interface EntityDetailPageProps {
/** Back navigation. `to` is a TanStack Router route path (e.g. "/tasks"), label the button text. */
backTo: { to: string; label: string };
/** Title node — pages usually pass an InlineText-wrapped title or plain heading. */
title: ReactNode;
/** Optional leading icon next to the title (lucide icon component). */
icon?: ReactNode;
/** Badges row (status/priority/etc) under or beside the title. */
badges?: ReactNode;
/** Right-aligned action buttons (quick actions: complete, log, pin, delete...). */
actions?: ReactNode;
/** Tabs. Each renders its content in a TabsContent. */
tabs: DetailTab[];
/** Initial active tab. Defaults to the first tab. */
defaultTab?: string;
/** Sticky-header container class override (default "max-w-4xl"). */
containerClassName?: string;
}
export function EntityDetailPage({
backTo,
title,
icon,
badges,
actions,
tabs,
defaultTab,
containerClassName,
}: EntityDetailPageProps) {
const navigate = useNavigate();
return (
<div className={cn("mx-auto space-y-6 p-0", containerClassName || "max-w-4xl")}>
<div className="sticky top-0 z-10 -mx-1 mb-4 border-b bg-background/95 py-3 backdrop-blur">
<Button
variant="ghost"
size="sm"
onClick={() => navigate({ to: backTo.to })}
className="mb-3"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{backTo.label}
</Button>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
{icon && <div className="mt-1 text-muted-foreground">{icon}</div>}
<div className="space-y-1.5">
<h1 className="text-2xl font-bold leading-tight">{title}</h1>
{badges && (
<div className="flex flex-wrap items-center gap-2">{badges}</div>
)}
</div>
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
</div>
<Tabs defaultValue={defaultTab || tabs[0]?.value}>
<TabsList className="flex-wrap gap-1">
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
<div className="pt-2">{tab.content}</div>
</TabsContent>
))}
</Tabs>
</div>
);
}
@@ -0,0 +1,130 @@
import {
CheckCircle2,
CirclePlus,
Dot,
Link2,
ListOrdered,
MessageSquare,
Pencil,
Tag,
Trash2,
Unlink,
type LucideIcon,
} from "lucide-react";
import { formatDistanceToNow, parseISO } from "date-fns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ErrorState, LoadingState } from "@/components/state";
import { useApiQuery } from "@/lib/api";
import type { ActivityItem } from "@/lib/types";
interface EntityActivityProps {
entityType: string;
entityId: string;
limit?: number;
}
interface ActivityResponse {
items: ActivityItem[];
totalItems: number;
}
interface ActionMeta {
icon: LucideIcon;
label: string;
}
const ACTION_META: Record<string, ActionMeta> = {
created: { icon: CirclePlus, label: "created" },
completed: { icon: CheckCircle2, label: "completed" },
updated: { icon: Pencil, label: "updated" },
commented: { icon: MessageSquare, label: "commented" },
tagged: { icon: Tag, label: "tagged" },
untagged: { icon: Tag, label: "untagged" },
reordered: { icon: ListOrdered, label: "reordered" },
dependency_added: { icon: Link2, label: "added a dependency" },
dependency_removed: { icon: Unlink, label: "removed a dependency" },
deleted: { icon: Trash2, label: "deleted" },
deleted_comment: { icon: Trash2, label: "deleted a comment" },
};
// Keys recorded alongside "updated" that aren't user-facing field changes.
const UPDATED_META_KEYS = new Set(["updatedAt", "createdAt", "previousStatus"]);
/** Compact, plain-text summary of what changed. Only meaningful for updates. */
function changesSummary(
action: string,
changes: Record<string, unknown> | null
): string | null {
if (action !== "updated" || !changes) return null;
const fields = Object.keys(changes).filter((key) => !UPDATED_META_KEYS.has(key));
return fields.length > 0 ? fields.join(", ") : null;
}
function ActivityRow({ item }: { item: ActivityItem }) {
const meta: ActionMeta = ACTION_META[item.action] ?? { icon: Dot, label: item.action };
const Icon = meta.icon;
const summary = changesSummary(item.action, item.changes);
return (
<div className="flex items-start gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
<span className="text-sm font-medium text-foreground">{item.actor}</span>
<span className="text-sm text-muted-foreground">{meta.label}</span>
<span className="text-xs text-muted-foreground/70">
{formatDistanceToNow(parseISO(item.createdAt), { addSuffix: true })}
</span>
</p>
{summary ? <p className="mt-0.5 truncate text-xs text-muted-foreground">{summary}</p> : null}
</div>
</div>
);
}
/**
* Activity feed for a single entity. The API returns newest-first, so rows are
* rendered in the order received. `limit` caps how many entries are fetched
* (the API clamps it to 1200).
*/
export function EntityActivity({ entityType, entityId, limit }: EntityActivityProps) {
const path =
`/activity?entityType=${entityType}&entityId=${entityId}` +
(limit ? `&limit=${limit}` : "");
const { data, isLoading, isError, error, refetch } = useApiQuery<ActivityResponse>(
["activity", entityType, entityId],
path
);
const items = data?.items ?? [];
return (
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? <LoadingState label="Loading activity..." /> : null}
{isError ? (
<ErrorState
message={error instanceof Error ? error.message : "Failed to load activity"}
onRetry={() => refetch()}
/>
) : null}
{!isLoading && !isError && items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">No activity yet</p>
) : null}
{!isLoading && !isError && items.length > 0 ? (
<div className="space-y-3">
{items.map((item) => (
<ActivityRow key={item.id} item={item} />
))}
</div>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,277 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow, parseISO } from "date-fns";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { ErrorState, LoadingState } from "@/components/state";
import { api, useApiMutation, useApiQuery } from "@/lib/api";
import type { Comment } from "@/lib/types";
interface EntityCommentsProps {
entityType: string;
entityId: string;
}
interface CommentsResponse {
items: Comment[];
}
interface CreateCommentVariables {
entityType: string;
entityId: string;
content: string;
parentId?: string | null;
}
interface CommentNode {
comment: Comment;
children: CommentNode[];
}
/** Assemble the flat, oldest-first API list into a reply tree via parentId. */
function buildCommentTree(items: Comment[]): CommentNode[] {
const childrenByParent = new Map<string, Comment[]>();
for (const item of items) {
const key = item.parentId ?? "__root__";
const siblings = childrenByParent.get(key);
if (siblings) siblings.push(item);
else childrenByParent.set(key, [item]);
}
const build = (parentId: string): CommentNode[] =>
(childrenByParent.get(parentId) ?? []).map((comment) => ({
comment,
children: build(comment.id),
}));
return build("__root__");
}
function initials(name: string): string {
return (
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join("") || "?"
);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Something went wrong";
}
interface CommentComposerProps {
entityType: string;
entityId: string;
parentId?: string | null;
submitLabel?: string;
placeholder?: string;
autoFocus?: boolean;
onSubmitted?: () => void;
}
/**
* Inline comment/reply composer. Each instance owns its text + mutation state,
* so the top composer and any open reply composer are independent and neither
* can double-submit.
*/
function CommentComposer({
entityType,
entityId,
parentId = null,
submitLabel = "Comment",
placeholder = "Write a comment...",
autoFocus = false,
onSubmitted,
}: CommentComposerProps) {
const queryClient = useQueryClient();
const [content, setContent] = useState("");
const createMutation = useApiMutation<Comment, CreateCommentVariables>(
"post",
"/comments",
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["comments", entityType, entityId] });
toast.success("Comment added");
setContent("");
onSubmitted?.();
},
onError: (err) => toast.error(errorMessage(err)),
}
);
const canSubmit = content.trim().length > 0 && !createMutation.isPending;
const submit = () => {
const trimmed = content.trim();
if (!trimmed || createMutation.isPending) return;
createMutation.mutate({
entityType,
entityId,
content: trimmed,
parentId,
});
};
return (
<div className="space-y-2">
<Textarea
rows={2}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
/>
<div className="flex justify-end">
<Button size="sm" onClick={submit} disabled={!canSubmit}>
{createMutation.isPending ? "Posting..." : submitLabel}
</Button>
</div>
</div>
);
}
interface CommentThreadProps {
node: CommentNode;
onDelete: (id: string) => void;
deletePending: boolean;
}
/** A comment plus its nested replies, indented one level per depth. */
function CommentThread({ node, onDelete, deletePending }: CommentThreadProps) {
const { comment, children } = node;
const [replying, setReplying] = useState(false);
return (
<div>
<div className="group">
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">{initials(comment.author)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-sm font-medium">{comment.author}</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(parseISO(comment.createdAt), { addSuffix: true })}
</span>
</p>
<p className="mt-0.5 text-sm whitespace-pre-wrap text-foreground/90">
{comment.content}
</p>
<div className="mt-1 flex items-center gap-3">
<button
type="button"
onClick={() => setReplying((value) => !value)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Reply
</button>
<button
type="button"
onClick={() => onDelete(comment.id)}
disabled={deletePending}
aria-label="Delete comment"
title="Delete comment"
className="rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{replying ? (
<div className="mt-2">
<CommentComposer
entityType={comment.entityType}
entityId={comment.entityId}
parentId={comment.id}
submitLabel="Reply"
placeholder="Write a reply..."
autoFocus
onSubmitted={() => setReplying(false)}
/>
</div>
) : null}
</div>
</div>
</div>
{children.length > 0 ? (
<div className="ml-6 mt-4 space-y-4 border-l pl-4">
{children.map((child) => (
<CommentThread
key={child.comment.id}
node={child}
onDelete={onDelete}
deletePending={deletePending}
/>
))}
</div>
) : null}
</div>
);
}
/**
* Threaded comments for a single entity. The API returns a flat list
* (oldest-first); the reply tree is assembled from `parentId` here. Deleting a
* comment soft-deletes its descendants server-side, so invalidating the query
* refreshes the whole subtree.
*/
export function EntityComments({ entityType, entityId }: EntityCommentsProps) {
const queryClient = useQueryClient();
const { data, isLoading, isError, error, refetch } = useApiQuery<CommentsResponse>(
["comments", entityType, entityId],
`/comments?entityType=${entityType}&entityId=${entityId}`
);
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/comments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["comments", entityType, entityId] });
toast.success("Comment deleted");
},
onError: (err) => toast.error(errorMessage(err)),
});
const tree = useMemo(() => buildCommentTree(data?.items ?? []), [data?.items]);
return (
<Card>
<CardHeader>
<CardTitle>Comments</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CommentComposer entityType={entityType} entityId={entityId} />
{isLoading ? <LoadingState label="Loading comments..." /> : null}
{isError ? (
<ErrorState
message={error instanceof Error ? error.message : "Failed to load comments"}
onRetry={() => refetch()}
/>
) : null}
{!isLoading && !isError && tree.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No comments yet start the conversation.
</p>
) : null}
{!isLoading && !isError && tree.length > 0 ? (
<div className="space-y-4">
{tree.map((node) => (
<CommentThread
key={node.comment.id}
node={node}
onDelete={(id) => deleteMutation.mutate(id)}
deletePending={deleteMutation.isPending}
/>
))}
</div>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,403 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ChevronsUpDown, Pencil } from "lucide-react";
import { toast } from "sonner";
import { format as formatDate, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Failed to save";
}
interface InlineEditProps<T> {
value: T;
onSave: (value: T) => void | Promise<void>;
display: (value: T) => ReactNode;
renderEdit: (
value: T,
onChange: (v: T) => void,
commit: (value?: T) => void,
cancel: () => void
) => ReactNode;
className?: string;
title?: string;
/** Show the subtle pencil affordance on hover. Disable when a custom icon is used. */
showEditIcon?: boolean;
}
/**
* Click-to-edit primitive. Renders `display(value)` normally; clicking swaps to
* `renderEdit(...)` with autofocus. `commit()` calls `onSave` and exits edit
* mode; on failure it toasts the error and reverts local state to the original
* value. Esc cancels, blur (when not triggered by Enter) commits. While not
* editing, external changes to `value` (realtime events, cache rollbacks from
* failed mutations) are mirrored into the local draft immediately.
*/
export function InlineEdit<T>({
value,
onSave,
display,
renderEdit,
className,
title,
showEditIcon = true,
}: InlineEditProps<T>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<T>(value);
const originalRef = useRef(value);
const committingRef = useRef(false);
// Mirror external updates (realtime, cache rollbacks) into the draft while
// not editing so the next edit session starts from the freshest value.
useEffect(() => {
if (!editing) setDraft(value);
}, [value, editing]);
const startEdit = () => {
originalRef.current = value;
setDraft(value);
setEditing(true);
};
const cancel = useCallback(() => {
setDraft(originalRef.current);
setEditing(false);
}, []);
const commit = useCallback(
async (nextValue?: T) => {
if (committingRef.current) return;
committingRef.current = true;
setEditing(false);
try {
await onSave(nextValue !== undefined ? nextValue : draft);
} catch (err) {
toast.error(errorMessage(err) || "Failed to save");
setDraft(originalRef.current);
} finally {
committingRef.current = false;
}
},
[draft, onSave]
);
const onChange = useCallback((v: T) => setDraft(v), []);
if (editing) {
return (
<div className={cn("inline-flex max-w-full", className)}>
{renderEdit(draft, onChange, commit, cancel)}
</div>
);
}
return (
<button
type="button"
onClick={startEdit}
title={title}
className={cn(
"group inline-flex max-w-full cursor-pointer items-center gap-1 text-left",
"rounded px-1 -mx-1 transition-colors hover:bg-muted/60",
className
)}
>
{display(value)}
{showEditIcon && (
<Pencil className="h-3 w-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</button>
);
}
interface InlineTextProps {
value: string;
onSave: (value: string) => void | Promise<void>;
/** When false (default), an empty commit is treated as a cancel. */
allowEmpty?: boolean;
placeholder?: string;
className?: string;
title?: string;
}
/** Single-line text field. Enter commits, Esc cancels, blur commits. */
export function InlineText({
value,
onSave,
allowEmpty = false,
placeholder,
className,
title,
}: InlineTextProps) {
return (
<InlineEdit
value={value}
onSave={(next: string) => {
const trimmed = next.trim();
if (!trimmed && !allowEmpty) return;
return onSave(trimmed);
}}
className={className}
title={title}
display={(v) =>
v ? (
<span>{v}</span>
) : (
<span className="text-muted-foreground/70">{placeholder ?? "Click to edit"}</span>
)
}
renderEdit={(v, onChange, commit, cancel) => (
<Input
value={v}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
else if (e.key === "Escape") cancel();
}}
onBlur={() => commit()}
placeholder={placeholder}
autoFocus
className="h-7 w-full min-w-[8rem] px-2 text-sm"
/>
)}
/>
);
}
interface InlineTextareaProps {
value: string;
onSave: (value: string) => void | Promise<void>;
placeholder?: string;
className?: string;
title?: string;
}
/** Multi-line field. Blur commits, Esc cancels. Rows auto-resize with content (min 3). */
export function InlineTextarea({
value,
onSave,
placeholder,
className,
title,
}: InlineTextareaProps) {
const rowsFor = (text: string) => Math.max(3, Math.ceil(text.length / 40));
return (
<InlineEdit
value={value}
onSave={onSave}
className={className}
title={title}
display={(v) =>
v ? (
<span className="whitespace-pre-wrap">{v}</span>
) : (
<span className="text-muted-foreground/70">{placeholder ?? "Click to edit"}</span>
)
}
renderEdit={(v, onChange, commit, cancel) => (
<Textarea
value={v}
onChange={(e) => onChange(e.target.value)}
onBlur={() => commit()}
onKeyDown={(e) => {
if (e.key === "Escape") cancel();
}}
placeholder={placeholder}
autoFocus
rows={rowsFor(v)}
className="w-full text-sm"
/>
)}
/>
);
}
export interface InlineSelectOption {
value: string;
label: string;
}
interface InlineSelectProps {
value: string;
options: InlineSelectOption[];
onSave: (value: string) => void | Promise<void>;
/** Custom display for the selected value; when provided a ChevronsUpDown icon is shown. */
displayValue?: (value: string) => ReactNode;
className?: string;
title?: string;
}
/** Inline select. Selecting an option commits immediately. */
export function InlineSelect({
value,
options,
onSave,
displayValue,
className,
title,
}: InlineSelectProps) {
return (
<InlineEdit
value={value}
onSave={onSave}
className={className}
title={title}
showEditIcon={false}
display={(v) => (
<span className="inline-flex items-center gap-1">
<span>
{displayValue
? displayValue(v)
: options.find((o) => o.value === v)?.label ?? v}
</span>
{displayValue && (
<ChevronsUpDown className="h-3 w-3 shrink-0 text-muted-foreground/60" />
)}
</span>
)}
renderEdit={(v, onChange, commit, cancel) => (
<InlineSelectControl
value={v}
options={options}
onChange={onChange}
commit={commit}
cancel={cancel}
/>
)}
/>
);
}
function InlineSelectControl({
value,
options,
onChange,
commit,
cancel,
}: {
value: string;
options: InlineSelectOption[];
onChange: (v: string) => void;
commit: (value?: string) => void;
cancel: () => void;
}) {
const [open, setOpen] = useState(true);
return (
<Select
value={value}
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) cancel();
}}
onValueChange={(next) => {
onChange(next);
commit(next);
}}
>
<SelectTrigger autoFocus className="h-7 w-full min-w-[8rem] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
interface InlineDateProps {
value: string | null;
onSave: (value: string | null) => void | Promise<void>;
/** date-fns format string used for display. Defaults to "MMM d, yyyy". */
format?: string;
placeholder?: string;
className?: string;
title?: string;
}
/** Date field. Changing the value commits immediately; clearing saves null. */
export function InlineDate({
value,
onSave,
format: formatStr = "MMM d, yyyy",
placeholder = "No date",
className,
title,
}: InlineDateProps) {
return (
<InlineEdit
value={value}
onSave={onSave}
className={className}
title={title}
display={(v) =>
v ? (
<span>{formatDate(parseISO(v), formatStr)}</span>
) : (
<span className="text-muted-foreground/70">{placeholder}</span>
)
}
renderEdit={(v, onChange, commit, cancel) => (
<Input
type="date"
value={v ?? ""}
onChange={(e) => {
const next = e.target.value ? e.target.value : null;
onChange(next);
commit(next);
}}
onKeyDown={(e) => {
if (e.key === "Escape") cancel();
}}
autoFocus
className="h-7 w-fit text-sm"
/>
)}
/>
);
}
interface InlineToggleProps {
checked: boolean;
onSave: (checked: boolean) => void | Promise<void>;
label?: string;
className?: string;
}
/** Toggle switch. Commits immediately on change. */
export function InlineToggle({ checked, onSave, label, className }: InlineToggleProps) {
const [pending, setPending] = useState(false);
const handleChange = async (next: boolean) => {
if (pending) return;
setPending(true);
try {
await onSave(next);
} catch (err) {
toast.error(errorMessage(err) || "Failed to save");
} finally {
setPending(false);
}
};
return (
<div className={cn("flex items-center gap-2", className)}>
<Switch checked={checked} onCheckedChange={handleChange} disabled={pending} />
{label && <span className="text-sm text-muted-foreground">{label}</span>}
</div>
);
}
@@ -0,0 +1,98 @@
import { memo, useEffect, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
const AUTOSAVE_DEBOUNCE_MS = 800;
// TipTap-based note editor. Autosaves with a debounce (plus a save-on-blur and a
// flush-on-unmount safety net) and deliberately does NOT stop propagation of
// key/mouse events, so global shortcuts (command palette, etc.) keep working.
export const NoteEditor = memo(function NoteEditor({
initialContent,
onSave,
placeholder = "Start writing...",
}: {
initialContent: string;
onSave: (html: string) => void;
placeholder?: string;
}) {
const latestHtmlRef = useRef(initialContent || "");
const dirtyRef = useRef(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editor = useEditor(
{
extensions: [
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder }),
],
content: initialContent || "",
editorProps: {
attributes: {
class: "focus:outline-none min-h-[300px] p-3",
},
},
},
[placeholder, initialContent]
);
useEffect(() => {
if (!editor) return;
const flushSave = () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
if (!dirtyRef.current) return;
dirtyRef.current = false;
onSave(latestHtmlRef.current);
};
const handleUpdate = () => {
latestHtmlRef.current = editor.getHTML();
dirtyRef.current = true;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(flushSave, AUTOSAVE_DEBOUNCE_MS);
};
editor.on("update", handleUpdate);
editor.on("blur", flushSave);
return () => {
editor.off("update", handleUpdate);
editor.off("blur", flushSave);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
// Flush any unsaved edits on unmount so switching notes doesn't drop typing.
if (dirtyRef.current) {
dirtyRef.current = false;
onSave(latestHtmlRef.current);
}
};
}, [editor, onSave]);
if (!editor) return null;
return (
<div className="note-editor relative min-h-[300px]">
{/* Placeholder needs its ::before styling; the @tailwindcss/typography plugin
is not installed, so this is scoped CSS for the empty-editor state. */}
<style>{`
.note-editor p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
float: left;
height: 0;
pointer-events: none;
}
`}</style>
<EditorContent editor={editor} />
</div>
);
});
@@ -0,0 +1,65 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/lib/api";
interface PatchVariables {
id: string;
data: Record<string, unknown>;
}
interface UseOptimisticPatchOptions<T extends { id: string }> {
/** Detail query key, e.g. ["task", id]. */
entityKey: string[];
/** List query keys to prefix-invalidate after settle, e.g. [["tasks"]]. */
listKeys?: string[][];
/** Builds the PATCH url from the entity id, e.g. (id) => `/tasks/${id}`. */
patchUrl: (id: string) => string;
/** Merges the partial update into the cached entity for the optimistic UI. */
applyPatch: (current: T, data: Record<string, unknown>) => T;
}
/**
* Optimistic PATCH with rollback, shared by all entity detail pages.
*
* Returns a `patch({ id, data })` callable: the mutation updates the detail
* query cache immediately via `applyPatch`, restores the previous snapshot on
* error (with an error toast), and prefix-invalidates the entity key plus all
* list keys once the request settles.
*/
export function useOptimisticPatch<T extends { id: string }>(
opts: UseOptimisticPatchOptions<T>
) {
const queryClient = useQueryClient();
const { entityKey, listKeys = [], patchUrl, applyPatch } = opts;
const mutation = useMutation({
mutationFn: ({ id, data }: PatchVariables) =>
api.patch<unknown>(patchUrl(id), data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: entityKey });
const previous = queryClient.getQueryData<T>(entityKey);
queryClient.setQueryData<T>(entityKey, (old) =>
old ? applyPatch(old, data) : old
);
return { previous };
},
onError: (err, _vars, context) => {
if (context?.previous !== undefined) {
queryClient.setQueryData<T>(entityKey, context.previous);
}
const message = err instanceof Error ? err.message : "Failed to save";
toast.error(message || "Failed to save");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: entityKey });
for (const key of listKeys) {
queryClient.invalidateQueries({ queryKey: key });
}
},
});
return {
...mutation,
patch: mutation.mutate,
};
}
+5 -4
View File
@@ -28,16 +28,17 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
switch (entityType) {
case "task":
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"], ["analytics-daily"], ["analytics-projects"]);
// Singular keys (e.g. ["task", id]) refresh open detail pages live.
queryKeys.push(["tasks"], ["task"], ["tasks-due"], ["stats"], ["productivity-chart"], ["analytics-daily"], ["analytics-projects"]);
break;
case "habit":
queryKeys.push(["habits"], ["habits-today"], ["streaks"], ["analytics-habits"]);
queryKeys.push(["habits"], ["habit"], ["habits-today"], ["streaks"], ["analytics-habits"]);
break;
case "project":
queryKeys.push(["projects"], ["active-projects"], ["analytics-projects"]);
queryKeys.push(["projects"], ["project"], ["active-projects"], ["analytics-projects"]);
break;
case "note":
queryKeys.push(["notes"], ["recent-notes"]);
queryKeys.push(["notes"], ["note"], ["recent-notes"]);
break;
case "calendar_event":
queryKeys.push(["calendar-events"], ["upcoming-events"]);
+25
View File
@@ -12,6 +12,7 @@ export interface Task {
parentId: string | null;
dueDate: string | null;
estimatedMinutes: number | null;
recurrenceRule: string | null;
order: number;
completedAt: string | null;
createdAt: string;
@@ -386,6 +387,30 @@ export interface ProjectProgress {
progress: number;
}
// Comments & activity (detail pages)
export interface Comment {
id: string;
entityType: string;
entityId: string;
workspaceId: string;
parentId: string | null;
author: string;
content: string;
createdAt: string;
deletedAt: string | null;
}
export interface ActivityItem {
id: string;
actor: string;
action: string;
entityType: string;
entityId: string;
changes: Record<string, unknown> | null;
workspaceId: string;
createdAt: string;
}
export interface ProjectAnalytics {
projects: ProjectProgress[];
totalProjects: number;
+1 -123
View File
@@ -9,130 +9,8 @@ import { Button } from "@/components/ui/button";
import { LoadingState, ErrorState } from "@/components/state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { DailyAnalytics, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
import { format, subDays } from "date-fns";
// Simple SVG-based charts (no recharts dependency needed for basic charts)
function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
const width = Math.max(data.length * 30, 200);
const points = data.map((d, i) => {
const x = (i / (data.length - 1 || 1)) * (width - 40) + 20;
const y = height - 20 - ((d[yKey] / maxVal) * (height - 40));
return `${x},${y}`;
}).join(" ");
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Line chart">
<polyline fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" points={points} />
{data.map((d, i) => {
const x = (i / (data.length - 1 || 1)) * (width - 40) + 20;
const y = height - 20 - ((d[yKey] / maxVal) * (height - 40));
return <circle key={i} cx={x} cy={y} r="3" fill={color} />;
})}
</svg>
);
}
function BarChart({ data, xKey, yKey, yKey2, color = "#3b82f6", color2 = "#f97316", height = 120 }: { data: any[]; xKey: string; yKey: string; yKey2?: string; color?: string; color2?: string; height?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const valOf = (d: any, key?: string) => (key ? ((d[key] as number) ?? 0) : 0);
const maxVal = Math.max(...data.map((d) => Math.max(valOf(d, yKey), valOf(d, yKey2))), 1);
const series = yKey2 ? 2 : 1;
const barWidth = Math.max(20, Math.min(40, (300 / data.length) / series));
const width = Math.max(data.length * (barWidth * series + 4) + 40, 200);
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Bar chart">
{data.map((d, i) => {
const barH = (valOf(d, yKey) / maxVal) * (height - 30);
const x = i * (barWidth * series + 4) + 20;
const y = height - 20 - barH;
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color} rx="2" />;
})}
{yKey2 && data.map((d, i) => {
const barH = (valOf(d, yKey2) / maxVal) * (height - 30);
const x = i * (barWidth * series + 4) + 20 + barWidth;
const y = height - 20 - barH;
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color2} rx="2" />;
})}
</svg>
);
}
function HorizontalBar({ data, xKey, yKey, height = 100 }: { data: any[]; xKey: string; yKey: string; height?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
return (
<div className="space-y-2" style={{ height }}>
{data.map((d, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs w-20 truncate text-right">{d[xKey]}</span>
<div className="flex-1 bg-muted rounded-full h-4 overflow-hidden">
<div className="h-full bg-primary rounded-full transition-all" style={{ width: (d[yKey] / maxVal) * 100 + "%" }} />
</div>
<span className="text-xs w-8 text-right">{d[yKey]}</span>
</div>
))}
</div>
);
}
function PieChartSimple({ data, labelKey, valueKey, size = 120 }: { data: any[]; labelKey: string; valueKey: string; size?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const total = data.reduce((s, d) => s + d[valueKey], 0) || 1;
const colors = ["#3b82f6", "#22c55e", "#f97316", "#a855f7", "#e11d48", "#14b8a6"];
let cumulative = 0;
const slices = data.map((d, i) => {
const pct = d[valueKey] / total;
const startAngle = cumulative * 360;
cumulative += pct;
const endAngle = cumulative * 360;
const startRad = (startAngle - 90) * Math.PI / 180;
const endRad = (endAngle - 90) * Math.PI / 180;
const r = size / 2 - 4;
const cx = size / 2;
const cy = size / 2;
const x1 = cx + r * Math.cos(startRad);
const y1 = cy + r * Math.sin(startRad);
const x2 = cx + r * Math.cos(endRad);
const y2 = cy + r * Math.sin(endRad);
const largeArc = pct > 0.5 ? 1 : 0;
return { path: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`, color: colors[i % colors.length], label: d[labelKey], pct: Math.round(pct * 100) };
});
return (
<div className="flex items-center gap-4">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{slices.map((s, i) => <path key={i} d={s.path} fill={s.color} />)}
</svg>
<div className="space-y-1">
{slices.map((s, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: s.color }} />
<span>{s.label} ({s.pct}%)</span>
</div>
))}
</div>
</div>
);
}
function CalendarHeatmap({ data, days = 30 }: { data: any[]; days?: number }) {
const today = new Date();
const dateMap = new Map(data.map((d) => [d.date?.slice(0, 10), d.count || 0]));
const cells = [];
for (let i = days - 1; i >= 0; i--) {
const d = subDays(today, i);
const key = format(d, "yyyy-MM-dd");
const count = dateMap.get(key) || 0;
const intensity = count > 0 ? Math.min(count / 5, 1) : 0;
const color = intensity > 0.75 ? "bg-green-600" : intensity > 0.5 ? "bg-green-500" : intensity > 0.25 ? "bg-green-400" : intensity > 0 ? "bg-green-200" : "bg-muted";
cells.push(
<div key={key} className={cn("w-3 h-3 rounded-sm", color)} title={key + ": " + count + " completions"} />
);
}
return <div className="flex flex-wrap gap-0.5">{cells}</div>;
}
import { LineChart, BarChart, HorizontalBar, PieChartSimple, CalendarHeatmap } from "@/components/charts";
// ─── Analytics Page ──────────────────────────────────────────────────────
+164 -5
View File
@@ -1,18 +1,177 @@
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app";
import { useApiQuery } from "@/lib/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Calendar, Clock, FileText, LayoutDashboard, Trash2 } from "lucide-react";
import { format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
import { InlineTextarea } from "@/components/entities/inline-edit";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LoadingState, ErrorState } from "@/components/state";
import type { Canvas } from "@/lib/types";
import { CanvasEditor } from "../canvas";
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Something went wrong";
}
function formatCustomFieldValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function CanvasDetail() {
const { id } = useParams({ from: Route.id });
const navigate = useNavigate();
const { data: canvas, isLoading } = useApiQuery<Canvas>(["canvas", id], "/canvas/" + id);
const queryClient = useQueryClient();
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
if (!canvas) return <div className="p-8 text-center text-muted-foreground">Canvas not found</div>;
useRealtime({ enabled: true });
return <CanvasEditor key={canvas.id} canvas={canvas} onBack={() => navigate({ to: "/canvas" })} />;
const { data: canvas, isLoading, isError, error, refetch } = useApiQuery<Canvas>(
["canvas", id],
"/canvas/" + id
);
const { patch } = useOptimisticPatch<Canvas>({
entityKey: ["canvas", id],
listKeys: [["canvas"]],
patchUrl: (cid) => `/canvas/${cid}`,
applyPatch: (current, data) => ({ ...current, ...data }),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/canvas/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["canvas"] });
toast.success("Canvas deleted");
navigate({ to: "/canvas" });
},
onError: (err) => toast.error(errorMessage(err)),
});
if (isLoading) return <LoadingState label="Loading canvas..." />;
if (isError) {
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
}
if (!canvas) return <ErrorState message="Canvas not found" />;
const customFieldEntries = Object.entries(canvas.customFields ?? {});
return (
<div className="mx-auto max-w-5xl">
<div className="flex flex-col gap-6 lg:flex-row">
<div className="min-w-0 flex-1">
<CanvasEditor key={canvas.id} canvas={canvas} onBack={() => navigate({ to: "/canvas" })} />
</div>
<aside className="w-full shrink-0 space-y-6 lg:w-72">
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
<InlineTextarea
value={canvas.description ?? ""}
onSave={(description) =>
patch({ id, data: { description: description || null } })
}
placeholder="Add a description…"
/>
</div>
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Details</p>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<LayoutDashboard className="h-4 w-4 shrink-0 text-muted-foreground" />
<Badge variant="outline">{canvas.mode}</Badge>
</div>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{canvas.cards?.length ?? 0} blocks</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>Created {format(parseISO(canvas.createdAt), "MMM d, yyyy HH:mm")}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>Updated {format(parseISO(canvas.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
</div>
</div>
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Tags</p>
{canvas.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{canvas.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No tags</p>
)}
</div>
{customFieldEntries.length > 0 && (
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Custom fields</p>
<dl className="space-y-2">
{customFieldEntries.map(([key, value]) => (
<div key={key} className="flex items-baseline justify-between gap-2 text-sm">
<dt className="shrink-0 text-muted-foreground">{key}</dt>
<dd className="truncate text-right">{formatCustomFieldValue(value)}</dd>
</div>
))}
</dl>
</div>
)}
<div className="border-t pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
<Trash2 className="h-4 w-4" /> Delete Canvas
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Canvas</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{canvas.name}"? All blocks in it will be
removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground"
onClick={() => deleteMutation.mutate()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</aside>
</div>
</div>
);
}
export const Route = createRoute({
+469 -70
View File
@@ -1,86 +1,485 @@
import { useState } from "react";
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app";
import { useApiQuery } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { TagManager } from "@/components/entities/tag-manager";
import { ArrowLeft, Flame, Calendar, Clock } from "lucide-react";
import type { Habit, HabitCompletion } from "@/lib/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Bell,
Calendar,
CalendarOff,
CheckCircle2,
Clock,
Flame,
RepeatIcon,
Target,
Trash2,
} from "lucide-react";
import { format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
import { EntityDetailPage } from "@/components/entities/detail-page";
import {
InlineEdit,
InlineSelect,
InlineText,
InlineTextarea,
InlineToggle,
type InlineSelectOption,
} from "@/components/entities/inline-edit";
import { EntityActivity } from "@/components/entities/entity-activity";
import { EntityComments } from "@/components/entities/entity-comments";
import { TagManager } from "@/components/entities/tag-manager";
import { CalendarHeatmap } from "@/components/charts";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LoadingState, ErrorState } from "@/components/state";
import type { Habit } from "@/lib/types";
const FREQUENCY_OPTIONS: InlineSelectOption[] = [
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "custom", label: "Custom" },
];
const DIFFICULTY_OPTIONS: InlineSelectOption[] = [
{ value: "easy", label: "Easy" },
{ value: "medium", label: "Medium" },
{ value: "hard", label: "Hard" },
];
/** Difficulty badge text colors (no shared token exists for habit difficulty). */
const DIFFICULTY_COLOR: Record<string, string> = {
easy: "text-green-600",
medium: "text-amber-600",
hard: "text-red-600",
};
const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Query keys to keep fresh after any habit-affecting mutation. Mirrors the
// realtime hook's invalidation so list/analytics views never go stale.
const LIST_KEYS: string[][] = [
["habits"],
["habits-today"],
["streaks"],
["analytics-habits"],
];
type PatchFn = (vars: { id: string; data: Record<string, unknown> }) => void;
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Something went wrong";
}
function HabitDetail() {
const { id } = useParams({ from: Route.id });
const navigate = useNavigate();
const { data: habit, isLoading } = useApiQuery<Habit>(["habit", id], "/habits/" + id);
const queryClient = useQueryClient();
const [days, setDays] = useState(30);
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
if (!habit) return <div className="p-8 text-center text-muted-foreground">Habit not found</div>;
useRealtime({ enabled: true });
const completions = habit.recentCompletions || [];
const { data: habit, isLoading, isError, error, refetch } = useApiQuery<Habit>(
["habit", id, String(days)],
`/habits/${id}?days=${days}`
);
const { patch } = useOptimisticPatch<Habit>({
entityKey: ["habit", id, String(days)],
listKeys: LIST_KEYS,
patchUrl: (hid) => `/habits/${hid}`,
applyPatch: (current, data) => ({ ...current, ...data }),
});
const logToday = useMutation({
mutationFn: () => api.post(`/habits/${id}/complete`, {}),
onSuccess: () => {
toast.success("Logged for today");
queryClient.invalidateQueries({ queryKey: ["habit", id] });
for (const key of LIST_KEYS) queryClient.invalidateQueries({ queryKey: key });
},
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/habits/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["habits"] });
toast.success("Habit deleted");
navigate({ to: "/habits" });
},
onError: (err) => toast.error(errorMessage(err)),
});
if (isLoading) return <LoadingState label="Loading habit..." />;
if (isError) {
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
}
if (!habit) return <ErrorState message="Habit not found" />;
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<Button variant="ghost" onClick={() => navigate({ to: "/habits" })} className="w-fit">
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Habits
</Button>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Flame className="h-6 w-6 text-orange-500" />
<CardTitle className="text-2xl">{habit.name}</CardTitle>
<Badge variant={habit.active ? "default" : "secondary"}>{habit.active ? "Active" : "Inactive"}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
{habit.description && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
<p className="text-sm whitespace-pre-wrap">{habit.description}</p>
</div>
)}
<Separator />
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-muted/50 rounded-lg">
<p className="text-2xl font-bold">{habit.streakCount}</p>
<p className="text-xs text-muted-foreground">Current Streak</p>
</div>
<div className="p-3 bg-muted/50 rounded-lg">
<p className="text-2xl font-bold">{habit.bestStreak}</p>
<p className="text-xs text-muted-foreground">Best Streak</p>
</div>
<div className="p-3 bg-muted/50 rounded-lg">
<p className="text-2xl font-bold">{habit.frequency}</p>
<p className="text-xs text-muted-foreground">Frequency</p>
</div>
</div>
<Separator />
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
<Separator />
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Recent Completions</h3>
{completions.length === 0 ? (
<p className="text-sm text-muted-foreground">No completions yet</p>
) : (
<div className="space-y-1">
{completions.slice(0, 10).map((c: HabitCompletion) => (
<div key={c.id} className="flex items-center gap-2 text-sm py-1">
<Calendar className="h-3.5 w-3.5 text-muted-foreground" />
<span>{format(parseISO(c.date), "MMM d, yyyy")}</span>
{c.value > 1 && <Badge variant="secondary">{c.value}x</Badge>}
{c.mood && <span className="text-xs text-muted-foreground">Mood: {c.mood}/5</span>}
</div>
))}
</div>
<EntityDetailPage
backTo={{ to: "/habits", label: "Back to Habits" }}
title={
<InlineText
value={habit.name}
onSave={(name) => patch({ id, data: { name } })}
placeholder="Untitled habit"
/>
}
icon={<Flame className="h-6 w-6 text-orange-500" />}
badges={
<>
<InlineToggle
checked={habit.active}
onSave={(active) => patch({ id, data: { active } })}
label={habit.active ? "Active" : "Inactive"}
/>
<InlineSelect
value={habit.frequency}
options={FREQUENCY_OPTIONS}
onSave={(frequency) => patch({ id, data: { frequency } })}
/>
</>
}
actions={
<>
<Button onClick={() => logToday.mutate()} disabled={logToday.isPending}>
<CheckCircle2 className="h-4 w-4" /> Log today
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4" /> Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Habit</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{habit.name}"? This action cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground"
onClick={() => deleteMutation.mutate()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
}
tabs={[
{ value: "overview", label: "Overview", content: <Overview habit={habit} patch={patch} /> },
{
value: "stats",
label: "Stats",
content: <Stats habit={habit} days={days} setDays={setDays} />,
},
{ value: "log", label: "Log", content: <LogTab habit={habit} days={days} /> },
{
value: "activity",
label: "Activity",
content: <EntityActivity entityType="habit" entityId={id} />,
},
{
value: "comments",
label: "Comments",
content: <EntityComments entityType="habit" entityId={id} />,
},
]}
/>
);
}
function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
const completions = habit.recentCompletions || [];
const distinctCompletionDays = new Set(
completions.map((c) => c.date.slice(0, 10))
).size;
const skipDays = habit.skipDays || [];
return (
<div className="space-y-6">
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
<InlineTextarea
value={habit.description ?? ""}
onSave={(description) =>
patch({ id: habit.id, data: { description: description || null } })
}
placeholder="Add a description…"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex items-center gap-2">
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineSelect
value={habit.frequency}
options={FREQUENCY_OPTIONS}
onSave={(frequency) => patch({ id: habit.id, data: { frequency } })}
/>
</div>
<div className="flex items-center gap-2">
<Flame className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineSelect
value={habit.difficulty}
options={DIFFICULTY_OPTIONS}
displayValue={(v) => (
<Badge variant="outline" className={DIFFICULTY_COLOR[v] ?? ""}>
{DIFFICULTY_OPTIONS.find((o) => o.value === v)?.label ?? v}
</Badge>
)}
onSave={(difficulty) => patch({ id: habit.id, data: { difficulty } })}
/>
</div>
<div className="flex items-center gap-2">
<Target className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineEdit
value={habit.goalPerPeriod}
onSave={(goal) =>
patch({ id: habit.id, data: { goalPerPeriod: Number(goal) || 1 } })
}
display={(goal) => (
<span>
{habit.unit ? `${goal} ${habit.unit} per period` : `${goal} per period`}
</span>
)}
renderEdit={(v, onChange, commit, cancel) => (
<Input
type="number"
min={1}
value={v}
onChange={(e) => onChange(Number(e.target.value))}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
else if (e.key === "Escape") cancel();
}}
onBlur={() => commit()}
autoFocus
className="h-7 w-24 text-sm"
/>
)}
/>
</div>
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineEdit
value={habit.reminderTime ?? ""}
onSave={(time) =>
patch({ id: habit.id, data: { reminderTime: time || null } })
}
display={(time) =>
time ? (
<span>Reminder {time}</span>
) : (
<span className="text-muted-foreground/70">No reminder</span>
)
}
renderEdit={(v, onChange, commit, cancel) => (
<Input
type="time"
value={v}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
else if (e.key === "Escape") cancel();
}}
onBlur={() => commit()}
autoFocus
className="h-7 text-sm"
/>
)}
/>
</div>
{skipDays.length > 0 ? (
<div className="flex items-center gap-2">
<CalendarOff className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-wrap gap-1">
{skipDays.map((d) => (
<Badge key={d} variant="secondary" className="text-[10px]">
{DAY_SHORT[d] ?? d}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
) : null}
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineToggle
checked={habit.moodTracking}
onSave={(moodTracking) => patch({ id: habit.id, data: { moodTracking } })}
label="Mood tracking"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-2xl font-bold">{habit.streakCount}</p>
<p className="text-xs text-muted-foreground">Current Streak</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-2xl font-bold">{habit.bestStreak}</p>
<p className="text-xs text-muted-foreground">Best Streak</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-2xl font-bold">{distinctCompletionDays}</p>
<p className="text-xs text-muted-foreground">Completion This Period</p>
</div>
</div>
<div className="border-t pt-4">
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
</div>
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Created {format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Updated {format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}
</span>
</div>
</div>
);
}
function Stats({
habit,
days,
setDays,
}: {
habit: Habit;
days: number;
setDays: (d: number) => void;
}) {
const completions = habit.recentCompletions || [];
const distinctCompletionDays = new Set(
completions.map((c) => c.date.slice(0, 10))
).size;
const completionRate = Math.round((distinctCompletionDays / days) * 100);
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
<SelectTrigger className="h-8 w-40 text-sm" aria-label="Range">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
<SelectItem value="180">Last 180 days</SelectItem>
<SelectItem value="365">Last 365 days</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-2xl font-bold">{habit.streakCount}</p>
<p className="text-xs text-muted-foreground">Current Streak</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-2xl font-bold">{habit.bestStreak}</p>
<p className="text-xs text-muted-foreground">Best Streak</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-2xl font-bold">{completionRate}%</p>
<p className="text-xs text-muted-foreground">Completion Rate</p>
</div>
</div>
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-semibold">Completion Heatmap</p>
<span className="ml-auto text-xs text-muted-foreground">
Last {days} days
</span>
</div>
<div className="mt-3">
{completions.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No completions in this period.
</p>
) : (
<CalendarHeatmap
data={completions.map((c) => ({
date: c.date.slice(0, 10),
count: c.value,
}))}
days={days}
/>
)}
</div>
</div>
</div>
);
}
function LogTab({ habit, days }: { habit: Habit; days: number }) {
const completions = habit.recentCompletions || [];
if (completions.length === 0) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
No completions in the last {days} days.
</p>
);
}
const sorted = [...completions].sort((a, b) => b.date.localeCompare(a.date));
return (
<div className="space-y-0.5">
{sorted.map((c) => (
<div
key={c.id}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Calendar className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm">{format(parseISO(c.date), "EEE, MMM d, yyyy")}</span>
{c.value > 1 && <Badge variant="secondary">{c.value}x</Badge>}
{c.mood != null && (
<span className="text-xs text-muted-foreground">Mood {c.mood}/5</span>
)}
{c.notes ? (
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{c.notes}
</span>
) : null}
</div>
))}
</div>
);
}
+1 -89
View File
@@ -7,6 +7,7 @@ import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react";
import { NoteEditor } from "@/components/entities/note-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -15,95 +16,6 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { Note, PaginatedResponse } from "@/lib/types";
import { format, parseISO } from "date-fns";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
const AUTOSAVE_DEBOUNCE_MS = 800;
// TipTap-based note editor. Autosaves with a debounce (plus a save-on-blur and a
// flush-on-unmount safety net) and deliberately does NOT stop propagation of
// key/mouse events, so global shortcuts (command palette, etc.) keep working.
const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholder = "Start writing..." }: { initialContent: string; onSave: (html: string) => void; placeholder?: string }) {
const latestHtmlRef = useRef(initialContent || "");
const dirtyRef = useRef(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editor = useEditor(
{
extensions: [
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder }),
],
content: initialContent || "",
editorProps: {
attributes: {
class: "focus:outline-none min-h-[300px] p-3",
},
},
},
[placeholder, initialContent]
);
useEffect(() => {
if (!editor) return;
const flushSave = () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
if (!dirtyRef.current) return;
dirtyRef.current = false;
onSave(latestHtmlRef.current);
};
const handleUpdate = () => {
latestHtmlRef.current = editor.getHTML();
dirtyRef.current = true;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(flushSave, AUTOSAVE_DEBOUNCE_MS);
};
editor.on("update", handleUpdate);
editor.on("blur", flushSave);
return () => {
editor.off("update", handleUpdate);
editor.off("blur", flushSave);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
// Flush any unsaved edits on unmount so switching notes doesn't drop typing.
if (dirtyRef.current) {
dirtyRef.current = false;
onSave(latestHtmlRef.current);
}
};
}, [editor, onSave]);
if (!editor) return null;
return (
<div className="note-editor relative min-h-[300px]">
{/* Placeholder needs its ::before styling; the @tailwindcss/typography plugin
is not installed, so this is scoped CSS for the empty-editor state. */}
<style>{`
.note-editor p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
float: left;
height: 0;
pointer-events: none;
}
`}</style>
<EditorContent editor={editor} />
</div>
);
});
// Completely uncontrolled title input - uses ref to avoid any re-render
const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: { noteId: string; initialTitle: string }) {
+276 -69
View File
@@ -1,86 +1,293 @@
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app";
import { useApiQuery } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { TagManager } from "@/components/entities/tag-manager";
import { ArrowLeft, FileText, Clock } from "lucide-react";
import type { Note } from "@/lib/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Archive,
ArchiveRestore,
Clock,
FileText,
Link2,
Pin,
PinOff,
Trash2,
} from "lucide-react";
import { format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
import { EntityDetailPage } from "@/components/entities/detail-page";
import { InlineText } from "@/components/entities/inline-edit";
import { EntityActivity } from "@/components/entities/entity-activity";
import { EntityComments } from "@/components/entities/entity-comments";
import { TagManager } from "@/components/entities/tag-manager";
import { NoteEditor } from "@/components/entities/note-editor";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LoadingState, ErrorState } from "@/components/state";
import type { Note } from "@/lib/types";
// Note content is Tiptap-generated HTML stored by the API. Lightweight sanitizer
// applied before rendering via dangerouslySetInnerHTML: drops script/style
// blocks, inline event handlers, and javascript: URLs.
const sanitizeNoteHtml = (html: string): string =>
html
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "")
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, "")
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
.replace(/(\shref|\ssrc)\s*=\s*(?:"|')\s*javascript:[^"']*(?:"|')/gi, ' $1=""');
// Query keys to keep fresh after any note-affecting mutation. Mirrors the
// realtime hook's invalidation so the notes list and recent notes never go stale.
const LIST_KEYS: string[][] = [["notes"], ["recent-notes"]];
type PatchFn = (vars: { id: string; data: Record<string, unknown> }) => void;
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Something went wrong";
}
function NoteDetail() {
const { id } = useParams({ from: Route.id });
const navigate = useNavigate();
const { data: note, isLoading } = useApiQuery<Note>(["note", id], "/notes/" + id);
const queryClient = useQueryClient();
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
if (!note) return <div className="p-8 text-center text-muted-foreground">Note not found</div>;
useRealtime({ enabled: true });
const { data: note, isLoading, isError, error, refetch } = useApiQuery<Note>(
["note", id],
"/notes/" + id
);
const { patch } = useOptimisticPatch<Note>({
entityKey: ["note", id],
listKeys: LIST_KEYS,
patchUrl: (nid) => `/notes/${nid}`,
applyPatch: (current, data) => ({ ...current, ...data }),
});
const refreshLists = () => {
for (const key of LIST_KEYS) queryClient.invalidateQueries({ queryKey: key });
};
const togglePin = useMutation({
mutationFn: () =>
api.patch<Note>(`/notes/${id}`, { isPinned: !note?.isPinned }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["note", id] });
refreshLists();
toast.success(note?.isPinned ? "Note unpinned" : "Note pinned");
},
onError: (err) => toast.error(errorMessage(err)),
});
const toggleArchive = useMutation({
mutationFn: () =>
api.patch<Note>(`/notes/${id}`, { isArchived: !note?.isArchived }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["note", id] });
refreshLists();
toast.success(note?.isArchived ? "Note unarchived" : "Note archived");
},
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/notes/${id}`),
onSuccess: () => {
refreshLists();
toast.success("Note deleted");
navigate({ to: "/notes" });
},
onError: (err) => toast.error(errorMessage(err)),
});
if (isLoading) return <LoadingState label="Loading note..." />;
if (isError) {
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
}
if (!note) return <ErrorState message="Note not found" />;
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<Button variant="ghost" onClick={() => navigate({ to: "/notes" })} className="w-fit">
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Notes
</Button>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<FileText className="h-6 w-6 text-muted-foreground" />
<CardTitle className="text-2xl">{note.title}</CardTitle>
{note.isPinned && <Badge>Pinned</Badge>}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(note.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
<Separator />
{/* The @tailwindcss/typography plugin isn't installed, so style the
Tiptap output with scoped CSS instead of `prose` classes. */}
<div className="note-detail-content max-w-none">
<style>{`
.note-detail-content { line-height: 1.75; }
.note-detail-content h1 { font-size: 1.75rem; font-weight: 700; line-height: 1.25; margin: 1.5rem 0 0.75rem; }
.note-detail-content h2 { font-size: 1.5rem; font-weight: 700; line-height: 1.3; margin: 1.5rem 0 0.75rem; }
.note-detail-content h3 { font-size: 1.25rem; font-weight: 600; line-height: 1.4; margin: 1.25rem 0 0.5rem; }
.note-detail-content h4 { font-size: 1.125rem; font-weight: 600; line-height: 1.4; margin: 1rem 0 0.5rem; }
.note-detail-content h5, .note-detail-content h6 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.5rem; }
.note-detail-content p { margin: 0.75rem 0; }
.note-detail-content a { color: hsl(var(--primary)); text-decoration: underline; }
.note-detail-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
.note-detail-content ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
.note-detail-content li { margin: 0.25rem 0; }
.note-detail-content li p { margin: 0; }
.note-detail-content blockquote { border-left: 3px solid hsl(var(--border)); padding-left: 1rem; margin: 1rem 0; color: hsl(var(--muted-foreground)); }
.note-detail-content hr { border: 0; border-top: 1px solid hsl(var(--border)); margin: 1.5rem 0; }
.note-detail-content code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.875em; background: hsl(var(--muted)); padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
.note-detail-content pre { background: hsl(var(--muted)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
.note-detail-content pre code { background: transparent; padding: 0; font-size: 0.875rem; }
.note-detail-content ul[data-type="taskList"] { list-style: none; padding-left: 0.25rem; }
.note-detail-content ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5rem; }
.note-detail-content ul[data-type="taskList"] li p { flex: 1; }
`}</style>
{note.content ? (
<div dangerouslySetInnerHTML={{ __html: sanitizeNoteHtml(note.content) }} />
<EntityDetailPage
backTo={{ to: "/notes", label: "Back to Notes" }}
title={
<InlineText
value={note.title}
onSave={(title) => patch({ id, data: { title } })}
placeholder="Untitled note"
/>
}
icon={<FileText className="h-6 w-6" />}
badges={note.isPinned ? <Badge>Pinned</Badge> : null}
actions={
<>
<Button
variant="outline"
onClick={() => togglePin.mutate()}
disabled={togglePin.isPending}
>
{note.isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
{note.isPinned ? "Unpin" : "Pin"}
</Button>
<Button
variant="outline"
onClick={() => toggleArchive.mutate()}
disabled={toggleArchive.isPending}
>
{note.isArchived ? (
<ArchiveRestore className="h-4 w-4" />
) : (
<p className="text-sm text-muted-foreground">No content</p>
<Archive className="h-4 w-4" />
)}
{note.isArchived ? "Unarchive" : "Archive"}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4" /> Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Note</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{note.title}"? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground"
onClick={() => deleteMutation.mutate()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
}
tabs={[
{
value: "editor",
label: "Editor",
content: <EditorTab note={note} patch={patch} />,
},
{ value: "links", label: "Links", content: <LinksTab note={note} /> },
{
value: "activity",
label: "Activity",
content: <EntityActivity entityType="note" entityId={id} />,
},
{
value: "comments",
label: "Comments",
content: <EntityComments entityType="note" entityId={id} />,
},
]}
/>
);
}
function EditorTab({ note, patch }: { note: Note; patch: PatchFn }) {
return (
<div className="space-y-6">
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Content</p>
<div className="rounded-lg border">
<NoteEditor
key={note.id}
initialContent={note.content ?? ""}
onSave={(html) => patch({ id: note.id, data: { content: html } })}
placeholder="Start writing..."
/>
</div>
</div>
<div className="border-t pt-4">
<TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
</div>
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Created {format(parseISO(note.createdAt), "MMM d, yyyy HH:mm")}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}
</span>
</div>
</div>
);
}
function LinksTab({ note }: { note: Note }) {
const navigate = useNavigate();
const backlinks = note.backlinks || [];
const outgoingLinks = note.outgoingLinks || [];
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" />
Backlinks
</h3>
{backlinks.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">No backlinks yet.</p>
) : (
<div className="space-y-0.5">
{backlinks.map((bl) => (
<button
key={bl.id}
type="button"
onClick={() => navigate({ to: "/notes/$id", params: { id: bl.id } })}
className="flex items-start gap-2 rounded-md px-2 py-1.5 text-left hover:bg-muted/50"
>
<Link2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm hover:underline">{bl.title}</span>
{bl.excerpt ? (
<span className="block truncate text-xs text-muted-foreground">
{bl.excerpt}
</span>
) : null}
</span>
</button>
))}
</div>
<TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
</CardContent>
</Card>
)}
</div>
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" />
Outgoing links
</h3>
{outgoingLinks.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">No outgoing links.</p>
) : (
<div className="space-y-0.5">
{outgoingLinks.map((link) => (
<button
key={link.noteId}
type="button"
onClick={() => navigate({ to: "/notes/$id", params: { id: link.noteId } })}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-muted/50"
>
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm hover:underline">
{link.noteTitle}
</span>
</button>
))}
</div>
)}
</div>
</div>
);
}
+675 -62
View File
@@ -1,78 +1,691 @@
import { useState } from "react";
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app";
import { useApiQuery } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Calendar,
Clock,
Flag,
FolderKanban,
ListTodo,
Plus,
Trash2,
X,
} from "lucide-react";
import { differenceInCalendarDays, format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
import { EntityDetailPage } from "@/components/entities/detail-page";
import {
InlineDate,
InlineEdit,
InlineSelect,
InlineText,
InlineTextarea,
type InlineSelectOption,
} from "@/components/entities/inline-edit";
import { EntityActivity } from "@/components/entities/entity-activity";
import { EntityComments } from "@/components/entities/entity-comments";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { ArrowLeft, Calendar, Clock, ListTodo, Activity } from "lucide-react";
import type { Project } from "@/lib/types";
import { format, parseISO } from "date-fns";
import { PROJECT_STATUS } from "@/lib/status-colors";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LoadingState, ErrorState } from "@/components/state";
import { PRIORITY, PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
import type { Project, Section, Task } from "@/lib/types";
import { cn } from "@/lib/utils";
const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [
{ value: "active", label: "Active" },
{ value: "paused", label: "Paused" },
{ value: "completed", label: "Completed" },
{ value: "archived", label: "Archived" },
];
const SECTION_STATUS_OPTIONS: InlineSelectOption[] = [
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In Progress" },
{ value: "complete", label: "Complete" },
];
/** Section lifecycle colors (no shared token exists for section statuses). */
const SECTION_STATUS: Record<string, { label: string; badge: string; dot: string }> = {
planned: { label: "Planned", badge: "bg-slate-500 text-white", dot: "bg-slate-400" },
in_progress: { label: "In Progress", badge: "bg-blue-500 text-white", dot: "bg-blue-500" },
complete: { label: "Complete", badge: "bg-green-500 text-white", dot: "bg-green-500" },
};
/** Sentinel for the "No section" option in the task composer select. */
const NO_SECTION = "__none__";
type PatchFn = (vars: { id: string; data: Record<string, unknown> }) => void;
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Something went wrong";
}
function targetCountdown(
targetDate: string | null
): { text: string; className: string } | null {
if (!targetDate) return null;
const days = differenceInCalendarDays(parseISO(targetDate), new Date());
if (days > 0) {
return {
text: `${days} ${days === 1 ? "day" : "days"} left`,
className: "text-muted-foreground",
};
}
if (days === 0) {
return { text: "Due today", className: "text-muted-foreground" };
}
const overdue = Math.abs(days);
return {
text: `Overdue by ${overdue} ${overdue === 1 ? "day" : "days"}`,
className: "text-destructive",
};
}
function ProjectDetail() {
const { id } = useParams({ from: Route.id });
const navigate = useNavigate();
const { data: project, isLoading } = useApiQuery<Project>(["project", id], "/projects/" + id);
const queryClient = useQueryClient();
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>;
useRealtime({ enabled: true });
const { data: project, isLoading, isError, error, refetch } = useApiQuery<Project>(
["project", id],
"/projects/" + id
);
const { patch } = useOptimisticPatch<Project>({
entityKey: ["project", id],
listKeys: [["projects"], ["active-projects"], ["analytics-projects"]],
patchUrl: (pid) => `/projects/${pid}`,
applyPatch: (current, data) => ({ ...current, ...data }),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/projects/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
toast.success("Project deleted");
navigate({ to: "/projects" });
},
onError: (err) => toast.error(errorMessage(err)),
});
if (isLoading) return <LoadingState label="Loading project..." />;
if (isError) {
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
}
if (!project) return <ErrorState message="Project not found" />;
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<Button variant="ghost" onClick={() => navigate({ to: "/projects" })} className="w-fit">
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Projects
</Button>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color || "#3b82f6" }} />
<CardTitle className="text-2xl">{project.name}</CardTitle>
<Badge className={PROJECT_STATUS[project.status]?.badge}>{PROJECT_STATUS[project.status]?.label ?? project.status}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
{project.description && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</div>
)}
<Separator />
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="flex items-center gap-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<span>{project.taskCount} tasks ({project.completedCount} done)</span>
</div>
{project.targetDate && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Target: {format(parseISO(project.targetDate), "MMM d, yyyy")}</span>
</div>
<EntityDetailPage
backTo={{ to: "/projects", label: "Back to Projects" }}
title={
<InlineText
value={project.name}
onSave={(name) => patch({ id, data: { name } })}
placeholder="Untitled project"
/>
}
icon={<FolderKanban className="h-6 w-6" />}
badges={
<>
<InlineSelect
value={project.status}
options={PROJECT_STATUS_OPTIONS}
displayValue={(v) => (
<Badge className={PROJECT_STATUS[v]?.badge}>
{PROJECT_STATUS[v]?.label ?? v}
</Badge>
)}
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<span>{project.progress}% complete</span>
</div>
</div>
<Progress value={project.progress} className="h-2" />
{project.tags && project.tags.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
<div className="flex flex-wrap gap-2">
{project.tags.map((t: any) => (
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
))}
</div>
</div>
onSave={(status) => patch({ id, data: { status } })}
/>
<InlineEdit
value={project.color ?? ""}
onSave={(color) => patch({ id, data: { color: color || null } })}
showEditIcon={false}
title="Edit color"
display={() => (
<span
className="h-4 w-4 rounded-full"
style={{ backgroundColor: project.color || "#3b82f6" }}
/>
)}
renderEdit={(v, onChange, commit) => (
<Input
type="color"
autoFocus
className="h-8 w-12"
value={v || "#3b82f6"}
onChange={(e) => {
onChange(e.target.value);
commit(e.target.value);
}}
/>
)}
/>
</>
}
actions={
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4" /> Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Project</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{project.name}"? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground"
onClick={() => deleteMutation.mutate()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
tabs={[
{
value: "overview",
label: "Overview",
content: <Overview project={project} patch={patch} />,
},
{ value: "tasks", label: "Tasks", content: <ProjectTasks project={project} /> },
{ value: "sections", label: "Sections", content: <Sections project={project} /> },
{
value: "activity",
label: "Activity",
content: <EntityActivity entityType="project" entityId={id} />,
},
{
value: "comments",
label: "Comments",
content: <EntityComments entityType="project" entityId={id} />,
},
]}
/>
);
}
function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
const countdown = targetCountdown(project.targetDate);
return (
<div className="space-y-6">
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
<InlineTextarea
value={project.description ?? ""}
onSave={(description) =>
patch({ id: project.id, data: { description: description || null } })
}
placeholder="Add a description…"
/>
</div>
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-semibold">Progress</p>
<span className="ml-auto text-sm font-medium">{project.progress}%</span>
</div>
<Progress value={project.progress} className="mt-3 h-2" />
<p className="mt-2 text-xs text-muted-foreground">
{project.completedCount} of {project.taskCount} tasks done · {project.progress}%
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineDate
value={project.targetDate}
onSave={(targetDate) => patch({ id: project.id, data: { targetDate } })}
/>
{countdown && (
<span className={cn("text-xs", countdown.className)}>{countdown.text}</span>
)}
</CardContent>
</Card>
</div>
</div>
{project.tags && project.tags.length > 0 ? (
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((t) => (
<Badge key={t.id} variant="secondary">
{t.name}
</Badge>
))}
</div>
</div>
) : null}
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Created {format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Updated {format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}
</span>
</div>
</div>
);
}
function ProjectTasks({ project }: { project: Project }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [newTitle, setNewTitle] = useState("");
const [sectionId, setSectionId] = useState("");
const [pendingId, setPendingId] = useState<string | null>(null);
const sections = project.sections || [];
const tasks = project.tasks || [];
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["project", project.id] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const addMutation = useMutation({
mutationFn: ({ title, sectionId }: { title: string; sectionId: string | null }) =>
api.post<Task>("/tasks", {
title,
projectId: project.id,
domain: project.domainId,
sectionId,
}),
onSuccess: () => {
setNewTitle("");
toast.success("Task added");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const toggleMutation = useMutation({
mutationFn: ({ taskId, status }: { taskId: string; status: Task["status"] }) =>
api.post<Task>(`/tasks/${taskId}/status`, { status }),
onMutate: (vars) => setPendingId(vars.taskId),
onSettled: () => setPendingId(null),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const submitNewTask = () => {
const title = newTitle.trim();
if (!title || addMutation.isPending) return;
addMutation.mutate({
title,
sectionId: sectionId && sectionId !== NO_SECTION ? sectionId : null,
});
};
// Group tasks by section, keeping sections in API sort order. Tasks whose
// sectionId is null or points at a hard-deleted section land in Unassigned.
const sectionIdSet = new Set(sections.map((s) => s.id));
const tasksBySection = new Map<string, Task[]>();
const unassigned: Task[] = [];
for (const task of tasks) {
if (task.sectionId && sectionIdSet.has(task.sectionId)) {
const bucket = tasksBySection.get(task.sectionId) ?? [];
bucket.push(task);
tasksBySection.set(task.sectionId, bucket);
} else {
unassigned.push(task);
}
}
const openTask = (taskId: string) =>
navigate({ to: "/tasks/$id", params: { id: taskId } });
return (
<div className="space-y-4">
<div className="flex gap-2">
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitNewTask();
}
}}
placeholder="New task title…"
className="h-9"
/>
<Select value={sectionId} onValueChange={setSectionId}>
<SelectTrigger className="h-9 w-44" aria-label="Section">
<SelectValue placeholder="No section" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_SECTION}>No section</SelectItem>
{sections.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={submitNewTask}
disabled={!newTitle.trim() || addMutation.isPending}
>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{tasks.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No tasks yet add the first one above.
</p>
) : (
<div className="space-y-5">
{sections.map((section) => (
<div key={section.id} className="space-y-0.5">
<div className="flex items-center gap-2 px-2 pb-1">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
SECTION_STATUS[section.status]?.dot ?? "bg-slate-400"
)}
/>
<span className="text-sm font-semibold">{section.name}</span>
<Badge variant="secondary" className="text-[10px]">
{tasksBySection.get(section.id)?.length ?? 0}
</Badge>
</div>
{(tasksBySection.get(section.id) ?? []).map((task) => (
<TaskRow
key={task.id}
task={task}
pending={pendingId === task.id}
onToggle={(vars) => toggleMutation.mutate(vars)}
onOpen={() => openTask(task.id)}
/>
))}
</div>
))}
{unassigned.length > 0 ? (
<div className="space-y-0.5">
<div className="flex items-center gap-2 px-2 pb-1">
<span className="h-2 w-2 shrink-0 rounded-full bg-slate-300" />
<span className="text-sm font-semibold text-muted-foreground">
Unassigned
</span>
<Badge variant="secondary" className="text-[10px]">
{unassigned.length}
</Badge>
</div>
{unassigned.map((task) => (
<TaskRow
key={task.id}
task={task}
pending={pendingId === task.id}
onToggle={(vars) => toggleMutation.mutate(vars)}
onOpen={() => openTask(task.id)}
/>
))}
</div>
) : null}
</div>
)}
</div>
);
}
function TaskRow({
task,
pending,
onToggle,
onOpen,
}: {
task: Task;
pending: boolean;
onToggle: (vars: { taskId: string; status: Task["status"] }) => void;
onOpen: () => void;
}) {
return (
<div className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Checkbox
checked={task.status === "done"}
disabled={pending}
onCheckedChange={() =>
onToggle({
taskId: task.id,
status: task.status === "done" ? "todo" : "done",
})
}
aria-label={
"Mark " + task.title + " " + (task.status === "done" ? "as not done" : "as done")
}
/>
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[task.status]?.dot)} />
<button
type="button"
onClick={onOpen}
className={cn(
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
task.status === "done" && "text-muted-foreground line-through"
)}
>
{task.title}
</button>
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>
{PRIORITY[task.priority]?.label ?? task.priority}
</Badge>
{task.dueDate ? (
<span className="shrink-0 text-xs text-muted-foreground">
{format(parseISO(task.dueDate), "MMM d")}
</span>
) : null}
</div>
);
}
function Sections({ project }: { project: Project }) {
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
const [kind, setKind] = useState<"section" | "milestone">("section");
const sections = project.sections || [];
const tasks = project.tasks || [];
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["project", project.id] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
};
const addMutation = useMutation({
mutationFn: ({ name, kind }: { name: string; kind: "section" | "milestone" }) =>
api.post<Section>(`/projects/${project.id}/sections`, { name, kind }),
onSuccess: () => {
setNewName("");
toast.success("Section added");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const renameMutation = useMutation({
mutationFn: ({ sid, name }: { sid: string; name: string }) =>
api.patch<Section>(`/projects/${project.id}/sections/${sid}`, { name }),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const statusMutation = useMutation({
mutationFn: ({ sid, status }: { sid: string; status: Section["status"] }) =>
api.patch<Section>(`/projects/${project.id}/sections/${sid}`, { status }),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const dateMutation = useMutation({
mutationFn: ({ sid, targetDate }: { sid: string; targetDate: string | null }) =>
api.patch<Section>(`/projects/${project.id}/sections/${sid}`, { targetDate }),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: (sid: string) => api.delete(`/projects/${project.id}/sections/${sid}`),
onSuccess: () => {
toast.success("Section deleted");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const submitNewSection = () => {
const name = newName.trim();
if (!name || addMutation.isPending) return;
addMutation.mutate({ name, kind });
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitNewSection();
}
}}
placeholder="New section name…"
className="h-9"
/>
<ToggleGroup
type="single"
size="sm"
value={kind}
onValueChange={(v) => {
if (v) setKind(v as "section" | "milestone");
}}
>
<ToggleGroupItem value="section" aria-label="Section kind">
Section
</ToggleGroupItem>
<ToggleGroupItem value="milestone" aria-label="Milestone kind">
<Flag className="mr-1 h-3.5 w-3.5" />
Milestone
</ToggleGroupItem>
</ToggleGroup>
<Button
size="sm"
onClick={submitNewSection}
disabled={!newName.trim() || addMutation.isPending}
>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{sections.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No sections yet.</p>
) : (
<div className="space-y-0.5">
{sections.map((section) => {
const taskCount = tasks.filter((t) => t.sectionId === section.id).length;
return (
<div
key={section.id}
className="flex flex-wrap items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Badge variant="outline" className="shrink-0 gap-1 text-[10px]">
{section.kind === "milestone" ? (
<>
<Flag className="h-3 w-3" /> Milestone
</>
) : (
"Section"
)}
</Badge>
<InlineText
value={section.name}
onSave={(name) => renameMutation.mutate({ sid: section.id, name })}
className="text-sm"
/>
<InlineSelect
value={section.status}
options={SECTION_STATUS_OPTIONS}
displayValue={(v) => (
<Badge className={SECTION_STATUS[v]?.badge}>
{SECTION_STATUS[v]?.label ?? v}
</Badge>
)}
onSave={(status) =>
statusMutation.mutate({
sid: section.id,
status: status as Section["status"],
})
}
/>
<InlineDate
value={section.targetDate}
onSave={(targetDate) =>
dateMutation.mutate({ sid: section.id, targetDate })
}
/>
<span className="shrink-0 text-xs text-muted-foreground">
{taskCount} {taskCount === 1 ? "task" : "tasks"}
</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => deleteMutation.mutate(section.id)}
aria-label={"Delete section " + section.name}
title="Delete section"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
);
}
+595 -57
View File
@@ -1,75 +1,613 @@
import { useState } from "react";
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app";
import { useApiQuery } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Calendar,
CheckCircle2,
Clock,
Link2,
ListTodo,
Plus,
RepeatIcon,
RotateCcw,
Trash2,
X,
} from "lucide-react";
import { format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
import { EntityDetailPage } from "@/components/entities/detail-page";
import {
InlineDate,
InlineSelect,
InlineText,
InlineTextarea,
type InlineSelectOption,
} from "@/components/entities/inline-edit";
import { EntityActivity } from "@/components/entities/entity-activity";
import { EntityComments } from "@/components/entities/entity-comments";
import { TagManager } from "@/components/entities/tag-manager";
import { CustomFieldsDisplay } from "@/components/custom-fields/custom-fields-display";
import { ArrowLeft, Calendar, Clock, ListTodo } from "lucide-react";
import type { Task } from "@/lib/types";
import { format, parseISO } from "date-fns";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LoadingState, ErrorState } from "@/components/state";
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
import type { PaginatedResponse, Project, Task } from "@/lib/types";
import { cn } from "@/lib/utils";
const STATUS_OPTIONS: InlineSelectOption[] = [
{ value: "todo", label: "Todo" },
{ value: "in_progress", label: "In Progress" },
{ value: "done", label: "Done" },
{ value: "cancelled", label: "Cancelled" },
];
const PRIORITY_OPTIONS: InlineSelectOption[] = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
];
const ESTIMATE_OPTIONS: InlineSelectOption[] = [
{ value: "", label: "None" },
{ value: "15", label: "15 min" },
{ value: "30", label: "30 min" },
{ value: "45", label: "45 min" },
{ value: "60", label: "60 min" },
{ value: "90", label: "90 min" },
{ value: "120", label: "120 min" },
];
// Query keys to keep fresh after any task-affecting mutation. Mirrors the
// realtime hook's invalidation so list/analytics views never go stale.
const LIST_KEYS: string[][] = [
["tasks"],
["tasks-due"],
["stats"],
["productivity-chart"],
["analytics-daily"],
["analytics-projects"],
];
type PatchFn = (vars: { id: string; data: Record<string, unknown> }) => void;
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Something went wrong";
}
function TaskDetail() {
const { id } = useParams({ from: Route.id });
const navigate = useNavigate();
const { data: task, isLoading } = useApiQuery<Task>(["task", id], "/tasks/" + id);
const queryClient = useQueryClient();
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
if (!task) return <div className="p-8 text-center text-muted-foreground">Task not found</div>;
useRealtime({ enabled: true });
const { data: task, isLoading, isError, error, refetch } = useApiQuery<Task>(
["task", id],
"/tasks/" + id
);
const { patch } = useOptimisticPatch<Task>({
entityKey: ["task", id],
listKeys: LIST_KEYS,
patchUrl: (taskId) => `/tasks/${taskId}`,
applyPatch: (current, data) => ({ ...current, ...data }),
});
const toggleComplete = useMutation({
mutationFn: () =>
api.post<Task>(`/tasks/${id}/status`, {
status: task?.status === "done" ? "todo" : "done",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["task", id] });
for (const key of LIST_KEYS) queryClient.invalidateQueries({ queryKey: key });
},
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/tasks/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
toast.success("Task deleted");
navigate({ to: "/tasks" });
},
onError: (err) => toast.error(errorMessage(err)),
});
if (isLoading) return <LoadingState label="Loading task..." />;
if (isError) {
return (
<ErrorState
message={errorMessage(error)}
onRetry={() => refetch()}
/>
);
}
if (!task) return <ErrorState message="Task not found" />;
const isDone = task.status === "done";
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<Button variant="ghost" onClick={() => navigate({ to: "/tasks" })} className="w-fit">
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Tasks
</Button>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<ListTodo className="h-6 w-6 text-muted-foreground" />
<CardTitle className="text-2xl">{task.title}</CardTitle>
<Badge className={TASK_STATUS[task.status]?.badge}>{TASK_STATUS[task.status]?.label ?? task.status}</Badge>
<Badge variant="outline" className={PRIORITY[task.priority]?.badge}>
{PRIORITY[task.priority]?.label ?? task.priority}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
{task.description && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
<p className="text-sm whitespace-pre-wrap">{task.description}</p>
</div>
)}
<Separator />
<div className="grid grid-cols-2 gap-4 text-sm">
{task.dueDate && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Due: {format(parseISO(task.dueDate), "MMM d, yyyy")}</span>
</div>
<EntityDetailPage
backTo={{ to: "/tasks", label: "Back to Tasks" }}
title={
<InlineText
value={task.title}
onSave={(title) => patch({ id, data: { title } })}
placeholder="Untitled task"
/>
}
icon={<ListTodo className="h-6 w-6" />}
badges={
<>
<InlineSelect
value={task.status}
options={STATUS_OPTIONS}
displayValue={(v) => (
<Badge className={TASK_STATUS[v]?.badge}>
{TASK_STATUS[v]?.label ?? v}
</Badge>
)}
{task.estimatedMinutes && (
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>{task.estimatedMinutes} min</span>
</div>
onSave={(status) => patch({ id, data: { status } })}
/>
<InlineSelect
value={task.priority}
options={PRIORITY_OPTIONS}
displayValue={(v) => (
<Badge variant="outline" className={PRIORITY[v]?.badge}>
{PRIORITY[v]?.label ?? v}
</Badge>
)}
<div className="flex items-center gap-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<span>Status: {task.status.replace("_", " ")}</span>
</div>
onSave={(priority) => patch({ id, data: { priority } })}
/>
</>
}
actions={
<>
<Button
onClick={() => toggleComplete.mutate()}
disabled={toggleComplete.isPending}
>
{isDone ? (
<>
<RotateCcw className="h-4 w-4" /> Reopen
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" /> Complete
</>
)}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4" /> Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Task</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{task.title}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground"
onClick={() => deleteMutation.mutate()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
}
tabs={[
{ value: "overview", label: "Overview", content: <Overview task={task} patch={patch} /> },
{ value: "subtasks", label: "Subtasks", content: <Subtasks task={task} /> },
{ value: "dependencies", label: "Dependencies", content: <Dependencies task={task} /> },
{
value: "activity",
label: "Activity",
content: <EntityActivity entityType="task" entityId={id} />,
},
{
value: "comments",
label: "Comments",
content: <EntityComments entityType="task" entityId={id} />,
},
]}
/>
);
}
function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
const navigate = useNavigate();
const activeDomainId = useApiDomain();
const { data: projectsData } = useApiQuery<PaginatedResponse<Project>>(
["projects", activeDomainId],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const projects = projectsData?.items ?? [];
const projectNameById = new Map(projects.map((p) => [p.id, p.name]));
const projectOptions: InlineSelectOption[] = [
{ value: "", label: "No project" },
...projects.map((p) => ({ value: p.id, label: p.name })),
];
return (
<div className="space-y-6">
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
<InlineTextarea
value={task.description ?? ""}
onSave={(description) =>
patch({ id: task.id, data: { description: description || null } })
}
placeholder="Add a description…"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineDate
value={task.dueDate}
onSave={(dueDate) => patch({ id: task.id, data: { dueDate } })}
/>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineSelect
value={task.estimatedMinutes !== null ? String(task.estimatedMinutes) : ""}
options={ESTIMATE_OPTIONS}
displayValue={(v) =>
v ? (
<span>{v} min</span>
) : (
<span className="text-muted-foreground/70">No estimate</span>
)
}
onSave={(est) =>
patch({ id: task.id, data: { estimatedMinutes: est ? Number(est) : null } })
}
/>
</div>
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineSelect
value={task.projectId ?? ""}
options={projectOptions}
displayValue={(v) =>
v ? (
<a
href={`/projects/${v}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate({ to: "/projects/$id", params: { id: v } });
}}
className="text-primary underline-offset-4 hover:underline"
>
{projectNameById.get(v) ?? "Unknown project"}
</a>
) : (
<span className="text-muted-foreground/70">No project</span>
)
}
onSave={(projectId) =>
patch({ id: task.id, data: { projectId: projectId || null } })
}
/>
</div>
{task.recurrenceRule ? (
<div className="flex items-center gap-2">
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-sm">{task.recurrenceRule}</span>
</div>
<CustomFieldsDisplay entityType="tasks" values={task.customFields} />
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
</CardContent>
</Card>
) : null}
</div>
<div className="border-t pt-4">
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
</div>
<CustomFieldsDisplay entityType="tasks" values={task.customFields} />
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Created {format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Updated {format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}
</span>
</div>
</div>
);
}
function Subtasks({ task }: { task: Task }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [newTitle, setNewTitle] = useState("");
const [pendingId, setPendingId] = useState<string | null>(null);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["task", task.id] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const addMutation = useMutation({
mutationFn: (title: string) =>
api.post<Task>("/tasks", { title, parentId: task.id, domain: task.domainId }),
onSuccess: () => {
setNewTitle("");
toast.success("Subtask added");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const toggleMutation = useMutation({
mutationFn: ({ subId, status }: { subId: string; status: Task["status"] }) =>
api.post<Task>(`/tasks/${subId}/status`, { status }),
onMutate: (vars) => setPendingId(vars.subId),
onSettled: () => setPendingId(null),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const subtasks = task.subtasks || [];
const submitNewSubtask = () => {
const title = newTitle.trim();
if (!title || addMutation.isPending) return;
addMutation.mutate(title);
};
return (
<div className="space-y-3">
<div className="flex gap-2">
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitNewSubtask();
}
}}
placeholder="New subtask title…"
className="h-9"
/>
<Button size="sm" onClick={submitNewSubtask} disabled={!newTitle.trim() || addMutation.isPending}>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{subtasks.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No subtasks yet.</p>
) : (
<div className="space-y-0.5">
{subtasks.map((sub) => (
<div
key={sub.id}
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Checkbox
checked={sub.status === "done"}
disabled={pendingId === sub.id}
onCheckedChange={() =>
toggleMutation.mutate({
subId: sub.id,
status: sub.status === "done" ? "todo" : "done",
})
}
aria-label={"Mark " + sub.title + " " + (sub.status === "done" ? "as not done" : "as done")}
/>
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[sub.status]?.dot)} />
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: sub.id } })}
className={cn(
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
sub.status === "done" && "text-muted-foreground line-through"
)}
>
{sub.title}
</button>
</div>
))}
</div>
)}
</div>
);
}
function Dependencies({ task }: { task: Task }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [depValue, setDepValue] = useState("");
const { data: tasksData, isLoading: tasksLoading } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, "dependency-picker"],
"/tasks?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["task", task.id] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const addDependency = useMutation({
mutationFn: (dependsOnTaskId: string) =>
api.post(`/tasks/${task.id}/dependencies`, { dependsOnTaskId }),
onSuccess: () => {
setDepValue("");
toast.success("Dependency added");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const removeDependency = useMutation({
mutationFn: ({ taskId, depId }: { taskId: string; depId: string }) =>
api.delete(`/tasks/${taskId}/dependencies/${depId}`),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const dependencies = task.dependencies || [];
const dependents = task.dependents || [];
const availableTasks = (tasksData?.items ?? []).filter(
(t) => t.id !== task.id && !dependencies.some((d) => d.id === t.id)
);
const depPlaceholder = tasksLoading
? "Loading tasks..."
: availableTasks.length === 0
? "No tasks to add"
: "Add dependency...";
const handleAddDependency = (value: string) => {
if (!value) return;
addDependency.mutate(value);
};
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" />
Blocked by
</h3>
{dependencies.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">Nothing blocks this task.</p>
) : (
<div className="space-y-0.5">
{dependencies.map((dep) => (
<div
key={dep.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{dep.title}
</button>
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
{TASK_STATUS[dep.status]?.label ?? dep.status}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeDependency.mutate({ taskId: task.id, depId: dep.id })}
aria-label={"Remove dependency on " + dep.title}
title="Remove dependency"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
<div className="mt-3">
<Select
value={depValue}
onValueChange={handleAddDependency}
disabled={availableTasks.length === 0}
>
<SelectTrigger className="h-8 w-full text-sm" aria-label="Add dependency">
<SelectValue placeholder={depPlaceholder} />
</SelectTrigger>
<SelectContent>
{availableTasks.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" />
Blocks
</h3>
{dependents.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">Nothing depends on this task.</p>
) : (
<div className="space-y-0.5">
{dependents.map((dep) => (
<div
key={dep.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{dep.title}
</button>
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
{TASK_STATUS[dep.status]?.label ?? dep.status}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeDependency.mutate({ taskId: dep.id, depId: task.id })}
aria-label={"Remove this task from " + dep.title + "'s dependencies"}
title="Remove dependency"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
);
}