Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion frontend/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -234,6 +235,11 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
</button>
</div>

{/* Domain nav section */}
<Suspense>
<DomainNav />
</Suspense>

{/* Bottom section — upgrade CTA + theme toggle + settings */}
<div className="mt-auto mx-3 pt-3 pb-4 border-t border-border/50 flex flex-col gap-0.5">
{user && !user.is_pro && (
Expand Down
52 changes: 13 additions & 39 deletions frontend/src/app/(app)/today/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<TaskInputHandle>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [domains, setDomains] = useState<Domain[]>([]);
const [domainFilter, setDomainFilter] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

useGlobalShortcut("n", useCallback(() => {
Expand Down Expand Up @@ -207,41 +208,6 @@ export default function TodayPage() {

return (
<div className="flex flex-col min-h-screen max-w-lg mx-auto px-4 py-6 gap-4">
{/* Domain filters */}
{domains.length > 0 && (
<div className="flex items-center gap-2">
<button
onClick={() => setDomainFilter(null)}
className={cn(
"text-xs px-2.5 py-1 rounded-full border transition-colors",
domainFilter === null
? "border-text-secondary text-text-primary"
: "border-border text-text-muted hover:border-text-muted",
)}
>
All
</button>
{domains.map((d) => (
<button
key={d.id}
onClick={() => setDomainFilter(domainFilter === d.id ? null : d.id)}
className={cn(
"flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-colors",
domainFilter === d.id
? "border-text-secondary text-text-primary"
: "border-border text-text-muted hover:border-text-muted",
)}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: d.color }}
/>
{d.name}
</button>
))}
</div>
)}

{/* Task input */}
<TaskInput ref={taskInputRef} bucket={BUCKET} domains={domains} onCreated={refresh} />

Expand Down Expand Up @@ -309,3 +275,11 @@ export default function TodayPage() {
</div>
);
}

export default function TodayPage() {
return (
<Suspense>
<TodayContent />
</Suspense>
);
}
97 changes: 97 additions & 0 deletions frontend/src/components/domain-nav.tsx
Original file line number Diff line number Diff line change
@@ -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<Domain[]>([]);
const [pendingTasks, setPendingTasks] = useState<Task[]>([]);
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 (
<div className="mt-4 px-3">
<p className="text-[10px] font-medium uppercase tracking-widest text-text-muted px-3 mb-1">
Domains
</p>
<div className="flex flex-col gap-0.5">
{/* All — clears filter */}
<button
onClick={() => handleClick(null)}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-colors duration-150 w-full",
!activeDomainId
? "bg-bg-hover text-text-primary font-medium"
: "text-text-muted hover:text-text-secondary hover:bg-bg-hover/50",
)}
>
<span className="h-2.5 w-2.5 rounded-full shrink-0 bg-text-muted/30" />
<span className="flex-1 text-left">All</span>
</button>

{domains.map((d) => {
const isActive = activeDomainId === d.id;
const count = countForDomain(d.id);
return (
<button
key={d.id}
onClick={() => handleClick(d.id)}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-colors duration-150 w-full",
isActive
? "bg-bg-hover text-text-primary font-medium"
: "text-text-muted hover:text-text-secondary hover:bg-bg-hover/50",
)}
>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: d.color }}
/>
<span className="flex-1 text-left truncate">{d.name}</span>
{count > 0 && (
<span className="text-[11px] text-text-muted tabular-nums">{count}</span>
)}
</button>
);
})}
</div>
</div>
);
}
70 changes: 69 additions & 1 deletion frontend/src/components/task-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -143,11 +143,22 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP
const [noteSaving, setNoteSaving] = useState(false);
const [noteError, setNoteError] = useState<string | null>(null);
const noteTextareaRef = useRef<HTMLTextAreaElement>(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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -313,6 +348,7 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP
<div className={cn(
"flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-bg-hover transition-colors",
isMIT && "bg-accent-blue/8 border border-accent-blue/20",
isQ1 && !isComplete && "border-l-2 border-red-500 bg-red-500/[0.08]",
)}>
{/* Complete checkbox */}
<button
Expand Down Expand Up @@ -459,6 +495,38 @@ export function TaskItem({ task, domains, onMutate, isMIT, onSetMIT }: TaskItemP
</span>
)}

{/* Priority pills — shown on hover, hidden on completed tasks */}
{!isComplete && (
<>
<button
onClick={handleToggleImportant}
aria-label={important ? "Remove important" : "Mark as important"}
title={important ? "Remove important" : "Mark as important"}
className={cn(
"shrink-0 font-mono text-[10px] px-1.5 py-0.5 rounded border transition-all duration-150",
important
? "opacity-100 border-red-500/35 bg-red-500/8 text-red-300"
: "opacity-0 group-hover:opacity-100 border-neutral-800 text-neutral-500 hover:text-neutral-400",
)}
>
!
</button>
<button
onClick={handleToggleUrgent}
aria-label={urgent ? "Remove urgent" : "Mark as urgent"}
title={urgent ? "Remove urgent" : "Mark as urgent"}
className={cn(
"shrink-0 text-[10px] px-1.5 py-0.5 rounded border transition-all duration-150",
urgent
? "opacity-100 border-amber-500/35 bg-amber-500/8 text-amber-300"
: "opacity-0 group-hover:opacity-100 border-neutral-800 text-neutral-500 hover:text-neutral-400",
)}
>
</button>
</>
)}

{/* Age badge */}
{task.age_days > 0 && (
<span className={cn("text-xs shrink-0", ageColor(task.age_days, task.bucket))}>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task> {
return request<Task>(`tasks/${id}/priority`, { method: "POST", body: JSON.stringify(body) });
}

// Triage
export async function getTriageQueue(): Promise<TriageQueue> {
return request<TriageQueue>("triage");
Expand Down