"use client" import { Suspense, useEffect, useMemo, useState } from "react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { ArrowLeft, CheckCircle2, Filter, FolderKanban, RefreshCcw } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader } from "@/components/ui/card" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { isSprintInProgress, parseSprintEnd, parseSprintStart } from "@/lib/utils" import { useTaskStore, type Priority, type TaskStatus, type TaskType } from "@/stores/useTaskStore" const TASK_STATUSES: TaskStatus[] = [ "open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done", ] const TASK_PRIORITIES: Priority[] = ["low", "medium", "high", "urgent"] const TASK_TYPES: TaskType[] = ["idea", "task", "bug", "research", "plan"] const BACKLOG_FILTER_TOKEN = "backlog" const UNASSIGNED_FILTER_TOKEN = "unassigned" type TaskScope = "all" | "active-sprint" type DueFilter = "any" | "has" | "none" | "overdue" | "today" | "upcoming" type TaskDateField = "createdAt" | "updatedAt" | "dueDate" type SortOrder = "asc" | "desc" type Filters = { scope: TaskScope sortBy: TaskDateField sortOrder: SortOrder query: string statuses: TaskStatus[] priorities: Priority[] types: TaskType[] projectIds: string[] sprintIds: string[] assigneeIds: string[] tags: string[] due: DueFilter } const DUE_FILTERS: DueFilter[] = ["any", "has", "none", "overdue", "today", "upcoming"] const DATE_SORT_FIELDS: TaskDateField[] = ["updatedAt", "createdAt", "dueDate"] const DUE_FILTER_LABELS: Record = { any: "Any Due", has: "Has Due Date", none: "No Due Date", overdue: "Overdue", today: "Due Today", upcoming: "Upcoming", } const DATE_SORT_FIELD_LABELS: Record = { updatedAt: "Updated At", createdAt: "Created At", dueDate: "Due Date", } function toTimestamp(value: string): number { const timestamp = new Date(value).getTime() return Number.isNaN(timestamp) ? 0 : timestamp } function parseCsv(raw: string | null): string[] { if (!raw) return [] return raw .split(",") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) } function parseScope(raw: string | null): TaskScope { return raw === "active-sprint" ? "active-sprint" : "all" } function parseDueFilter(raw: string | null): DueFilter { if (!raw) return "any" return DUE_FILTERS.includes(raw as DueFilter) ? (raw as DueFilter) : "any" } function parseSortBy(raw: string | null): TaskDateField { if (!raw) return "updatedAt" return DATE_SORT_FIELDS.includes(raw as TaskDateField) ? (raw as TaskDateField) : "updatedAt" } function parseSortOrder(raw: string | null): SortOrder { return raw === "asc" ? "asc" : "desc" } function isTaskStatus(value: string): value is TaskStatus { return TASK_STATUSES.includes(value as TaskStatus) } function isTaskPriority(value: string): value is Priority { return TASK_PRIORITIES.includes(value as Priority) } function isTaskType(value: string): value is TaskType { return TASK_TYPES.includes(value as TaskType) } function formatStatus(status: TaskStatus): string { if (status === "todo") return "To Do" return status .split("-") .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) .join(" ") } function formatType(type: TaskType): string { return `${type.charAt(0).toUpperCase()}${type.slice(1)}` } function formatUpdatedAt(value: string): string { const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) return "Unknown update time" return parsed.toLocaleString() } function getPriorityClasses(priority: Priority): string { switch (priority) { case "urgent": return "bg-red-500/10 text-red-300 border-red-500/30" case "high": return "bg-orange-500/10 text-orange-300 border-orange-500/30" case "medium": return "bg-yellow-500/10 text-yellow-300 border-yellow-500/30" default: return "bg-blue-500/10 text-blue-300 border-blue-500/30" } } function getStatusClasses(status: TaskStatus): string { switch (status) { case "done": return "bg-emerald-500/10 text-emerald-300 border-emerald-500/30" case "in-progress": return "bg-blue-500/10 text-blue-300 border-blue-500/30" case "blocked": return "bg-red-500/10 text-red-300 border-red-500/30" case "review": case "validate": return "bg-purple-500/10 text-purple-300 border-purple-500/30" case "canceled": return "bg-rose-500/10 text-rose-300 border-rose-500/30" default: return "bg-slate-500/10 text-slate-300 border-slate-500/30" } } function toggleOrdered(current: T[], value: T, universe: readonly T[]): T[] { const next = new Set(current) if (next.has(value)) { next.delete(value) } else { next.add(value) } return universe.filter((entry) => next.has(entry)) } function getTaskDateSortValue( task: { createdAt: string; updatedAt: string; dueDate?: string }, field: TaskDateField ): number | null { if (field === "dueDate") { if (!task.dueDate) return null const timestamp = parseSprintStart(task.dueDate).getTime() return Number.isNaN(timestamp) ? null : timestamp } const source = field === "createdAt" ? task.createdAt : task.updatedAt const timestamp = new Date(source).getTime() return Number.isNaN(timestamp) ? null : timestamp } function buildTasksUrl(filters: Filters): string { const params = new URLSearchParams() if (filters.scope !== "all") params.set("scope", filters.scope) if (filters.sortBy !== "updatedAt") params.set("sortBy", filters.sortBy) if (filters.sortOrder !== "desc") params.set("sortOrder", filters.sortOrder) if (filters.query.trim().length > 0) params.set("q", filters.query.trim()) if (filters.statuses.length > 0) params.set("status", filters.statuses.join(",")) if (filters.priorities.length > 0) params.set("priority", filters.priorities.join(",")) if (filters.types.length > 0) params.set("type", filters.types.join(",")) if (filters.projectIds.length > 0) params.set("project", filters.projectIds.join(",")) if (filters.sprintIds.length > 0) params.set("sprint", filters.sprintIds.join(",")) if (filters.assigneeIds.length > 0) params.set("assignee", filters.assigneeIds.join(",")) if (filters.tags.length > 0) params.set("tag", filters.tags.join(",")) if (filters.due !== "any") params.set("due", filters.due) const query = params.toString() return query.length > 0 ? `/tasks?${query}` : "/tasks" } function TasksPageContent() { const router = useRouter() const searchParams = useSearchParams() const { projects, sprints, tasks, syncFromServer, syncError } = useTaskStore() const [authReady, setAuthReady] = useState(false) const [isLoading, setIsLoading] = useState(true) const [searchInput, setSearchInput] = useState("") const [filtersDialogOpen, setFiltersDialogOpen] = useState(false) useEffect(() => { let isMounted = true const loadSession = async () => { try { const response = await fetch("/api/auth/session", { cache: "no-store" }) if (!response.ok) { if (isMounted) router.replace("/login") return } if (isMounted) setAuthReady(true) } catch { if (isMounted) router.replace("/login") } } void loadSession() return () => { isMounted = false } }, [router]) const projectById = useMemo(() => { const mapping = new Map() projects.forEach((project) => { mapping.set(project.id, { name: project.name, color: project.color }) }) return mapping }, [projects]) const sprintById = useMemo(() => { const mapping = new Map() sprints.forEach((sprint) => { mapping.set(sprint.id, sprint.name) }) return mapping }, [sprints]) const projectIdSet = useMemo(() => new Set(projects.map((project) => project.id)), [projects]) const sprintIdSet = useMemo(() => new Set(sprints.map((sprint) => sprint.id)), [sprints]) const assigneeOptions = useMemo(() => { const byId = new Map() tasks.forEach((task) => { if (!task.assigneeId || !task.assigneeName) return byId.set(task.assigneeId, task.assigneeName) }) return Array.from(byId.entries()) .map(([id, name]) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name)) }, [tasks]) const assigneeIdSet = useMemo(() => new Set(assigneeOptions.map((entry) => entry.id)), [assigneeOptions]) const tagOptions = useMemo(() => { const counts = new Map() tasks.forEach((task) => { task.tags.forEach((tag) => { const normalized = tag.trim().toLowerCase() if (!normalized) return const existing = counts.get(normalized) if (existing) { existing.count += 1 return } counts.set(normalized, { value: normalized, label: tag.trim(), count: 1 }) }) }) return Array.from(counts.values()) .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)) .slice(0, 24) }, [tasks]) const tagValueSet = useMemo(() => new Set(tagOptions.map((entry) => entry.value)), [tagOptions]) const filters = useMemo(() => { const scope = parseScope(searchParams.get("scope")) const sortBy = parseSortBy(searchParams.get("sortBy")) const sortOrder = parseSortOrder(searchParams.get("sortOrder")) const query = (searchParams.get("q") || "").trim() const statuses = parseCsv(searchParams.get("status")).filter(isTaskStatus) const priorities = parseCsv(searchParams.get("priority")).filter(isTaskPriority) const types = parseCsv(searchParams.get("type")).filter(isTaskType) const projectIds = parseCsv(searchParams.get("project")).filter((entry) => projectIdSet.has(entry)) const sprintIds = parseCsv(searchParams.get("sprint")).filter((entry) => { return entry === BACKLOG_FILTER_TOKEN || sprintIdSet.has(entry) }) const assigneeIds = parseCsv(searchParams.get("assignee")).filter((entry) => { return entry === UNASSIGNED_FILTER_TOKEN || assigneeIdSet.has(entry) }) const tags = parseCsv(searchParams.get("tag")) .map((entry) => entry.toLowerCase()) .filter((entry) => tagValueSet.has(entry)) const due = parseDueFilter(searchParams.get("due")) return { scope, sortBy, sortOrder, query, statuses, priorities, types, projectIds, sprintIds, assigneeIds, tags, due, } }, [assigneeIdSet, projectIdSet, searchParams, sprintIdSet, tagValueSet]) useEffect(() => { if (!authReady) return let active = true const loadData = async () => { setIsLoading(true) await syncFromServer({ scope: filters.scope }) if (active) setIsLoading(false) } void loadData() return () => { active = false } }, [authReady, filters.scope, syncFromServer]) const activeSprint = useMemo(() => { const now = new Date() return ( sprints .filter((sprint) => isSprintInProgress(sprint.startDate, sprint.endDate, now)) .sort((a, b) => parseSprintStart(b.startDate).getTime() - parseSprintStart(a.startDate).getTime())[0] || null ) }, [sprints]) const filteredTasks = useMemo(() => { const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0) const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999) const query = filters.query.toLowerCase() return tasks .filter((task) => { if (filters.statuses.length > 0 && !filters.statuses.includes(task.status)) return false if (filters.priorities.length > 0 && !filters.priorities.includes(task.priority)) return false if (filters.types.length > 0 && !filters.types.includes(task.type)) return false if (filters.projectIds.length > 0 && !filters.projectIds.includes(task.projectId)) return false if (filters.sprintIds.length > 0) { const matchesSprint = filters.sprintIds.some((filterToken) => { if (filterToken === BACKLOG_FILTER_TOKEN) return !task.sprintId return task.sprintId === filterToken }) if (!matchesSprint) return false } if (filters.assigneeIds.length > 0) { const matchesAssignee = filters.assigneeIds.some((filterToken) => { if (filterToken === UNASSIGNED_FILTER_TOKEN) return !task.assigneeId return task.assigneeId === filterToken }) if (!matchesAssignee) return false } if (filters.tags.length > 0) { const taskTagSet = new Set(task.tags.map((tag) => tag.toLowerCase())) const matchesTags = filters.tags.some((tag) => taskTagSet.has(tag)) if (!matchesTags) return false } if (filters.due !== "any") { if (!task.dueDate) { if (filters.due !== "none") return false } else { if (filters.due === "none") return false if (filters.due !== "has") { const dueStart = parseSprintStart(task.dueDate) const dueEnd = parseSprintEnd(task.dueDate) if (Number.isNaN(dueStart.getTime()) || Number.isNaN(dueEnd.getTime())) return false if (filters.due === "overdue" && dueEnd >= now) return false if (filters.due === "today" && (dueStart > todayEnd || dueEnd < todayStart)) return false if (filters.due === "upcoming" && dueStart <= todayEnd) return false } } } if (query.length > 0) { const projectName = projectById.get(task.projectId)?.name || "" const sprintName = task.sprintId ? sprintById.get(task.sprintId) || "" : "backlog" const haystack = [ task.title, task.description || "", task.type, task.status, task.priority, task.assigneeName || "", projectName, sprintName, task.tags.join(" "), ] .join(" ") .toLowerCase() if (!haystack.includes(query)) return false } return true }) .sort((a, b) => { const left = getTaskDateSortValue(a, filters.sortBy) const right = getTaskDateSortValue(b, filters.sortBy) if (left === null && right === null) { return toTimestamp(b.updatedAt) - toTimestamp(a.updatedAt) } if (left === null) return 1 if (right === null) return -1 if (left === right) return toTimestamp(b.updatedAt) - toTimestamp(a.updatedAt) return filters.sortOrder === "asc" ? left - right : right - left }) }, [filters, projectById, sprintById, tasks]) const hasQueryParams = useMemo(() => searchParams.toString().length > 0, [searchParams]) const statusFilterSet = useMemo(() => new Set(filters.statuses), [filters.statuses]) const priorityFilterSet = useMemo(() => new Set(filters.priorities), [filters.priorities]) const typeFilterSet = useMemo(() => new Set(filters.types), [filters.types]) const projectFilterSet = useMemo(() => new Set(filters.projectIds), [filters.projectIds]) const sprintFilterSet = useMemo(() => new Set(filters.sprintIds), [filters.sprintIds]) const assigneeFilterSet = useMemo(() => new Set(filters.assigneeIds), [filters.assigneeIds]) const tagFilterSet = useMemo(() => new Set(filters.tags), [filters.tags]) const activeFilterCount = filters.statuses.length + filters.priorities.length + filters.types.length + filters.projectIds.length + filters.sprintIds.length + filters.assigneeIds.length + filters.tags.length + (filters.sortBy !== "updatedAt" || filters.sortOrder !== "desc" ? 1 : 0) + (filters.query.length > 0 ? 1 : 0) + (filters.due !== "any" ? 1 : 0) + (filters.scope !== "all" ? 1 : 0) const updateFilters = (updates: Partial) => { const nextFilters: Filters = { ...filters, ...updates } router.push(buildTasksUrl(nextFilters)) } const clearFilters = () => { router.push("/tasks") } const applySearch = () => { updateFilters({ query: searchInput.trim() }) } const toggleStatusFilter = (status: TaskStatus) => { updateFilters({ statuses: toggleOrdered(filters.statuses, status, TASK_STATUSES), }) } const togglePriorityFilter = (priority: Priority) => { updateFilters({ priorities: toggleOrdered(filters.priorities, priority, TASK_PRIORITIES), }) } const toggleTypeFilter = (type: TaskType) => { updateFilters({ types: toggleOrdered(filters.types, type, TASK_TYPES), }) } const toggleProjectFilter = (projectId: string) => { updateFilters({ projectIds: toggleOrdered(filters.projectIds, projectId, projects.map((project) => project.id)), }) } const sprintOrder = [BACKLOG_FILTER_TOKEN, ...sprints.map((sprint) => sprint.id)] const toggleSprintFilter = (sprintId: string) => { updateFilters({ sprintIds: toggleOrdered(filters.sprintIds, sprintId, sprintOrder), }) } const assigneeOrder = [UNASSIGNED_FILTER_TOKEN, ...assigneeOptions.map((assignee) => assignee.id)] const toggleAssigneeFilter = (assigneeId: string) => { updateFilters({ assigneeIds: toggleOrdered(filters.assigneeIds, assigneeId, assigneeOrder), }) } const toggleTagFilter = (tag: string) => { updateFilters({ tags: toggleOrdered(filters.tags, tag, tagOptions.map((entry) => entry.value)), }) } if (!authReady) { return (

Checking session...

) } return (

Tasks

Master-filter task list

{hasQueryParams && ( )}
{syncError && (
Sync error: {syncError}
)}

Scope: {filters.scope === "active-sprint" ? "Current sprint" : "All tasks"}

Status: {filters.statuses.length > 0 ? filters.statuses.map(formatStatus).join(", ") : "Any"}

Priority: {filters.priorities.length > 0 ? filters.priorities.map((entry) => entry.toUpperCase()).join(", ") : "Any"}

Sort: {DATE_SORT_FIELD_LABELS[filters.sortBy]} ({filters.sortOrder.toUpperCase()})

{filters.scope === "active-sprint" && activeSprint && (

Active sprint: {activeSprint.name}

)}
{isLoading ? (
Loading tasks...
) : filteredTasks.length === 0 ? (

No tasks found

{filters.scope === "active-sprint" && !activeSprint ? ( <>

There is no active sprint right now, so active-sprint scope returned no tasks.

) : (

Open Filters to adjust status, priority, project, sprint, assignee, tags, due date, and search.

)}
) : (
{filteredTasks.map((task) => { const project = projectById.get(task.projectId) const sprintName = task.sprintId ? sprintById.get(task.sprintId) : null return (
{task.title}
{formatStatus(task.status)} {task.priority.toUpperCase()}
{project?.name || "Unknown project"} {sprintName || "Backlog"} {task.assigneeName || "Unassigned"} Updated {formatUpdatedAt(task.updatedAt)}
) })}
)} {filters.scope === "active-sprint" && filteredTasks.length > 0 && (
)}
{ if (open) setSearchInput(filters.query) setFiltersDialogOpen(open) }} > Master Filters

Scope

Search

setSearchInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault() applySearch() } }} placeholder="Search title, description, tags, assignee, project, sprint..." className="bg-slate-950 border-slate-700 text-slate-100" />

Sort

{DATE_SORT_FIELDS.map((field) => { const isActive = filters.sortBy === field return ( ) })}
{(["desc", "asc"] as const).map((order) => { const isActive = filters.sortOrder === order return ( ) })}

Due Date

{DUE_FILTERS.map((due) => { const isActive = filters.due === due return ( ) })}

Status Filters

{TASK_STATUSES.map((status) => { const isActive = statusFilterSet.has(status) return ( ) })}

Priority Filters

{TASK_PRIORITIES.map((priority) => { const isActive = priorityFilterSet.has(priority) return ( ) })}

Type Filters

{TASK_TYPES.map((type) => { const isActive = typeFilterSet.has(type) return ( ) })}

Project Filters

{projects.length === 0 ? (

No projects available.

) : ( projects.map((project) => { const isActive = projectFilterSet.has(project.id) return ( ) }) )}

Sprint Filters

{sprints.map((sprint) => { const isActive = sprintFilterSet.has(sprint.id) return ( ) })}

Assignee Filters

{assigneeOptions.map((assignee) => { const isActive = assigneeFilterSet.has(assignee.id) return ( ) })}

Tag Filters

{tagOptions.length === 0 ? (

No tags found in tasks.

) : ( tagOptions.map((tag) => { const isActive = tagFilterSet.has(tag.value) return ( ) }) )}
) } export default function TasksPage() { return (

Loading tasks...

} >
) }