diff --git a/README.md b/README.md index 2c3f0ca..5106936 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi - Added a dedicated `/sprints` page (previously missing, caused route 404). - Added sprint management UI on `/sprints` to view all sprints and perform create/edit/delete actions. +- Added a dedicated `/tasks` list page with URL-based filtering and scope controls. - Removed sprint-to-project coupling across app/API/CLI. Tasks remain project-scoped; sprints are now global. - Added DB migration script to drop `sprints.project_id`: `supabase/remove_sprint_project_id.sql`. @@ -162,6 +163,17 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi - Each task now has a shareable deep link. - Task detail includes explicit Project and Sprint dropdowns. +### Task list route + +- Added a dedicated task list route at `/tasks`. +- Supported query params: + - `scope=active-sprint|all` (default: `all`) + - `status=` (for example `in-progress,review`) + - `priority=` (for example `urgent,high`) +- Unknown status/priority tokens are ignored. +- Filtering is client-side against the synced task dataset. +- Results are sorted by `updatedAt` descending. + ### Attachments - Task detail page supports adding multiple attachments per task. diff --git a/src/app/sprints/[id]/page.tsx b/src/app/sprints/[id]/page.tsx new file mode 100644 index 0000000..409714d --- /dev/null +++ b/src/app/sprints/[id]/page.tsx @@ -0,0 +1,329 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import { ArrowLeft, Calendar, CheckCircle2 } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { parseSprintEnd, parseSprintStart } from "@/lib/utils" +import { useTaskStore, type SprintStatus, type Task, type TaskStatus } from "@/stores/useTaskStore" + +const SPRINT_STATUS_LABELS: Record = { + planning: "Planning", + active: "Active", + completed: "Completed", +} + +const SPRINT_STATUS_BADGE_CLASSES: Record = { + planning: "bg-amber-500/10 text-amber-300 border-amber-500/30", + active: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30", + completed: "bg-slate-500/10 text-slate-300 border-slate-500/30", +} + +const TASK_STATUS_LABELS: Record = { + open: "Open", + todo: "To Do", + blocked: "Blocked", + "in-progress": "In Progress", + review: "Review", + validate: "Validate", + archived: "Archived", + canceled: "Canceled", + done: "Done", +} + +function formatDateRange(startDate: string, endDate: string): string { + const start = parseSprintStart(startDate) + const end = parseSprintEnd(endDate) + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return "Invalid date range" + } + return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}` +} + +function toTimestamp(value: string): number { + const timestamp = new Date(value).getTime() + return Number.isNaN(timestamp) ? 0 : timestamp +} + +function TaskSection({ + title, + emptyState, + tasks, + onTaskClick, +}: { + title: string + emptyState: string + tasks: Task[] + onTaskClick: (taskId: string) => void +}) { + return ( + + +
+

{title}

+ + {tasks.length} + +
+
+ + {tasks.length === 0 ? ( +

{emptyState}

+ ) : ( +
+ {tasks.map((task) => ( + + ))} +
+ )} +
+
+ ) +} + +export default function SprintDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const routeSprintId = Array.isArray(params.id) ? params.id[0] : params.id + const sprintId = decodeURIComponent(routeSprintId || "") + + const { sprints, tasks, syncFromServer, selectSprint, syncError } = useTaskStore() + const [authReady, setAuthReady] = useState(false) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + let isMounted = true + const loadSession = async () => { + try { + const res = await fetch("/api/auth/session", { cache: "no-store" }) + if (!res.ok) { + if (isMounted) router.replace("/login") + return + } + if (isMounted) setAuthReady(true) + } catch { + if (isMounted) router.replace("/login") + } + } + + void loadSession() + return () => { + isMounted = false + } + }, [router]) + + useEffect(() => { + if (!authReady) return + + let active = true + const loadData = async () => { + setIsLoading(true) + await syncFromServer({ scope: "all" }) + if (active) setIsLoading(false) + } + + void loadData() + return () => { + active = false + } + }, [authReady, syncFromServer]) + + const sprint = useMemo( + () => sprints.find((candidate) => candidate.id === sprintId) || null, + [sprints, sprintId] + ) + + const sprintTasks = useMemo( + () => + tasks + .filter((task) => task.sprintId === sprintId) + .sort((a, b) => toTimestamp(b.updatedAt) - toTimestamp(a.updatedAt)), + [tasks, sprintId] + ) + + const completedTasks = useMemo( + () => sprintTasks.filter((task) => task.status === "done" || task.status === "archived"), + [sprintTasks] + ) + const canceledTasks = useMemo( + () => sprintTasks.filter((task) => task.status === "canceled"), + [sprintTasks] + ) + const activeTasks = useMemo( + () => + sprintTasks.filter( + (task) => task.status !== "done" && task.status !== "archived" && task.status !== "canceled" + ), + [sprintTasks] + ) + + const completionPercent = + sprintTasks.length > 0 ? Math.round((completedTasks.length / sprintTasks.length) * 100) : 0 + + const openOnBoard = () => { + if (!sprint) return + selectSprint(sprint.id) + router.push("/") + } + + const openTask = (taskId: string) => { + router.push(`/tasks/${encodeURIComponent(taskId)}`) + } + + if (!authReady) { + return ( +
+

Checking session...

+
+ ) + } + + return ( +
+
+
+
+
+ +
+

+ Sprint Details +

+

+ Sprint deep link: {sprintId || "Unknown sprint"} +

+
+
+ +
+
+
+ +
+ {syncError && ( +
+ Sync error: {syncError} +
+ )} + + {isLoading ? ( +
+ Loading sprint... +
+ ) : !sprint ? ( + + +

Sprint not found

+

+ No sprint was found for ID: {sprintId || "unknown"} +

+ +
+
+ ) : ( + <> + + +
+
+

{sprint.name}

+ {sprint.goal &&

{sprint.goal}

} +
+ + {SPRINT_STATUS_LABELS[sprint.status]} + +
+
+ +
+ + {formatDateRange(sprint.startDate, sprint.endDate)} +
+ +
+
+

Total Tasks

+

{sprintTasks.length}

+
+
+

Completed

+

{completedTasks.length}

+
+
+

Completion

+

{completionPercent}%

+
+
+ +
+
+ Progress + {completedTasks.length}/{sprintTasks.length} +
+
+
+
+
+ + + +
+ + + +
+ + )} +
+
+ ) +} diff --git a/src/app/sprints/page.tsx b/src/app/sprints/page.tsx index 11732bd..44eec55 100644 --- a/src/app/sprints/page.tsx +++ b/src/app/sprints/page.tsx @@ -66,7 +66,6 @@ export default function SprintsPage() { const { tasks, sprints, - selectSprint, syncFromServer, syncError, } = useTaskStore() @@ -240,9 +239,8 @@ export default function SprintsPage() { } } - const openOnBoard = (sprintId: string) => { - selectSprint(sprintId) - router.push("/") + const openSprintDetails = (sprintId: string) => { + router.push(`/sprints/${encodeURIComponent(sprintId)}`) } if (!authReady) { @@ -347,11 +345,17 @@ export default function SprintsPage() { const completion = counts.total > 0 ? Math.round((counts.done / counts.total) * 100) : 0 return ( - + openSprintDetails(sprint.id)} + >
-

{sprint.name}

+

+ {sprint.name} +

{STATUS_LABELS[sprint.status]} @@ -384,7 +388,10 @@ export default function SprintsPage() { size="sm" variant="outline" className="border-slate-700 text-slate-200 hover:bg-slate-800" - onClick={() => openOnBoard(sprint.id)} + onClick={(event) => { + event.stopPropagation() + openSprintDetails(sprint.id) + }} > Open @@ -393,7 +400,10 @@ export default function SprintsPage() { size="sm" variant="outline" className="border-slate-700 text-slate-200 hover:bg-slate-800" - onClick={() => openEditDialog(sprint)} + onClick={(event) => { + event.stopPropagation() + openEditDialog(sprint) + }} > Edit @@ -402,7 +412,10 @@ export default function SprintsPage() { size="sm" variant="outline" className="border-red-500/40 text-red-300 hover:bg-red-500/10" - onClick={() => setDeleteConfirmId(sprint.id)} + onClick={(event) => { + event.stopPropagation() + setDeleteConfirmId(sprint.id) + }} > Delete diff --git a/src/app/tasks/page.tsx b/src/app/tasks/page.tsx new file mode 100644 index 0000000..dbe07a2 --- /dev/null +++ b/src/app/tasks/page.tsx @@ -0,0 +1,1015 @@ +"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...

+
+ } + > + + + ) +}