feat: add threaded comments, activity feeds, and task dependencies
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 1–200).
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "project-e",
|
||||
"dependencies": {
|
||||
"@project-e/db": "^0.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"postgres": "^3.4.8",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "comments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" uuid NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"author" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_workspace_id_domains_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "comments_workspace_id_idx" ON "comments" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "comments_entity_idx" ON "comments" USING btree ("entity_type","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "comments_parent_id_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint
|
||||
CREATE INDEX "comments_created_at_idx" ON "comments" USING btree ("created_at");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "0002-snapshot-id",
|
||||
"prevId": "0002-snapshot-id",
|
||||
"id": "e8a3c1d2-0a2b-4c3d-9e4f-5a6b7c8d9e0f",
|
||||
"prevId": "f29d0874-5760-4ab6-aaa5-e910f7e8637e",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -2598,4 +2598,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1619
-6
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1785557679802,
|
||||
"tag": "0005_search_vector_trigger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1786392464857,
|
||||
"tag": "0006_minor_doctor_octopus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
"deploy": "bash script/deploy.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@project-e/db": "^0.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"postgres": "^3.4.8"
|
||||
},
|
||||
|
||||
@@ -416,6 +416,34 @@ export const activityFeed = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// ── Comments ───────────────────────────────────────────────────────────────────
|
||||
// Nested comment threads on any entity. Comments were previously stored as
|
||||
// activity_feed rows (entity_type='comment'); they now live here so replies
|
||||
// (parent_id) are first-class.
|
||||
|
||||
export const comments = pgTable(
|
||||
'comments',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: uuid('entity_id').notNull(),
|
||||
workspaceId: uuid('workspace_id')
|
||||
.notNull()
|
||||
.references((): any => domains.id, { onDelete: 'cascade' }),
|
||||
parentId: uuid('parent_id').references((): any => comments.id, { onDelete: 'cascade' }),
|
||||
author: text('author').notNull(),
|
||||
content: text('content').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index('comments_workspace_id_idx').on(table.workspaceId),
|
||||
index('comments_entity_idx').on(table.entityType, table.entityId),
|
||||
index('comments_parent_id_idx').on(table.parentId),
|
||||
index('comments_created_at_idx').on(table.createdAt),
|
||||
]
|
||||
);
|
||||
|
||||
// ── Scheduled Jobs ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const scheduledJobs = pgTable(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { db, activityFeed, comments, tasks } from "@project-e/db";
|
||||
import { and, asc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
// Backfill legacy comments from activity_feed (entity_type='comment') into the
|
||||
// new dedicated comments table. Comments were only ever created for tasks, so
|
||||
// every row maps to entity_type='task' with no parent.
|
||||
//
|
||||
// Idempotent: rows that already exist in `comments` (same entity_id + content +
|
||||
// created_at) are skipped, so this script is safe to re-run.
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(activityFeed)
|
||||
.where(eq(activityFeed.entityType, "comment"))
|
||||
.orderBy(asc(activityFeed.createdAt));
|
||||
|
||||
let inserted = 0;
|
||||
let alreadyPresent = 0;
|
||||
let missingContent = 0;
|
||||
let orphaned = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const content = row.changes?.content;
|
||||
if (typeof content !== "string" || content.trim() === "") {
|
||||
missingContent++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only backfill comments whose task still exists (skip soft-deleted/absent).
|
||||
const [task] = await db
|
||||
.select({ id: tasks.id })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, row.entityId), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!task) {
|
||||
orphaned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idempotency guard: skip if this comment was already migrated.
|
||||
const [existing] = await db
|
||||
.select({ id: comments.id })
|
||||
.from(comments)
|
||||
.where(and(
|
||||
eq(comments.entityId, row.entityId),
|
||||
eq(comments.content, content),
|
||||
eq(comments.createdAt, row.createdAt),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
alreadyPresent++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.insert(comments).values({
|
||||
entityType: "task",
|
||||
entityId: row.entityId,
|
||||
workspaceId: row.workspaceId,
|
||||
author: row.actor,
|
||||
content,
|
||||
createdAt: row.createdAt,
|
||||
parentId: null,
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
inserted++;
|
||||
}
|
||||
|
||||
console.log(`[backfill-comments] rows found: ${rows.length}`);
|
||||
console.log(`[backfill-comments] inserted: ${inserted}`);
|
||||
console.log(`[backfill-comments] skipped (already present): ${alreadyPresent}`);
|
||||
console.log(`[backfill-comments] skipped (missing content): ${missingContent}`);
|
||||
console.log(`[backfill-comments] skipped (orphaned entity): ${orphaned}`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("[backfill-comments] failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user