From 898b162f56a4457860b954d74ba3da3cf6c89655 Mon Sep 17 00:00:00 2001 From: Brandon Brown Date: Fri, 24 Apr 2026 02:34:13 -0700 Subject: [PATCH 1/2] Add priority UI (important/urgent pills) and move domain filter to sidebar (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `important` and `urgent` booleans to the Task interface in api-types.ts - Add `setPriority()` API helper calling POST /tasks/{id}/priority - Create DomainNav sidebar component fetching domains + pending task counts, with URL param filtering (?domain_id=) - Add DomainNav to desktop sidebar in layout.tsx (below time bucket nav) - Remove domain pill row from today/page.tsx; domain filter now reads ?domain_id from URL - Add ! and ⚡ priority pills to TaskItem: hidden by default, visible on hover, active state with red/amber styling - Apply Q1 row styling (2px red left border + rgba(239,68,68,0.08) bg) when both important && urgent - Optimistic updates with rollback on priority toggle Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app/(app)/layout.tsx | 8 ++- frontend/src/app/(app)/today/page.tsx | 52 +++++------------ frontend/src/components/domain-nav.tsx | 80 ++++++++++++++++++++++++++ frontend/src/components/task-item.tsx | 65 ++++++++++++++++++++- frontend/src/lib/api-types.ts | 2 + frontend/src/lib/api.ts | 7 +++ 6 files changed, 172 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/domain-nav.tsx 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..5debc54 --- /dev/null +++ b/frontend/src/components/domain-nav.tsx @@ -0,0 +1,80 @@ +"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([]); + + useEffect(() => { + Promise.all([getDomains(), getTasks({ status: "pending" })]) + .then(([d, t]) => { + setDomains(d); + setPendingTasks(t); + }) + .catch((err) => { + console.error("DomainNav: failed to load", err); + }); + }, [pathname]); + + if (domains.length === 0) return null; + + function countForDomain(domainId: string): number { + return pendingTasks.filter((t) => t.domain?.id === domainId).length; + } + + function handleClick(domainId: string) { + const params = new URLSearchParams(searchParams.toString()); + if (activeDomainId === domainId) { + params.delete("domain_id"); + } else { + params.set("domain_id", domainId); + } + const qs = params.toString(); + router.push(`${pathname}${qs ? `?${qs}` : ""}`); + } + + return ( +
+

+ Domains +

+
+ {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..61b28cf 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,15 @@ 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 + const [important, setImportant] = useState(task.important); + const [urgent, setUrgent] = useState(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 +262,28 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP } } + async function handleToggleImportant() { + const prev = important; + setImportant(!prev); + try { + await setPriority(task.id, { important: !prev }); + } catch (err) { + console.error("Failed to set important:", err); + setImportant(prev); + } + } + + async function handleToggleUrgent() { + const prev = urgent; + setUrgent(!prev); + try { + await setPriority(task.id, { urgent: !prev }); + } catch (err) { + console.error("Failed to set urgent:", err); + setUrgent(prev); + } + } + function startNoteEdit() { setNoteDraft(task.notes ?? ""); setNoteEditing(true); @@ -313,7 +339,10 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP
+ isQ1 && !isComplete && "border-l-2 border-red-500", + )} + style={isQ1 && !isComplete ? { backgroundColor: "rgba(239,68,68,0.08)" } : undefined} + > {/* 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"); From 564754d804d1030849297aec7c307bc17f28aa4a Mon Sep 17 00:00:00 2001 From: Brandon Brown Date: Fri, 24 Apr 2026 02:47:32 -0700 Subject: [PATCH 2/2] Fix priority pill state sync, unmount guard, aria-labels, Q1 Tailwind, DomainNav All MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - task-item: sync important/urgent from props via useEffect (stale local state bug) - task-item: mountedRef guard on rollback setState to avoid unmounted-component update - task-item: call onMutate() after successful priority toggle so quadrant/list re-sorts - task-item: add aria-label to ! and ⚡ pills for screen readers - task-item: Q1 row uses Tailwind bg-red-500/[0.08] instead of inline rgba (theme-safe) - task-item: remove dead inline style overrides on pills (Tailwind classes now own it) - domain-nav: add "All" button to clear the domain filter - domain-nav: distinguish loaded-empty vs load-failed (show null only after data arrives) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/domain-nav.tsx | 23 +++++++++++++++++++--- frontend/src/components/task-item.tsx | 27 ++++++++++++++++---------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/domain-nav.tsx b/frontend/src/components/domain-nav.tsx index 5debc54..f8bead6 100644 --- a/frontend/src/components/domain-nav.tsx +++ b/frontend/src/components/domain-nav.tsx @@ -14,27 +14,30 @@ export function DomainNav() { 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 (domains.length === 0) return null; + 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) { + function handleClick(domainId: string | null) { const params = new URLSearchParams(searchParams.toString()); - if (activeDomainId === domainId) { + if (domainId === null || activeDomainId === domainId) { params.delete("domain_id"); } else { params.set("domain_id", domainId); @@ -49,6 +52,20 @@ export function DomainNav() { Domains

+ {/* All — clears filter */} + + {domains.map((d) => { const isActive = activeDomainId === d.id; const count = countForDomain(d.id); diff --git a/frontend/src/components/task-item.tsx b/frontend/src/components/task-item.tsx index 61b28cf..d7bf6e7 100644 --- a/frontend/src/components/task-item.tsx +++ b/frontend/src/components/task-item.tsx @@ -143,9 +143,16 @@ 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 + // 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; @@ -267,9 +274,10 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP setImportant(!prev); try { await setPriority(task.id, { important: !prev }); + onMutate(); } catch (err) { console.error("Failed to set important:", err); - setImportant(prev); + if (mountedRef.current) setImportant(prev); } } @@ -278,9 +286,10 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP setUrgent(!prev); try { await setPriority(task.id, { urgent: !prev }); + onMutate(); } catch (err) { console.error("Failed to set urgent:", err); - setUrgent(prev); + if (mountedRef.current) setUrgent(prev); } } @@ -339,10 +348,8 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP
+ isQ1 && !isComplete && "border-l-2 border-red-500 bg-red-500/[0.08]", + )}> {/* Complete checkbox */}