feat: toast notifications, delete task, due date badges on cards, keyboard shortcuts

This commit is contained in:
2026-01-29 07:33:05 +00:00
parent e874cafbec
commit 578b092a78
6 changed files with 234 additions and 8 deletions

View File

@@ -7,6 +7,7 @@ import { ProjectsPage } from "./pages/ProjectsPage";
import { TaskPage } from "./pages/TaskPage";
import { AdminPage } from "./components/AdminPage";
import { LoginPage } from "./components/LoginPage";
import { ToastProvider } from "./components/Toast";
import { useSession } from "./lib/auth-client";
function AuthenticatedApp() {
@@ -42,7 +43,11 @@ function App() {
return <LoginPage onSuccess={() => window.location.reload()} />;
}
return <AuthenticatedApp />;
return (
<ToastProvider>
<AuthenticatedApp />
</ToastProvider>
);
}
export default App;

View File

@@ -123,6 +123,37 @@ export function TaskCard({
{task.description && (
<p className="text-xs sm:text-sm text-gray-500 line-clamp-1">{task.description}</p>
)}
{/* Due date and subtask badges */}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{task.dueDate && (() => {
const due = new Date(task.dueDate);
const diffMs = due.getTime() - Date.now();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
const isOverdue = diffMs < 0;
const isDueSoon = diffDays <= 2 && !isOverdue;
return (
<span className={`text-[10px] sm:text-xs px-1.5 py-0.5 rounded-full font-medium inline-flex items-center gap-0.5 ${
isOverdue ? "bg-red-100 text-red-700" : isDueSoon ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"
}`}>
📅 {isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`}
</span>
);
})()}
{task.subtasks?.length > 0 && (() => {
const done = task.subtasks.filter(s => s.completed).length;
const total = task.subtasks.length;
const pct = Math.round((done / total) * 100);
return (
<span className="text-[10px] sm:text-xs text-gray-400 inline-flex items-center gap-1.5">
<span className="inline-block w-12 h-1 bg-gray-200 rounded-full overflow-hidden">
<span className={`block h-full rounded-full ${pct === 100 ? "bg-green-500" : "bg-amber-400"}`} style={{ width: `${pct}%` }} />
</span>
{done}/{total}
</span>
);
})()}
</div>
</div>
{/* Expand chevron - always visible */}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from "react";
import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask } from "../lib/api";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
import { useToast } from "./Toast";
const priorityColors: Record<TaskPriority, string> = {
critical: "bg-red-500 text-white",
@@ -246,6 +247,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
const [saving, setSaving] = useState(false);
const [noteText, setNoteText] = useState("");
const [addingNote, setAddingNote] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const { toast } = useToast();
// Draft state for editable fields
const [draftTitle, setDraftTitle] = useState(task.title);
@@ -263,6 +267,15 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
fetchProjects().then(setProjects).catch(() => {});
}, []);
// Keyboard shortcut: Escape to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && !showDeleteConfirm) onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, showDeleteConfirm]);
// Reset drafts when task changes
useEffect(() => {
setDraftTitle(task.title);
@@ -305,13 +318,32 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
if (draftDueDate !== currentDueDate) updates.dueDate = draftDueDate ? new Date(draftDueDate).toISOString() : null;
await updateTask(task.id, updates, token);
onTaskUpdated();
toast("Changes saved", "success");
} catch (e) {
console.error("Failed to update:", e);
toast("Failed to save changes", "error");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!hasToken) return;
setDeleting(true);
try {
await deleteTask(task.id, token);
toast("Task deleted", "success");
onClose();
onTaskUpdated();
} catch (e) {
console.error("Failed to delete:", e);
toast("Failed to delete task", "error");
} finally {
setDeleting(false);
setShowDeleteConfirm(false);
}
};
// Legacy single-field save available if needed
const _handleFieldSave = async (field: string, value: string) => {
if (!hasToken) return;
@@ -802,7 +834,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
{hasToken && actions.length > 0 && (
<div className="px-4 sm:px-6 py-4 border-t border-gray-200 bg-gray-50">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Actions</h3>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 items-center">
{actions.map((action) => (
<button
key={action.next}
@@ -815,6 +847,33 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
{action.label}
</button>
))}
<div className="flex-1" />
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1.5 rounded transition"
title="Delete task"
>
🗑 Delete
</button>
) : (
<div className="flex items-center gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 animate-slide-up">
<span className="text-xs text-red-700">Delete this task?</span>
<button
onClick={handleDelete}
disabled={deleting}
className="text-xs px-3 py-1 bg-red-600 text-white rounded font-medium hover:bg-red-700 transition disabled:opacity-50"
>
{deleting ? "..." : "Yes, delete"}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700 transition"
>
Cancel
</button>
</div>
)}
</div>
</div>
)}

View File

@@ -0,0 +1,67 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
interface Toast {
id: number;
message: string;
type: "success" | "error" | "info";
}
interface ToastContextValue {
toast: (message: string, type?: Toast["type"]) => void;
}
const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
export function useToast() {
return useContext(ToastContext);
}
let nextId = 0;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, type: Toast["type"] = "info") => {
const id = nextId++;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const typeStyles: Record<Toast["type"], string> = {
success: "bg-green-600 text-white",
error: "bg-red-600 text-white",
info: "bg-gray-800 text-white",
};
const typeIcons: Record<Toast["type"], string> = {
success: "✓",
error: "✕",
info: "",
};
return (
<ToastContext.Provider value={{ toast: addToast }}>
{children}
{/* Toast container */}
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
{toasts.map((t) => (
<div
key={t.id}
className={`pointer-events-auto px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 text-sm font-medium animate-slide-up min-w-[200px] max-w-sm ${typeStyles[t.type]}`}
onClick={() => removeToast(t.id)}
role="alert"
>
<span className="text-base shrink-0">{typeIcons[t.type]}</span>
<span className="flex-1">{t.message}</span>
</div>
))}
</div>
</ToastContext.Provider>
);
}

View File

@@ -4,6 +4,7 @@ import { useCurrentUser } from "../hooks/useCurrentUser";
import { TaskCard } from "../components/TaskCard";
import { TaskDetailPanel } from "../components/TaskDetailPanel";
import { CreateTaskModal } from "../components/CreateTaskModal";
import { useToast } from "../components/Toast";
import { updateTask, reorderTasks, createTask } from "../lib/api";
import type { TaskStatus } from "../lib/types";
@@ -15,6 +16,7 @@ export function QueuePage() {
const [selectedTask, setSelectedTask] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [filterPriority, setFilterPriority] = useState<string>("");
const { toast } = useToast();
const filteredTasks = useMemo(() => {
let filtered = tasks;
@@ -50,8 +52,9 @@ export function QueuePage() {
try {
await updateTask(id, { status });
refresh();
toast(`Task moved to ${status}`, "success");
} catch (e) {
alert("Failed to update task.");
toast("Failed to update task", "error");
}
};
@@ -77,8 +80,13 @@ export function QueuePage() {
source?: string;
priority?: string;
}) => {
await createTask(task);
refresh();
try {
await createTask(task);
refresh();
toast("Task created", "success");
} catch (e) {
toast("Failed to create task", "error");
}
};
return (

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { useParams, Link } from "react-router-dom";
import { useParams, Link, useNavigate } from "react-router-dom";
import type { Task, TaskStatus, Project } from "../lib/types";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask } from "../lib/api";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
import { useToast } from "../components/Toast";
const priorityColors: Record<string, string> = {
critical: "bg-red-500 text-white",
@@ -73,6 +74,10 @@ export function TaskPage() {
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
const [addingSubtask, setAddingSubtask] = useState(false);
const [saving, setSaving] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const fetchTask = useCallback(async () => {
if (!taskRef) return;
@@ -106,13 +111,31 @@ export function TaskPage() {
try {
await updateTask(task.id, { status });
fetchTask();
toast(`Task moved to ${status}`, "success");
} catch (e) {
console.error("Failed to update status:", e);
toast("Failed to update status", "error");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!task) return;
setDeleting(true);
try {
await deleteTask(task.id);
toast("Task deleted", "success");
navigate("/queue");
} catch (e) {
console.error("Failed to delete:", e);
toast("Failed to delete task", "error");
} finally {
setDeleting(false);
setShowDeleteConfirm(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400">
@@ -478,6 +501,7 @@ export function TaskPage() {
onClick={() => {
const url = `${window.location.origin}/task/HQ-${task.taskNumber}`;
navigator.clipboard.writeText(url);
toast("Link copied", "info");
}}
className="text-xs px-2 py-1 bg-gray-100 rounded hover:bg-gray-200 transition"
>
@@ -485,6 +509,38 @@ export function TaskPage() {
</button>
</div>
</div>
{/* Danger zone */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Danger Zone</h3>
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full text-sm text-red-600 hover:text-red-700 border border-red-200 rounded-lg px-3 py-2 hover:bg-red-50 transition font-medium"
>
🗑 Delete Task
</button>
) : (
<div className="space-y-2 animate-slide-up">
<p className="text-xs text-red-700">This cannot be undone. Are you sure?</p>
<div className="flex gap-2">
<button
onClick={handleDelete}
disabled={deleting}
className="flex-1 text-sm px-3 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition disabled:opacity-50"
>
{deleting ? "Deleting..." : "Yes, delete"}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>