Merge branch 'feat/poweruser-capture'
# Conflicts: # apps/web/src/routes/_app/tasks.tsx
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/home/user/projects/dev/ProjectE/node_modules
|
||||
Reference in New Issue
Block a user