= {
- none: 'bg-gray-500/20 text-gray-400',
- low: 'bg-green-500/20 text-green-400',
- medium: 'bg-yellow-500/20 text-yellow-400',
- high: 'bg-red-500/20 text-red-400',
-};
-
-interface KanbanBoardProps {
- tasks: ProjectTask[];
- selectedTaskId: number | null;
- kanbanParentTask?: ProjectTask | null;
- onSelectTask: (taskId: number) => void;
- onStatusChange: (taskId: number, status: string) => void;
- onBackToAllTasks?: () => void;
-}
-
-function KanbanColumn({
- column,
- tasks,
- selectedTaskId,
- onSelectTask,
-}: {
- column: (typeof COLUMNS)[0];
- tasks: ProjectTask[];
- selectedTaskId: number | null;
- onSelectTask: (taskId: number) => void;
-}) {
- const { setNodeRef, isOver } = useDroppable({ id: column.id });
-
- return (
-
- {/* Column header */}
-
-
-
- {column.label}
-
-
- {tasks.length}
-
-
-
-
- {/* Cards */}
-
- {tasks.map((task) => (
- onSelectTask(task.id)}
- />
- ))}
-
-
- );
-}
-
-function KanbanCard({
- task,
- isSelected,
- onSelect,
-}: {
- task: ProjectTask;
- isSelected: boolean;
- onSelect: () => void;
-}) {
- const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
- id: task.id,
- data: { task },
- });
-
- const style = transform
- ? {
- transform: `translate(${transform.x}px, ${transform.y}px)`,
- opacity: isDragging ? 0.5 : 1,
- }
- : undefined;
-
- const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
- const totalSubtasks = task.subtasks?.length ?? 0;
-
- return (
-
-
{task.title}
-
-
- {task.priority}
-
- {task.due_date && (
-
- {format(parseISO(task.due_date), 'MMM d')}
-
- )}
- {totalSubtasks > 0 && (
-
- {completedSubtasks}/{totalSubtasks}
-
- )}
-
-
- );
-}
-
-export default function KanbanBoard({
- tasks,
- selectedTaskId,
- kanbanParentTask,
- onSelectTask,
- onStatusChange,
- onBackToAllTasks,
-}: KanbanBoardProps) {
- const sensors = useSensors(
- useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
- );
-
- // Subtask view is driven by kanbanParentTask (decoupled from selected task)
- const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
- const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
-
- const handleDragEnd = (event: DragEndEvent) => {
- const { active, over } = event;
- if (!over) return;
-
- const taskId = active.id as number;
- const newStatus = over.id as string;
-
- const task = activeTasks.find((t) => t.id === taskId);
- if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
- onStatusChange(taskId, newStatus);
- }
- };
-
- const tasksByStatus = COLUMNS.map((col) => ({
- column: col,
- tasks: activeTasks.filter((t) => t.status === col.id),
- }));
-
- return (
-
- {/* Subtask view header */}
- {isSubtaskView && kanbanParentTask && (
-
-
- /
-
- Subtasks of: {kanbanParentTask.title}
-
-
- )}
-
-
-
- {tasksByStatus.map(({ column, tasks: colTasks }) => (
-
- ))}
-
-
-
- );
-}
+ TouchSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+ useDroppable,
+ useDraggable,
+} from '@dnd-kit/core';
+import { format, parseISO } from 'date-fns';
+import type { ProjectTask } from '@/types';
+import { Badge } from '@/components/ui/badge';
+
+const COLUMNS: { id: string; label: string; color: string }[] = [
+ { id: 'pending', label: 'Pending', color: 'text-gray-400' },
+ { id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
+ { id: 'blocked', label: 'Blocked', color: 'text-red-400' },
+ { id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
+ { id: 'review', label: 'Review', color: 'text-yellow-400' },
+ { id: 'completed', label: 'Completed', color: 'text-green-400' },
+];
+
+const priorityColors: Record
= {
+ none: 'bg-gray-500/20 text-gray-400',
+ low: 'bg-green-500/20 text-green-400',
+ medium: 'bg-yellow-500/20 text-yellow-400',
+ high: 'bg-red-500/20 text-red-400',
+};
+
+interface KanbanBoardProps {
+ tasks: ProjectTask[];
+ selectedTaskId: number | null;
+ kanbanParentTask?: ProjectTask | null;
+ onSelectTask: (taskId: number) => void;
+ onStatusChange: (taskId: number, status: string) => void;
+ onBackToAllTasks?: () => void;
+}
+
+function KanbanColumn({
+ column,
+ tasks,
+ selectedTaskId,
+ onSelectTask,
+}: {
+ column: (typeof COLUMNS)[0];
+ tasks: ProjectTask[];
+ selectedTaskId: number | null;
+ onSelectTask: (taskId: number) => void;
+}) {
+ const { setNodeRef, isOver } = useDroppable({ id: column.id });
+
+ return (
+
+ {/* Column header */}
+
+
+
+ {column.label}
+
+
+ {tasks.length}
+
+
+
+
+ {/* Cards */}
+
+ {tasks.map((task) => (
+ onSelectTask(task.id)}
+ />
+ ))}
+
+
+ );
+}
+
+function KanbanCard({
+ task,
+ isSelected,
+ onSelect,
+}: {
+ task: ProjectTask;
+ isSelected: boolean;
+ onSelect: () => void;
+}) {
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
+ id: task.id,
+ data: { task },
+ });
+
+ const style = transform
+ ? {
+ transform: `translate(${transform.x}px, ${transform.y}px)`,
+ opacity: isDragging ? 0.5 : 1,
+ }
+ : undefined;
+
+ const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
+ const totalSubtasks = task.subtasks?.length ?? 0;
+
+ return (
+
+
{task.title}
+
+
+ {task.priority}
+
+ {task.due_date && (
+
+ {format(parseISO(task.due_date), 'MMM d')}
+
+ )}
+ {totalSubtasks > 0 && (
+
+ {completedSubtasks}/{totalSubtasks}
+
+ )}
+
+
+ );
+}
+
+export default function KanbanBoard({
+ tasks,
+ selectedTaskId,
+ kanbanParentTask,
+ onSelectTask,
+ onStatusChange,
+ onBackToAllTasks,
+}: KanbanBoardProps) {
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
,
+ useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } })
+ );
+
+ // Subtask view is driven by kanbanParentTask (decoupled from selected task)
+ const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
+ const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over) return;
+
+ const taskId = active.id as number;
+ const newStatus = over.id as string;
+
+ const task = activeTasks.find((t) => t.id === taskId);
+ if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
+ onStatusChange(taskId, newStatus);
+ }
+ };
+
+ const tasksByStatus = COLUMNS.map((col) => ({
+ column: col,
+ tasks: activeTasks.filter((t) => t.status === col.id),
+ }));
+
+ return (
+
+ {/* Subtask view header */}
+ {isSubtaskView && kanbanParentTask && (
+
+
+ /
+
+ Subtasks of: {kanbanParentTask.title}
+
+
+ )}
+
+
+
+ {tasksByStatus.map(({ column, tasks: colTasks }) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx
index 867d9e8..867bd54 100644
--- a/frontend/src/components/projects/ProjectDetail.tsx
+++ b/frontend/src/components/projects/ProjectDetail.tsx
@@ -1,4 +1,5 @@
import { useState, useMemo, useCallback } from 'react';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@@ -257,6 +258,8 @@ export default function ProjectDetail() {
}
}, [topLevelTasks, sortMode, sortSubtasks]);
+ const isDesktop = useMediaQuery('(min-width: 1024px)');
+
const selectedTask = useMemo(() => {
if (!selectedTaskId) return null;
// Search top-level and subtasks
@@ -345,13 +348,13 @@ export default function ProjectDetail() {
if (isLoading) {
return (
-
+
-
Loading...
+
Loading...
-
@@ -375,14 +378,14 @@ export default function ProjectDetail() {
return (
{/* Header */}
-
+
-
+
{project.name}
-
+
{statusLabels[project.status]}
-
{/* Content area */}
{/* Summary section - scrolls with left panel on small, fixed on large */}
-
+
{/* Description */}
{project.description && (
{project.description}
@@ -426,7 +427,7 @@ export default function ProjectDetail() {
{/* Project Summary Card */}
-
+
Overall Progress
@@ -444,7 +445,7 @@ export default function ProjectDetail() {
{completedTasks} of {totalTasks} tasks completed
-
+
@@ -490,7 +491,7 @@ export default function ProjectDetail() {
{/* Task list header + view controls */}
-
+
Tasks
{/* View toggle */}
@@ -544,7 +545,7 @@ export default function ProjectDetail() {
{/* Left panel: task list or kanban */}
-
+
{topLevelTasks.length === 0 ? (
{/* Expanded subtasks */}
{isExpanded && hasSubtasks && (
-
+
{task.subtasks.map((subtask) => (
- {/* Right panel: task detail (hidden on small screens) */}
-
-
-
openTaskForm(null, parentId)}
- onClose={() => setSelectedTaskId(null)}
- onSelectTask={setSelectedTaskId}
- />
+ {/* Right panel: task detail (desktop only) */}
+ {selectedTaskId && isDesktop && (
+
+
+ openTaskForm(null, parentId)}
+ onClose={() => setSelectedTaskId(null)}
+ onSelectTask={setSelectedTaskId}
+ />
+
-
+ )}
{/* Mobile: show detail panel as overlay when task selected on small screens */}
- {selectedTaskId && selectedTask && (
-
+ {selectedTaskId && selectedTask && !isDesktop && (
+
Task Details
diff --git a/frontend/src/components/projects/ProjectsPage.tsx b/frontend/src/components/projects/ProjectsPage.tsx
index d14054c..8980863 100644
--- a/frontend/src/components/projects/ProjectsPage.tsx
+++ b/frontend/src/components/projects/ProjectsPage.tsx
@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Project } from '@/types';
import { Button } from '@/components/ui/button';
+import { Select } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { GridSkeleton } from '@/components/ui/skeleton';
@@ -70,10 +71,19 @@ export default function ProjectsPage() {
return (
{/* Header */}
-
-
Projects
+
+
Projects
-
+
+
{statusFilters.map((sf) => (
setSearch(e.target.value)}
- className="w-52 h-8 pl-8 text-sm"
+ className="w-32 sm:w-52 h-8 pl-8 text-sm"
/>
setShowForm(true)} size="sm">
-
- New Project
+ New Project
-
+
{/* Summary stats */}
{!isLoading && projects.length > 0 && (
-
+
-
-
+
+
-
Total
-
{projects.length}
+
Total
+
{projects.length}
-
-
+
+
-
In Progress
-
{inProgressCount}
+
In Progress
+
{inProgressCount}
-
-
+
+
-
Completed
-
{completedCount}
+
Completed
+
{completedCount}
diff --git a/frontend/src/components/projects/TaskDetailPanel.tsx b/frontend/src/components/projects/TaskDetailPanel.tsx
index 2f7c144..3f15928 100644
--- a/frontend/src/components/projects/TaskDetailPanel.tsx
+++ b/frontend/src/components/projects/TaskDetailPanel.tsx
@@ -484,7 +484,7 @@ export default function TaskDetailPanel({
{
e.stopPropagation();
handleDeleteSubtask(subtask.id, subtask.title);
@@ -527,7 +527,7 @@ export default function TaskDetailPanel({
{
if (!window.confirm('Delete this comment?')) return;
deleteCommentMutation.mutate(comment.id);
diff --git a/frontend/src/components/projects/TaskRow.tsx b/frontend/src/components/projects/TaskRow.tsx
index fc78847..c93501c 100644
--- a/frontend/src/components/projects/TaskRow.tsx
+++ b/frontend/src/components/projects/TaskRow.tsx
@@ -52,7 +52,7 @@ export default function TaskRow({
return (
{/* Metadata columns */}
-
+
{task.status.replace('_', ' ')}
{task.priority}
-
{hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'}
+ {/* Mobile-only: compact priority dot + overdue indicator */}
+
+
+ {isOverdue &&
{task.due_date ? format(parseISO(task.due_date), 'M/d') : ''}}
+
+
{/* Subtask progress bar */}
{hasSubtasks && (
diff --git a/frontend/src/components/reminders/ReminderItem.tsx b/frontend/src/components/reminders/ReminderItem.tsx
index bb2b990..7cc9812 100644
--- a/frontend/src/components/reminders/ReminderItem.tsx
+++ b/frontend/src/components/reminders/ReminderItem.tsx
@@ -73,14 +73,14 @@ export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) {
return (
-
onEdit(reminder)}
- >
- {reminder.title}
-
-
- {reminder.recurrence_rule && (
-
- {recurrenceLabels[reminder.recurrence_rule] || reminder.recurrence_rule}
-
- )}
-
- {remindDate && (
+ {/* Content wrapper — stacks on mobile, inline on desktop */}
+
onEdit(reminder)}
>
- {format(remindDate, 'MMM d, h:mm a')}
+ {reminder.title}
- )}
- {!reminder.is_dismissed && (
+
+ {reminder.recurrence_rule && (
+
+ {recurrenceLabels[reminder.recurrence_rule] || reminder.recurrence_rule}
+
+ )}
+
+ {remindDate && (
+
+ {format(remindDate, 'MMM d, h:mm a')}
+
+ )}
+
+
+
+ {/* Actions */}
+
+ {!reminder.is_dismissed && (
+
dismissMutation.mutate()}
+ disabled={dismissMutation.isPending}
+ className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
+ aria-label="Dismiss reminder"
+ >
+
+
+ )}
+
dismissMutation.mutate()}
- disabled={dismissMutation.isPending}
- className="h-7 w-7 shrink-0 hover:bg-orange-500/10 hover:text-orange-400"
- aria-label="Dismiss reminder"
+ onClick={() => onEdit(reminder)}
+ className="h-7 w-7"
+ aria-label="Edit reminder"
>
-
+
- )}
-
onEdit(reminder)}
- className="h-7 w-7 shrink-0"
- aria-label="Edit reminder"
- >
-
-
-
- {confirmingDelete ? (
-
- Sure?
-
- ) : (
-
-
-
- )}
+ {confirmingDelete ? (
+
+ Sure?
+
+ ) : (
+
+
+
+ )}
+
);
}
diff --git a/frontend/src/components/reminders/RemindersPage.tsx b/frontend/src/components/reminders/RemindersPage.tsx
index 7fdb1b6..328f1ca 100644
--- a/frontend/src/components/reminders/RemindersPage.tsx
+++ b/frontend/src/components/reminders/RemindersPage.tsx
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -6,6 +7,7 @@ import { isPast, isToday, parseISO } from 'date-fns';
import api from '@/lib/api';
import type { Reminder } from '@/types';
import { Button } from '@/components/ui/button';
+import { Select } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton';
@@ -23,6 +25,8 @@ type StatusFilter = (typeof statusFilters)[number]['value'];
export default function RemindersPage() {
const location = useLocation();
+ const isDesktop = useMediaQuery('(min-width: 1024px)');
+
// Panel state
const [selectedReminderId, setSelectedReminderId] = useState
(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
@@ -99,10 +103,19 @@ export default function RemindersPage() {
return (
{/* Header */}
-
-
Reminders
+
+
Reminders
-
+
+
{statusFilters.map((sf) => (
-
+
setSearch(e.target.value)}
- className="w-52 h-8 pl-8 text-sm ring-inset"
+ className="w-28 sm:w-52 h-8 pl-8 text-sm ring-inset"
/>
-
-
- Add Reminder
+
+ Add Reminder
@@ -148,46 +160,46 @@ export default function RemindersPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
-
+
{/* Summary stats */}
{!isLoading && reminders.length > 0 && (
-
+
-
-
+
+
-
+
Active
-
{activeCount}
+
{activeCount}
-
-
+
+
-
+
Overdue
-
{overdueCount}
+
{overdueCount}
-
-
+
+
-
+
Dismissed
-
{dismissedCount}
+
{dismissedCount}
@@ -207,24 +219,24 @@ export default function RemindersPage() {
{/* Detail panel (desktop) */}
-
-
-
+ {panelOpen && isDesktop && (
+
+
+
+ )}
{/* Mobile detail panel overlay */}
- {panelOpen && (
+ {panelOpen && !isDesktop && (
{/* Page header — matches Stage 4-5 pages */}
-
+
Settings
diff --git a/frontend/src/components/shared/CategoryFilterBar.tsx b/frontend/src/components/shared/CategoryFilterBar.tsx
index 4739557..39de1b1 100644
--- a/frontend/src/components/shared/CategoryFilterBar.tsx
+++ b/frontend/src/components/shared/CategoryFilterBar.tsx
@@ -146,56 +146,57 @@ export default function CategoryFilterBar({
};
return (
-
- {/* All pill */}
-
-
- All
-
-
-
- {/* Pinned pill */}
-
-
- {pinnedLabel}
-
-
-
- {/* Extra pinned filters (e.g. "Umbral") */}
- {extraPinnedFilters.map((epf) => (
+
+ {/* Top row: pills + search */}
+
+ {/* All pill */}
-
- {epf.label}
+
+ All
- ))}
- {/* Categories pill + expandable chips */}
- {categories.length > 0 && (
- <>
+ {/* Pinned pill */}
+
+
+ {pinnedLabel}
+
+
+
+ {/* Extra pinned filters (e.g. "Umbral") */}
+ {extraPinnedFilters.map((epf) => (
+
+
+ {epf.label}
+
+
+ ))}
+
+ {/* Categories pill */}
+ {categories.length > 0 && (
setOtherOpen((p) => !p)}
@@ -207,78 +208,72 @@ export default function CategoryFilterBar({
Categories
+ )}
-
- {/* "All" chip inside categories — non-draggable */}
- {onSelectAllCategories && (
-
-
- All
-
-
- )}
-
- {/* Draggable category chips */}
-
-
- {categories.map((cat) => (
- onToggleCategory(cat)}
- />
- ))}
-
-
-
- >
- )}
-
- {/* Spacer */}
-
-
- {/* Search */}
-
-
-
onSearchChange(e.target.value)}
- className="w-52 h-8 pl-8 text-sm ring-inset"
- aria-label="Search"
- />
+ {/* Search */}
+
+
+
+ onSearchChange(e.target.value)}
+ className="w-28 sm:w-52 h-8 pl-8 text-sm ring-inset"
+ aria-label="Search"
+ />
+
+
+ {/* Expanded categories row — shows below on mobile, inline on desktop */}
+ {categories.length > 0 && otherOpen && (
+
+ {/* "All" chip inside categories — non-draggable */}
+ {onSelectAllCategories && (
+
+
+ All
+
+
+ )}
+
+ {/* Draggable category chips */}
+
+
+ {categories.map((cat) => (
+ onToggleCategory(cat)}
+ />
+ ))}
+
+
+
+ )}
);
}
diff --git a/frontend/src/components/shared/CopyableField.tsx b/frontend/src/components/shared/CopyableField.tsx
index ec44827..7a0ee84 100644
--- a/frontend/src/components/shared/CopyableField.tsx
+++ b/frontend/src/components/shared/CopyableField.tsx
@@ -27,7 +27,7 @@ export default function CopyableField({ value, icon: Icon, label }: CopyableFiel
type="button"
onClick={handleCopy}
aria-label={`Copy ${label || value}`}
- className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 p-0.5 rounded text-muted-foreground hover:text-foreground shrink-0"
+ className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-150 p-0.5 rounded text-muted-foreground hover:text-foreground shrink-0"
>
{copied ?
:
}
diff --git a/frontend/src/components/shared/EntityTable.tsx b/frontend/src/components/shared/EntityTable.tsx
index de4a244..529493d 100644
--- a/frontend/src/components/shared/EntityTable.tsx
+++ b/frontend/src/components/shared/EntityTable.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { VisibilityMode } from '@/hooks/useTableVisibility';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
export interface ColumnDef
{
key: string;
@@ -28,6 +29,7 @@ interface EntityTableProps {
onSort: (key: string) => void;
visibilityMode: VisibilityMode;
loading?: boolean;
+ mobileCardRender?: (item: T) => React.ReactNode;
}
const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
@@ -127,10 +129,51 @@ export function EntityTable({
onSort,
visibilityMode,
loading = false,
+ mobileCardRender,
}: EntityTableProps) {
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
const colCount = visibleColumns.length;
const showPinnedSection = showPinned && pinnedRows.length > 0;
+ const isMobile = useMediaQuery('(max-width: 767px)');
+
+ if (isMobile && mobileCardRender) {
+ return (
+
+ {loading ? (
+ Array.from({ length: 6 }).map((_, i) => (
+
+ ))
+ ) : (
+ <>
+ {showPinnedSection && (
+ <>
+
{pinnedLabel}
+ {pinnedRows.map((item) => (
+
onRowClick(item.id)} className="cursor-pointer" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(item.id); } }}>
+ {mobileCardRender(item)}
+
+ ))}
+ >
+ )}
+ {groups.map((group) => (
+
+ {group.rows.length > 0 && (
+ <>
+ {group.label}
+ {group.rows.map((item) => (
+ onRowClick(item.id)} className="cursor-pointer" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(item.id); } }}>
+ {mobileCardRender(item)}
+
+ ))}
+ >
+ )}
+
+ ))}
+ >
+ )}
+
+ );
+ }
return (
diff --git a/frontend/src/components/todos/TodoItem.tsx b/frontend/src/components/todos/TodoItem.tsx
index cea548b..2036d07 100644
--- a/frontend/src/components/todos/TodoItem.tsx
+++ b/frontend/src/components/todos/TodoItem.tsx
@@ -52,7 +52,6 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
await api.delete(`/todos/${todo.id}`);
},
onMutate: async () => {
- // Optimistic removal
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData
(['todos']);
queryClient.setQueryData(['todos'], (old) =>
@@ -65,7 +64,6 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
toast.success('Todo deleted');
},
onError: (_err, _vars, context) => {
- // Rollback on failure
if (context?.previous) {
queryClient.setQueryData(['todos'], context.previous);
}
@@ -87,7 +85,7 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
return (
toggleMutation.mutate()}
disabled={toggleMutation.isPending}
+ className="mt-0.5 md:mt-0"
/>
-
onEdit(todo)}
- >
- {todo.title}
-
-
- {/* Inline pills */}
-
- {todo.priority}
-
-
- {todo.category && (
-
- {todo.category}
+ {/* Content wrapper — stacks on mobile, inline on desktop */}
+
+ {/* Title row — always takes full width on mobile */}
+
onEdit(todo)}
+ >
+ {todo.title}
- )}
- {todo.recurrence_rule && (
-
- {recurrenceLabels[todo.recurrence_rule] || todo.recurrence_rule}
-
- )}
-
- {/* Date / time / reset info — right-aligned cluster */}
- {showResetInfo ? (
-
-
-
- Resets {format(resetDate, 'EEE dd/MM')}
- {nextDueDate && (
- <> · Due {format(nextDueDate, 'dd/MM')}{todo.due_time ? ` ${todo.due_time.slice(0, 5)}` : ''}>
+ {/* Metadata row — wraps on second line on mobile */}
+
+
+ {todo.priority}
-
- ) : (
- <>
- {dueDate && (
-
+ {todo.category}
+
+ )}
+
+ {todo.recurrence_rule && (
+
+ {recurrenceLabels[todo.recurrence_rule] || todo.recurrence_rule}
+
+ )}
+
+ {showResetInfo ? (
+
+
+
+ Resets {format(resetDate, 'EEE dd/MM')}
+ {nextDueDate && (
+ <>{' \u00b7 '}Due {format(nextDueDate, 'dd/MM')}{todo.due_time ? ` ${todo.due_time.slice(0, 5)}` : ''}>
+ )}
+
+
+ ) : (
+ <>
+ {dueDate && (
+
+ {isOverdue ? : }
+ {isOverdue ? 'Overdue \u00b7 ' : isDueToday ? 'Today \u00b7 ' : ''}
+ {format(dueDate, 'MMM d')}
+
)}
- >
- {isOverdue ? : }
- {isOverdue ? 'Overdue · ' : isDueToday ? 'Today · ' : ''}
- {format(dueDate, 'MMM d')}
-
+ {todo.due_time && (
+
+
+ {todo.due_time.slice(0, 5)}
+
+ )}
+ >
)}
- {todo.due_time && (
-
-
- {todo.due_time.slice(0, 5)}
-
- )}
- >
- )}
+
+
{/* Actions */}
- onEdit(todo)} className="h-7 w-7 shrink-0" aria-label="Edit todo">
-
-
- {confirmingDelete ? (
-
- Sure?
+
+
onEdit(todo)} className="h-7 w-7" aria-label="Edit todo">
+
- ) : (
-
-
-
- )}
+ {confirmingDelete ? (
+
+ Sure?
+
+ ) : (
+
+
+
+ )}
+
);
}
diff --git a/frontend/src/components/todos/TodosPage.tsx b/frontend/src/components/todos/TodosPage.tsx
index 40d1367..1090915 100644
--- a/frontend/src/components/todos/TodosPage.tsx
+++ b/frontend/src/components/todos/TodosPage.tsx
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -6,6 +7,7 @@ import api from '@/lib/api';
import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
import { Button } from '@/components/ui/button';
+import { Select } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton';
import { CategoryFilterBar } from '@/components/shared';
@@ -24,6 +26,8 @@ const priorityFilters = [
export default function TodosPage() {
const location = useLocation();
+ const isDesktop = useMediaQuery('(min-width: 1024px)');
+
// Panel state
const [selectedTodoId, setSelectedTodoId] = useState(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
@@ -128,11 +132,20 @@ export default function TodosPage() {
return (
{/* Header */}
-
-
Todos
+
+
Todos
{/* Priority filter */}
-
+
+
{priorityFilters.map((pf) => (
{/* Category filter bar (All + Completed + Categories with drag) */}
-
+
@@ -183,46 +195,46 @@ export default function TodosPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
-
+
{/* Summary stats */}
{!isLoading && todos.length > 0 && (
-
+
-
-
+
+
-
+
Open
-
{totalCount}
+
{totalCount}
-
-
+
+
-
+
Completed
-
{completedCount}
+
{completedCount}
-
-
+
+
-
+
Overdue
-
{overdueCount}
+
{overdueCount}
@@ -242,24 +254,24 @@ export default function TodosPage() {
{/* Detail panel (desktop) */}
-
-
-
+ {panelOpen && isDesktop && (
+
+
+
+ )}
{/* Mobile detail panel overlay */}
- {panelOpen && (
+ {panelOpen && !isDesktop && (
(
const blurTimeoutRef = React.useRef
>();
const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 });
+ const isMobile = useMediaQuery('(max-width: 767px)');
React.useImperativeHandle(ref, () => triggerRef.current!);
@@ -324,8 +326,8 @@ const DatePicker = React.forwardRef(
e.stopPropagation()}
- style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 60 }}
- className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in"
+ style={isMobile ? { position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 60 } : { position: 'fixed', top: pos.top, left: pos.left, zIndex: 60 }}
+ className={isMobile ? 'w-full rounded-t-lg border border-input bg-card shadow-lg animate-fade-in pb-[env(safe-area-inset-bottom)]' : 'w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in'}
>
{/* Month/Year nav */}
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
index 04db187..3e70a9d 100644
--- a/frontend/src/components/ui/input.tsx
+++ b/frontend/src/components/ui/input.tsx
@@ -9,7 +9,7 @@ const Input = React.forwardRef
(
(