diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 5d5ef75..ba60413 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, Suspense } from "react"; import { usePathname, useRouter } from "next/navigation"; import { signOut } from "next-auth/react"; import type { TriageQueue, User } from "@/lib/api-types"; @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"; import { useTheme } from "@/components/theme-provider"; import { TriageModal } from "@/components/triage-modal"; import { WinddownModal } from "@/components/winddown-modal"; +import { DomainNav } from "@/components/domain-nav"; type NavIconName = "review" | "today" | "soon" | "later" | "someday" | "done" | "settings"; @@ -234,6 +235,11 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { + {/* Domain nav section */} + + + + {/* Bottom section — upgrade CTA + theme toggle + settings */}
{user && !user.is_pro && ( diff --git a/frontend/src/app/(app)/today/page.tsx b/frontend/src/app/(app)/today/page.tsx index 145f0a0..47953d8 100644 --- a/frontend/src/app/(app)/today/page.tsx +++ b/frontend/src/app/(app)/today/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useRef, Suspense } from "react"; +import { useSearchParams } from "next/navigation"; import { DndContext, closestCenter, @@ -24,15 +25,15 @@ import { SortableTaskItem } from "@/components/sortable-task-item"; import { TaskInput } from "@/components/task-input"; import type { TaskInputHandle } from "@/components/task-input"; import { useGlobalShortcut } from "@/hooks/useGlobalShortcut"; -import { cn } from "@/lib/utils"; const BUCKET: BucketType = "today"; -export default function TodayPage() { +function TodayContent() { + const searchParams = useSearchParams(); + const domainFilter = searchParams.get("domain_id"); const taskInputRef = useRef(null); const [tasks, setTasks] = useState([]); const [domains, setDomains] = useState([]); - const [domainFilter, setDomainFilter] = useState(null); const [loading, setLoading] = useState(true); useGlobalShortcut("n", useCallback(() => { @@ -207,41 +208,6 @@ export default function TodayPage() { return (
- {/* Domain filters */} - {domains.length > 0 && ( -
- - {domains.map((d) => ( - - ))} -
- )} - {/* Task input */} @@ -309,3 +275,11 @@ export default function TodayPage() {
); } + +export default function TodayPage() { + return ( + + + + ); +} diff --git a/frontend/src/components/domain-nav.tsx b/frontend/src/components/domain-nav.tsx new file mode 100644 index 0000000..f8bead6 --- /dev/null +++ b/frontend/src/components/domain-nav.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import type { Domain, Task } from "@/lib/api-types"; +import { getDomains, getTasks } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +export function DomainNav() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const activeDomainId = searchParams.get("domain_id"); + + const [domains, setDomains] = useState([]); + const [pendingTasks, setPendingTasks] = useState([]); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + Promise.all([getDomains(), getTasks({ status: "pending" })]) + .then(([d, t]) => { + setDomains(d); + setPendingTasks(t); + setLoaded(true); + }) + .catch((err) => { + console.error("DomainNav: failed to load", err); + setLoaded(true); + }); + }, [pathname]); + + if (!loaded || domains.length === 0) return null; + + function countForDomain(domainId: string): number { + return pendingTasks.filter((t) => t.domain?.id === domainId).length; + } + + function handleClick(domainId: string | null) { + const params = new URLSearchParams(searchParams.toString()); + if (domainId === null || activeDomainId === domainId) { + params.delete("domain_id"); + } else { + params.set("domain_id", domainId); + } + const qs = params.toString(); + router.push(`${pathname}${qs ? `?${qs}` : ""}`); + } + + return ( +
+

+ Domains +

+
+ {/* All — clears filter */} + + + {domains.map((d) => { + const isActive = activeDomainId === d.id; + const count = countForDomain(d.id); + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/task-item.tsx b/frontend/src/components/task-item.tsx index 73a9a56..d7bf6e7 100644 --- a/frontend/src/components/task-item.tsx +++ b/frontend/src/components/task-item.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { StickyNote } from "lucide-react"; import type { Task, Domain, SubTask } from "@/lib/api-types"; -import { completeTask, createTask, deleteTask, updateTask } from "@/lib/api"; +import { completeTask, createTask, deleteTask, updateTask, setPriority } from "@/lib/api"; import { formatAge, ageColor, cn } from "@/lib/utils"; import { DomainPicker } from "@/components/domain-picker"; @@ -143,11 +143,22 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP const [noteSaving, setNoteSaving] = useState(false); const [noteError, setNoteError] = useState(null); const noteTextareaRef = useRef(null); + // Priority state — optimistic local copies, kept in sync with incoming props + const [important, setImportant] = useState(task.important); + const [urgent, setUrgent] = useState(task.urgent); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + useEffect(() => { setImportant(task.important); }, [task.important]); + useEffect(() => { setUrgent(task.urgent); }, [task.urgent]); const isComplete = task.status === "complete"; const isSubtask = !!task.parent_id; const hasChildren = task.children.length > 0; const hasNote = !!task.notes; const completedChildren = task.children.filter((c) => c.status === "complete").length; + const isQ1 = important && urgent; useEffect(() => { if (isEditing && inputRef.current) { @@ -258,6 +269,30 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP } } + async function handleToggleImportant() { + const prev = important; + setImportant(!prev); + try { + await setPriority(task.id, { important: !prev }); + onMutate(); + } catch (err) { + console.error("Failed to set important:", err); + if (mountedRef.current) setImportant(prev); + } + } + + async function handleToggleUrgent() { + const prev = urgent; + setUrgent(!prev); + try { + await setPriority(task.id, { urgent: !prev }); + onMutate(); + } catch (err) { + console.error("Failed to set urgent:", err); + if (mountedRef.current) setUrgent(prev); + } + } + function startNoteEdit() { setNoteDraft(task.notes ?? ""); setNoteEditing(true); @@ -313,6 +348,7 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP
{/* Complete checkbox */} + + + )} + {/* Age badge */} {task.age_days > 0 && ( diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index cb9dfe4..4e2ad33 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -43,6 +43,8 @@ export interface Task { completed_at: string | null; age_days: number; is_mit: boolean; + important: boolean; + urgent: boolean; } // MIT suggestion from the backend diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index eabd320..893f62c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -96,6 +96,13 @@ export async function reorderTasks(taskIds: string[]): Promise<{ updated: number }); } +export async function setPriority( + id: string, + body: { important?: boolean; urgent?: boolean }, +): Promise { + return request(`tasks/${id}/priority`, { method: "POST", body: JSON.stringify(body) }); +} + // Triage export async function getTriageQueue(): Promise { return request("triage");