diff --git a/apps/web/src/app/(authenticated)/projects/[id]/page.tsx b/apps/web/src/app/(authenticated)/projects/[id]/page.tsx
new file mode 100644
index 0000000..1e79f2b
--- /dev/null
+++ b/apps/web/src/app/(authenticated)/projects/[id]/page.tsx
@@ -0,0 +1,491 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import type { ReactElement } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { ArrowLeft } from "lucide-react";
+
+import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
+import { fetchProject, type ProjectDetail } from "@/lib/api/projects";
+import { useWorkspaceId } from "@/lib/hooks";
+
+interface BadgeStyle {
+ label: string;
+ bg: string;
+ color: string;
+}
+
+interface StatusBadgeProps {
+ style: BadgeStyle;
+}
+
+interface MetaItemProps {
+ label: string;
+ value: string;
+}
+
+function getProjectStatusStyle(status: string): BadgeStyle {
+ switch (status) {
+ case "PLANNING":
+ return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
+ case "ACTIVE":
+ return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
+ case "PAUSED":
+ return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
+ case "COMPLETED":
+ return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" };
+ case "ARCHIVED":
+ return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ default:
+ return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ }
+}
+
+function getPriorityStyle(priority: string | null | undefined): BadgeStyle {
+ switch (priority) {
+ case "HIGH":
+ return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
+ case "MEDIUM":
+ return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
+ case "LOW":
+ return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ default:
+ return { label: "Unspecified", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ }
+}
+
+function getTaskStatusStyle(status: string): BadgeStyle {
+ switch (status) {
+ case "NOT_STARTED":
+ return { label: "Not Started", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
+ case "IN_PROGRESS":
+ return { label: "In Progress", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
+ case "PAUSED":
+ return { label: "Paused", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ case "COMPLETED":
+ return { label: "Completed", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
+ case "ARCHIVED":
+ return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ default:
+ return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ }
+}
+
+function formatDate(iso: string | null | undefined): string {
+ if (!iso) return "Not set";
+
+ try {
+ return new Date(iso).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+function formatDateTime(iso: string | null | undefined): string {
+ if (!iso) return "Not set";
+
+ try {
+ return new Date(iso).toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+function toFriendlyErrorMessage(error: unknown): string {
+ const fallback = "We had trouble loading this project. Please try again when you're ready.";
+
+ if (!(error instanceof Error)) {
+ return fallback;
+ }
+
+ const message = error.message.trim();
+ if (message.toLowerCase().includes("not found")) {
+ return "Project not found. It may have been deleted or you may not have access to it.";
+ }
+
+ return message || fallback;
+}
+
+function StatusBadge({ style: statusStyle }: StatusBadgeProps): ReactElement {
+ return (
+
+ {statusStyle.label}
+
+ );
+}
+
+function MetaItem({ label, value }: MetaItemProps): ReactElement {
+ return (
+
+ );
+}
+
+export default function ProjectDetailPage(): ReactElement {
+ const router = useRouter();
+ const params = useParams<{ id: string | string[] }>();
+ const workspaceId = useWorkspaceId();
+ const rawProjectId = params.id;
+ const projectId = Array.isArray(rawProjectId) ? (rawProjectId[0] ?? null) : rawProjectId;
+
+ const [project, setProject] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const loadProject = useCallback(async (id: string, wsId: string): Promise => {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const data = await fetchProject(id, wsId);
+ setProject(data);
+ } catch (err: unknown) {
+ console.error("[ProjectDetail] Failed to fetch project:", err);
+ setProject(null);
+ setError(toFriendlyErrorMessage(err));
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!projectId) {
+ setProject(null);
+ setError("The project link is invalid. Please return to the projects page.");
+ setIsLoading(false);
+ return;
+ }
+
+ if (!workspaceId) {
+ setProject(null);
+ setError("Select a workspace to view this project.");
+ setIsLoading(false);
+ return;
+ }
+
+ const id = projectId;
+ const wsId = workspaceId;
+ let cancelled = false;
+
+ async function load(): Promise {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const data = await fetchProject(id, wsId);
+ if (!cancelled) {
+ setProject(data);
+ }
+ } catch (err: unknown) {
+ console.error("[ProjectDetail] Failed to fetch project:", err);
+ if (!cancelled) {
+ setProject(null);
+ setError(toFriendlyErrorMessage(err));
+ }
+ } finally {
+ if (!cancelled) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void load();
+
+ return (): void => {
+ cancelled = true;
+ };
+ }, [projectId, workspaceId]);
+
+ function handleRetry(): void {
+ if (!projectId || !workspaceId) return;
+ void loadProject(projectId, workspaceId);
+ }
+
+ function handleBack(): void {
+ router.push("/projects");
+ }
+
+ const projectStatus = project ? getProjectStatusStyle(project.status) : null;
+ const projectPriority = project ? getPriorityStyle(project.priority) : null;
+ const dueDate = project?.dueDate ?? project?.endDate;
+ const creator =
+ project?.creator.name && project.creator.name.trim().length > 0
+ ? `${project.creator.name} (${project.creator.email})`
+ : (project?.creator.email ?? "Unknown");
+
+ return (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : error !== null ? (
+
+
{error}
+
+
+
+
+
+ ) : project === null ? (
+
+
Project details are not available.
+
+ ) : (
+
+
+
+
+
+ {project.name}
+
+
+
+ {projectStatus && }
+ {projectPriority && }
+
+
+
+ {project.description ? (
+
+ {project.description}
+
+ ) : (
+
+ No description provided.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tasks ({String(project._count.tasks)})
+
+
+ {project.tasks.length === 0 ? (
+
+ No tasks yet for this project.
+
+ ) : (
+
+ {project.tasks.map((task, index) => (
+
+
+
+
+ {task.title}
+
+
+ Due: {formatDate(task.dueDate)}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ Events ({String(project._count.events)})
+
+
+ {project.events.length === 0 ? (
+
+ No events scheduled for this project.
+
+ ) : (
+
+ {project.events.map((event, index) => (
+
+
+ {event.title}
+
+
+ {formatDateTime(event.startTime)} - {formatDateTime(event.endTime)}
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/lib/api/projects.ts b/apps/web/src/lib/api/projects.ts
index 8d68448..d746d91 100644
--- a/apps/web/src/lib/api/projects.ts
+++ b/apps/web/src/lib/api/projects.ts
@@ -25,7 +25,9 @@ export interface Project {
name: string;
description: string | null;
status: ProjectStatus;
+ priority?: string | null;
startDate: string | null;
+ dueDate?: string | null;
endDate: string | null;
creatorId: string;
domainId: string | null;
@@ -35,6 +37,54 @@ export interface Project {
updatedAt: string;
}
+/**
+ * Minimal creator details included on project detail response
+ */
+export interface ProjectCreator {
+ id: string;
+ name: string | null;
+ email: string;
+}
+
+/**
+ * Task row included on project detail response
+ */
+export interface ProjectTaskSummary {
+ id: string;
+ title: string;
+ status: string;
+ priority: string;
+ dueDate: string | null;
+}
+
+/**
+ * Event row included on project detail response
+ */
+export interface ProjectEventSummary {
+ id: string;
+ title: string;
+ startTime: string;
+ endTime: string | null;
+}
+
+/**
+ * Counts included on project detail response
+ */
+export interface ProjectDetailCounts {
+ tasks: number;
+ events: number;
+}
+
+/**
+ * Single-project response with related details
+ */
+export interface ProjectDetail extends Project {
+ creator: ProjectCreator;
+ tasks: ProjectTaskSummary[];
+ events: ProjectEventSummary[];
+ _count: ProjectDetailCounts;
+}
+
/**
* DTO for creating a new project
*/
@@ -72,8 +122,8 @@ export async function fetchProjects(workspaceId?: string): Promise {
/**
* Fetch a single project by ID
*/
-export async function fetchProject(id: string, workspaceId?: string): Promise {
- return apiGet(`/api/projects/${id}`, workspaceId);
+export async function fetchProject(id: string, workspaceId?: string): Promise {
+ return apiGet(`/api/projects/${id}`, workspaceId);
}
/**