Merge branch 'feat/poweruser-capture'

# Conflicts:
#	apps/web/src/routes/_app/tasks.tsx
This commit is contained in:
2026-09-09 10:35:08 +00:00
8 changed files with 784 additions and 197 deletions
+222 -1
View File
@@ -1,3 +1,224 @@
import { useState, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { parseTaskInput } from "@/lib/nlp";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
type EntityType = "task" | "note" | "event";
const TYPE_OPTIONS: { value: EntityType; label: string }[] = [
{ value: "task", label: "Task" },
{ value: "note", label: "Note" },
{ value: "event", label: "Event" },
];
export function QuickCapture() {
return null;
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const [entityType, setEntityType] = useState<EntityType>("task");
const [isSubmitting, setIsSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "c") {
e.preventDefault();
setOpen(true);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50);
} else {
setInputValue("");
setEntityType("task");
}
}, [open]);
const parsed = entityType === "task" ? parseTaskInput(inputValue) : null;
const handleSubmit = async () => {
if (!inputValue.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
let createdId: string | null = null;
if (entityType === "task") {
const p = parseTaskInput(inputValue);
const data: any = {
title: p.title,
priority: p.priority || "medium",
};
if (p.dueDate) data.dueDate = p.dueDate;
if (p.tags.length) data.tagNames = p.tags;
if (activeDomainId) data.domain = activeDomainId;
const res = await api.post<{ id: string }>("/tasks", data);
createdId = res.id;
toast.success("Task created", {
action: {
label: "Undo",
onClick: async () => {
try {
await api.delete("/tasks/" + createdId);
queryClient.invalidateQueries({ queryKey: ["tasks"] });
toast.success("Task deleted");
} catch {
toast.error("Failed to undo");
}
},
},
});
queryClient.invalidateQueries({ queryKey: ["tasks"] });
} else if (entityType === "note") {
const res = await api.post<{ id: string }>("/notes", {
title: inputValue.trim(),
...(activeDomainId ? { domain: activeDomainId } : {}),
});
createdId = res.id;
toast.success("Note created", {
action: {
label: "Undo",
onClick: async () => {
try {
await api.delete("/notes/" + createdId);
queryClient.invalidateQueries({ queryKey: ["notes"] });
toast.success("Note deleted");
} catch {
toast.error("Failed to undo");
}
},
},
});
queryClient.invalidateQueries({ queryKey: ["notes"] });
} else {
const now = new Date();
const end = new Date(now.getTime() + 60 * 60 * 1000);
const res = await api.post<{ id: string }>("/calendar/events", {
title: inputValue.trim(),
startTime: now.toISOString(),
endTime: end.toISOString(),
...(activeDomainId ? { domain: activeDomainId } : {}),
});
createdId = res.id;
toast.success("Event created", {
action: {
label: "Undo",
onClick: async () => {
try {
await api.delete("/calendar/events/" + createdId);
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
toast.success("Event deleted");
} catch {
toast.error("Failed to undo");
}
},
},
});
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
}
setInputValue("");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Quick Capture</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-1">
{TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setEntityType(opt.value)}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
entityType === opt.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
>
{opt.label}
</button>
))}
</div>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
placeholder={
entityType === "task"
? 'Try "Buy milk tomorrow 5pm #groceries p1"'
: entityType === "note"
? "Note title..."
: "Event title..."
}
disabled={isSubmitting}
/>
{entityType === "task" && parsed && (parsed.dueDate || parsed.priority || parsed.tags.length > 0) && (
<div className="flex flex-wrap gap-1">
{parsed.dueDate && (
<Badge variant="outline" className="text-[10px]">
Due {new Date(parsed.dueDate).toLocaleDateString()}{" "}
{new Date(parsed.dueDate).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Badge>
)}
{parsed.priority && (
<Badge variant="secondary" className="text-[10px]">
Priority {parsed.priority}
</Badge>
)}
{parsed.tags.map((t) => (
<Badge key={t} variant="outline" className="text-[10px]">
#{t}
</Badge>
))}
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleSubmit}
disabled={!inputValue.trim() || isSubmitting}
size="sm"
>
{isSubmitting ? "Creating..." : "Create"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+51 -1
View File
@@ -1,3 +1,53 @@
import { useState, useEffect } from "react";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { Wifi, WifiOff, ChevronUp, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export function StatusBar() {
return null;
const [collapsed, setCollapsed] = useState(false);
const [sseStatus, setSseStatus] = useState<"connected" | "reconnecting" | "disconnected">("disconnected");
const activeDomainId = useApiDomain();
useEffect(() => {
const es = new EventSource("/api/realtime");
const check = () => {
if (es.readyState === EventSource.OPEN) setSseStatus("connected");
else if (es.readyState === EventSource.CONNECTING) setSseStatus("reconnecting");
else setSseStatus("disconnected");
};
es.onopen = () => setSseStatus("connected");
es.onerror = () => check();
const interval = setInterval(check, 5000);
return () => {
es.close();
clearInterval(interval);
};
}, []);
return (
<div className="border-t bg-muted/30 px-3 py-1 text-[10px] text-muted-foreground flex items-center gap-4 shrink-0">
<span className="flex items-center gap-1">
{sseStatus === "connected" ? (
<Wifi className="h-3 w-3 text-green-500" />
) : (
<WifiOff className="h-3 w-3 text-amber-500" />
)}
{sseStatus}
</span>
{activeDomainId && <span>Domain: {activeDomainId.slice(0, 8)}</span>}
<span className="ml-auto">
<kbd className="px-1 py-0.5 rounded border bg-muted font-mono">K</kbd>
</span>
<button
onClick={() => setCollapsed(!collapsed)}
className="hover:text-foreground"
>
{collapsed ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { toast } from "sonner";
import { api } from "@/lib/api";
export function showUndoToast(
entityType: string,
entityId: string,
entityData: Record<string, any>,
queryClient: { invalidateQueries: (opts: { queryKey: string[] }) => void },
queryKey: string[],
) {
toast.success(`${entityType} deleted`, {
action: {
label: "Undo",
onClick: async () => {
try {
const endpoint =
entityType === "task"
? "/tasks"
: entityType === "note"
? "/notes"
: entityType === "habit"
? "/habits"
: "/calendar/events";
await api.post(endpoint, entityData);
queryClient.invalidateQueries({ queryKey });
toast.success(`${entityType} restored`);
} catch {
toast.error("Failed to restore");
}
},
},
});
}
+22 -1
View File
@@ -20,6 +20,7 @@ import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { showUndoToast } from "@/lib/undo/use-undo-toast";
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
@@ -163,7 +164,27 @@ function HabitsPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/habits/" + id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); },
onSuccess: (_, deletedId) => {
const habit = habits.find((h) => h.id === deletedId) || selectedHabit;
if (habit) {
showUndoToast(
"habit",
deletedId,
{
name: habit.name,
description: habit.description,
frequency: habit.frequency,
difficulty: habit.difficulty,
goalPerPeriod: habit.goalPerPeriod,
domain: habit.domainId || undefined,
},
queryClient,
["habits"],
);
}
queryClient.invalidateQueries({ queryKey: ["habits"] });
setPanelOpen(false);
},
});
const openHabitDetail = (habit: Habit) => {
+426 -191
View File
@@ -1,12 +1,23 @@
import { useMemo } from "react";
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { Inbox as InboxIcon, AlertTriangle, CalendarClock, Flame, FileText, Check, ChevronRight } from "lucide-react";
import { parseTaskInput } from "@/lib/nlp";
import { toast } from "sonner";
import {
Inbox as InboxIcon,
AlertTriangle,
CalendarClock,
Flame,
FileText,
Check,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { LoadingState, EmptyState } from "@/components/state";
@@ -14,32 +25,54 @@ import { cn } from "@/lib/utils";
import type { Task, Habit, Note, PaginatedResponse } from "@/lib/types";
import { format, isToday, isPast, parseISO } from "date-fns";
// ─── Flat item type for keyboard navigation ──────────────────────────────
interface FlatItem {
kind: "task-overdue" | "task-today" | "habit" | "note";
id: string;
title: string;
route: string;
}
function InboxPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
// ─── Quick capture state ───────────────────────────────────────────────
const [quickInput, setQuickInput] = useState("");
const quickInputRef = useRef<HTMLInputElement>(null);
const isInputFocused = useRef(false);
useRealtime({ enabled: true });
// ─── Queries ──────────────────────────────────────────────────────────
const { data: tasksData, isLoading: tasksLoading, error: tasksError, refetch: refetchTasks } =
useApiQuery<PaginatedResponse<Task>>(
["tasks-inbox", activeDomainId],
"/tasks?limit=200&status=todo,in_progress&sort=due_date" + domainSuffix,
);
const {
data: tasksData,
isLoading: tasksLoading,
error: tasksError,
} = useApiQuery<PaginatedResponse<Task>>(
["tasks-inbox", activeDomainId],
"/tasks?limit=200&status=todo,in_progress&sort=due_date" + domainSuffix,
);
const { data: habitsData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } =
useApiQuery<PaginatedResponse<Habit>>(
["habits-inbox", activeDomainId],
"/habits?limit=50" + domainSuffix,
);
const {
data: habitsData,
isLoading: habitsLoading,
error: habitsError,
} = useApiQuery<PaginatedResponse<Habit>>(
["habits-inbox", activeDomainId],
"/habits?limit=50" + domainSuffix,
);
const { data: notesData, isLoading: notesLoading, error: notesError, refetch: refetchNotes } =
useApiQuery<PaginatedResponse<Note>>(
["notes-inbox", activeDomainId],
"/notes?limit=5&sort=-updated" + domainSuffix,
);
const {
data: notesData,
isLoading: notesLoading,
error: notesError,
} = useApiQuery<PaginatedResponse<Note>>(
["notes-inbox", activeDomainId],
"/notes?limit=5&sort=-updated" + domainSuffix,
);
// ─── Filtering ────────────────────────────────────────────────────────
const tasks = tasksData?.items || [];
@@ -63,6 +96,108 @@ function InboxPage() {
const habits = habitsData?.items || [];
const notes = notesData?.items || [];
// ─── Flat list for keyboard nav ───────────────────────────────────────
const allItems = useMemo<FlatItem[]>(() => {
const items: FlatItem[] = [];
for (const t of overdueTasks) {
items.push({ kind: "task-overdue", id: t.id, title: t.title, route: `/tasks/${t.id}` });
}
for (const t of dueTodayTasks) {
items.push({ kind: "task-today", id: t.id, title: t.title, route: `/tasks/${t.id}` });
}
for (const h of habits) {
items.push({ kind: "habit", id: h.id, title: h.name, route: `/habits/${h.id}` });
}
for (const n of notes) {
items.push({ kind: "note", id: n.id, title: n.title, route: `/notes/${n.id}` });
}
return items;
}, [overdueTasks, dueTodayTasks, habits, notes]);
const [selectedRowIndex, setSelectedRowIndex] = useState(-1);
const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
// ─── Keyboard navigation ──────────────────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Skip if user is typing in an input/textarea
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
if (e.key === "j" || e.key === "J") {
e.preventDefault();
setSelectedRowIndex((prev) => Math.min(prev + 1, allItems.length - 1));
} else if (e.key === "k" || e.key === "K") {
e.preventDefault();
setSelectedRowIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter" && selectedRowIndex >= 0 && selectedRowIndex < allItems.length) {
e.preventDefault();
navigate({ to: allItems[selectedRowIndex].route });
} else if (e.key === "Escape") {
setSelectedRowIndex(-1);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [allItems, selectedRowIndex, navigate]);
// Scroll selected row into view
useEffect(() => {
if (selectedRowIndex >= 0) {
const el = rowRefs.current.get(selectedRowIndex);
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedRowIndex]);
// ─── Input-focus guard ────────────────────────────────────────────────
useEffect(() => {
const onFocus = () => {
const tag = (document.activeElement as HTMLElement)?.tagName;
isInputFocused.current = tag === "INPUT" || tag === "TEXTAREA";
};
const onBlur = () => {
// Small delay so the new focus target is set first
setTimeout(() => {
const tag = (document.activeElement as HTMLElement)?.tagName;
isInputFocused.current = tag === "INPUT" || tag === "TEXTAREA";
}, 0);
};
document.addEventListener("focusin", onFocus);
document.addEventListener("focusout", onBlur);
return () => {
document.removeEventListener("focusin", onFocus);
document.removeEventListener("focusout", onBlur);
};
}, []);
// ─── Quick capture ────────────────────────────────────────────────────
const createTaskMutation = useMutation({
mutationFn: (data: any) => api.post("/tasks", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks-inbox"] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const handleQuickCapture = () => {
if (!quickInput.trim()) return;
const parsed = parseTaskInput(quickInput);
const data: Record<string, any> = {
title: parsed.title,
priority: parsed.priority || "medium",
};
if (parsed.dueDate) data.dueDate = parsed.dueDate;
if (parsed.tags.length) data.tagNames = parsed.tags;
if (activeDomainId) data.domain = activeDomainId;
createTaskMutation.mutate(data, {
onSuccess: () => {
toast.success("Task created");
setQuickInput("");
},
onError: () => toast.error("Failed to create task"),
});
};
// ─── Mutations ────────────────────────────────────────────────────────
const completeTaskMutation = useMutation({
mutationFn: (taskId: string) => api.patch<Task>("/tasks/" + taskId, { status: "done" }),
@@ -84,11 +219,19 @@ function InboxPage() {
<h1 className="text-xl font-bold flex items-center gap-2">
<InboxIcon className="h-5 w-5" /> Inbox
</h1>
<EmptyState icon={AlertTriangle} title="Failed to load inbox" description="Check your connection and try again." />
<EmptyState
icon={AlertTriangle}
title="Failed to load inbox"
description="Check your connection and try again."
/>
</div>
);
}
// Helper: get flat index for an item
const flatIndex = (kind: FlatItem["kind"], id: string) =>
allItems.findIndex((i) => i.kind === kind && i.id === id);
return (
<div className="space-y-4">
{/* Page header */}
@@ -96,125 +239,176 @@ function InboxPage() {
<InboxIcon className="h-5 w-5" /> Inbox
</h1>
{/* Quick capture input */}
<div className="flex gap-2">
<Input
ref={quickInputRef}
value={quickInput}
onChange={(e) => setQuickInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleQuickCapture();
}
}}
placeholder='Quick task — e.g. "Call dentist tomorrow #health"'
className="flex-1"
/>
<Button
size="sm"
disabled={!quickInput.trim() || createTaskMutation.isPending}
onClick={handleQuickCapture}
>
Add
</Button>
</div>
{/* Keyboard hint */}
<p className="text-[10px] text-muted-foreground -mt-2">
Press <kbd className="px-1 py-0.5 rounded bg-muted text-[10px] font-mono">j</kbd>/
<kbd className="px-1 py-0.5 rounded bg-muted text-[10px] font-mono">k</kbd> to
navigate, <kbd className="px-1 py-0.5 rounded bg-muted text-[10px] font-mono">Enter</kbd> to
open
</p>
{/* Overdue tasks */}
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-destructive" />
<h2 className="text-sm font-semibold text-destructive">Overdue</h2>
{overdueTasks.length > 0 && (
<Badge variant="destructive" className="font-mono text-[10px] px-1.5">{overdueTasks.length}</Badge>
)}
</div>
{overdueTasks.length === 0 ? (
<div className="py-6 text-center">
<AlertTriangle className="h-6 w-6 mx-auto mb-1 text-green-500" />
<p className="text-xs text-muted-foreground">No overdue tasks</p>
{overdueTasks.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-destructive" />
<h2 className="text-sm font-semibold text-destructive">Overdue</h2>
<Badge variant="destructive" className="font-mono text-[10px] px-1.5">
{overdueTasks.length}
</Badge>
</div>
) : (
<div className="space-y-1">
{overdueTasks.map((task) => (
<Card key={task.id} className="border border-destructive/20 hover:bg-muted/20 transition-colors">
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={completeTaskMutation.isPending}
onClick={() => completeTaskMutation.mutate(task.id)}
aria-label={"Complete " + task.title}
>
<Check className="h-3.5 w-3.5" />
</Button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
>
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-destructive">
{format(parseISO(task.dueDate!), "MMM d")}
</span>
<Badge variant="secondary" className="font-mono text-[10px]">
{task.priority}
</Badge>
{overdueTasks.map((task) => {
const idx = flatIndex("task-overdue", task.id);
return (
<div
key={task.id}
ref={(el) => {
if (el) rowRefs.current.set(idx, el);
}}
>
<Card
className={cn(
"border border-destructive/20 hover:bg-muted/20 transition-colors",
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
)}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={completeTaskMutation.isPending}
onClick={() => completeTaskMutation.mutate(task.id)}
aria-label={"Complete " + task.title}
>
<Check className="h-3.5 w-3.5" />
</Button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() =>
navigate({ to: "/tasks/$id", params: { id: task.id } })
}
>
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-destructive">
{format(parseISO(task.dueDate!), "MMM d")}
</span>
<Badge variant="secondary" className="font-mono text-[10px]">
{task.priority}
</Badge>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</section>
</section>
)}
{/* Due today tasks */}
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<h2 className="text-sm font-semibold text-amber-500">Due Today</h2>
{dueTodayTasks.length > 0 && (
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">{dueTodayTasks.length}</Badge>
)}
</div>
{dueTodayTasks.length === 0 ? (
<div className="py-6 text-center">
<CalendarClock className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">No tasks due today</p>
{dueTodayTasks.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<h2 className="text-sm font-semibold text-amber-500">Due Today</h2>
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">
{dueTodayTasks.length}
</Badge>
</div>
) : (
<div className="space-y-1">
{dueTodayTasks.map((task) => (
<Card key={task.id} className="border border-amber-500/20 hover:bg-muted/20 transition-colors">
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={completeTaskMutation.isPending}
onClick={() => completeTaskMutation.mutate(task.id)}
aria-label={"Complete " + task.title}
>
<Check className="h-3.5 w-3.5" />
</Button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
>
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-amber-500">Today</span>
<Badge variant="secondary" className="font-mono text-[10px]">
{task.priority}
</Badge>
{dueTodayTasks.map((task) => {
const idx = flatIndex("task-today", task.id);
return (
<div
key={task.id}
ref={(el) => {
if (el) rowRefs.current.set(idx, el);
}}
>
<Card
className={cn(
"border border-amber-500/20 hover:bg-muted/20 transition-colors",
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
)}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={completeTaskMutation.isPending}
onClick={() => completeTaskMutation.mutate(task.id)}
aria-label={"Complete " + task.title}
>
<Check className="h-3.5 w-3.5" />
</Button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() =>
navigate({ to: "/tasks/$id", params: { id: task.id } })
}
>
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-amber-500">Today</span>
<Badge variant="secondary" className="font-mono text-[10px]">
{task.priority}
</Badge>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</section>
</section>
)}
{/* Habits */}
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-orange-500" />
<h2 className="text-sm font-semibold text-orange-500">Habits</h2>
{habits.length > 0 && (
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">{habits.length}</Badge>
)}
</div>
{habits.length === 0 ? (
<div className="py-6 text-center">
<Flame className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">No habits yet</p>
{habits.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-orange-500" />
<h2 className="text-sm font-semibold text-orange-500">Habits</h2>
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">
{habits.length}
</Badge>
</div>
) : (
<div className="space-y-1">
{habits.map((habit) => {
const doneToday = (habit.recentCompletions || []).some((c) => {
@@ -225,86 +419,127 @@ function InboxPage() {
d.getUTCDate() === today.getUTCDate()
);
});
const idx = flatIndex("habit", habit.id);
return (
<Card key={habit.id} className="hover:bg-muted/20 transition-colors">
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<button
className={cn(
"w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors",
doneToday
? "bg-green-500 border-green-500"
: "border-muted-foreground/30 hover:border-primary",
)}
disabled={completeHabitMutation.isPending}
onClick={() => completeHabitMutation.mutate(habit.id)}
aria-label={doneToday ? habit.name + " (completed)" : "Complete " + habit.name}
>
{doneToday && <Check className="h-3 w-3 text-white" />}
</button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => navigate({ to: "/habits/$id", params: { id: habit.id } })}
>
<p className={cn("text-sm font-medium truncate", doneToday && "line-through text-muted-foreground")}>
{habit.name}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-muted-foreground">{habit.frequency}</span>
{habit.streakCount > 0 && (
<Badge variant="secondary" className="font-mono text-[10px] px-1">
<Flame className="h-2.5 w-2.5 mr-0.5 text-orange-500" />
{habit.streakCount}
</Badge>
<div
key={habit.id}
ref={(el) => {
if (el) rowRefs.current.set(idx, el);
}}
>
<Card
className={cn(
"hover:bg-muted/20 transition-colors",
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
)}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<button
className={cn(
"w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors",
doneToday
? "bg-green-500 border-green-500"
: "border-muted-foreground/30 hover:border-primary",
)}
disabled={completeHabitMutation.isPending}
onClick={() => completeHabitMutation.mutate(habit.id)}
aria-label={
doneToday ? habit.name + " (completed)" : "Complete " + habit.name
}
>
{doneToday && <Check className="h-3 w-3 text-white" />}
</button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() =>
navigate({ to: "/habits/$id", params: { id: habit.id } })
}
>
<p
className={cn(
"text-sm font-medium truncate",
doneToday && "line-through text-muted-foreground",
)}
>
{habit.name}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-muted-foreground">
{habit.frequency}
</span>
{habit.streakCount > 0 && (
<Badge variant="secondary" className="font-mono text-[10px] px-1">
<Flame className="h-2.5 w-2.5 mr-0.5 text-orange-500" />
{habit.streakCount}
</Badge>
)}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</section>
</section>
)}
{/* Recent notes */}
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<h2 className="text-sm font-semibold text-blue-500">Recent Notes</h2>
</div>
{notes.length === 0 ? (
<div className="py-6 text-center">
<FileText className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">No notes yet</p>
{notes.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<h2 className="text-sm font-semibold text-blue-500">Recent Notes</h2>
</div>
) : (
<div className="space-y-1">
{notes.map((note) => (
<Card
key={note.id}
className="hover:bg-muted/20 transition-colors cursor-pointer"
onClick={() => navigate({ to: "/notes/$id", params: { id: note.id } })}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{note.title}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{format(parseISO(note.updatedAt), "MMM d, HH:mm")}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
{notes.map((note) => {
const idx = flatIndex("note", note.id);
return (
<div
key={note.id}
ref={(el) => {
if (el) rowRefs.current.set(idx, el);
}}
>
<Card
className={cn(
"hover:bg-muted/20 transition-colors cursor-pointer",
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
)}
onClick={() =>
navigate({ to: "/notes/$id", params: { id: note.id } })
}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{note.title}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{format(parseISO(note.updatedAt), "MMM d, HH:mm")}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</section>
</section>
)}
{/* Empty state */}
{allItems.length === 0 && !isLoading && (
<div className="py-12 text-center">
<InboxIcon className="h-10 w-10 mx-auto mb-2 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Inbox is clear</p>
</div>
)}
</div>
);
}
+12 -1
View File
@@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { showUndoToast } from "@/lib/undo/use-undo-toast";
import type { Note, PaginatedResponse } from "@/lib/types";
import { format, parseISO } from "date-fns";
@@ -218,7 +219,17 @@ function NotesPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/notes/" + id),
onSuccess: () => {
onSuccess: (_, deletedId) => {
const note = notes.find((n) => n.id === deletedId) || selectedNoteRef.current;
if (note) {
showUndoToast(
"note",
deletedId,
{ title: note.title, content: note.content, domain: note.domainId || undefined },
queryClient,
["notes"],
);
}
queryClient.invalidateQueries({ queryKey: ["notes"] });
selectedNoteRef.current = null;
setSelectedNoteId(null);
+18 -1
View File
@@ -33,6 +33,7 @@ import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
import { BulkActionBar } from "@/components/tasks/bulk-action-bar";
import { showUndoToast } from "@/lib/undo/use-undo-toast";
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
{ id: "backlog", label: "Backlog", colorClass: "bg-slate-400" },
@@ -514,7 +515,23 @@ function TasksPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/tasks/" + id),
onSuccess: () => {
onSuccess: (_, deletedId) => {
const task = tasks.find((t) => t.id === deletedId);
if (task) {
showUndoToast(
"task",
deletedId,
{
title: task.title,
priority: task.priority,
dueDate: task.dueDate,
description: task.description,
domain: task.domainId || undefined,
},
queryClient,
["tasks"],
);
}
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setPanelOpen(false);
},