merge: fix/ux-leaf-e-crosscut into integration/ux-28-gaps (resolve conflicts on tasks-kanban-view, tasks-list-view, task-detail-panel)

This commit is contained in:
2026-07-29 20:34:27 +00:00
9 changed files with 336 additions and 9 deletions
@@ -413,6 +413,62 @@ export function TaskDetailPanel({
);
})()}
{/* Time Tracking */}
<div className="space-y-3">
<Label>Time Tracking</Label>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Tracked: {task.trackedMinutes || 0}m / {task.estimatedMinutes || 0}m
</span>
{task.estimatedMinutes && task.estimatedMinutes > 0 && (
<span className="text-muted-foreground">
{Math.min(100, Math.round(((task.trackedMinutes || 0) / task.estimatedMinutes) * 100))}%
</span>
)}
</div>
{task.estimatedMinutes && task.estimatedMinutes > 0 && (
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${Math.min(100, Math.round(((task.trackedMinutes || 0) / task.estimatedMinutes) * 100))}%`,
}}
/>
</div>
)}
<div className="flex items-center gap-2">
{timerRunning ? (
<>
<Button
variant="destructive"
size="sm"
onClick={handleStopTimer}
className="gap-1"
>
<Square className="h-4 w-4" />
Stop timer
</Button>
<span className="text-sm font-mono text-primary">
{formatDuration(elapsedSeconds)}
</span>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={handleStartTimer}
className="gap-1"
>
<Play className="h-4 w-4" />
Start timer
</Button>
)}
</div>
</div>
</div>
{/* Tags */}
{task.tags && task.tags.length > 0 && (
<div className="space-y-2">
@@ -14,7 +14,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Calendar, GripVertical, Link2, Loader2, Plus } from 'lucide-react';
import { Calendar, Clock, GripVertical, Link2, Loader2, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { TaskDetailPanel } from './task-detail-panel';
import { useRealtimeContext } from '@/components/realtime-provider';
@@ -120,6 +120,15 @@ function TaskCard({
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
{task.estimatedMinutes && (
<span
className="flex items-center gap-1 text-xs text-muted-foreground"
title={`Estimated: ${task.estimatedMinutes}m`}
>
<Clock className="h-3 w-3" />
{task.estimatedMinutes}m
</span>
)}
{task.tags?.length > 0 && (
<div className="flex gap-1 flex-wrap">
{task.tags.slice(0, 3).map((tag) => (
+36 -3
View File
@@ -173,11 +173,15 @@ export function TasksListView({
const searchParams = useSearchParams();
const [tasks, setTasks] = useState<Task[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [domainMap, setDomainMap] = useState<Map<string, string>>(new Map());
const LIMIT = 50;
const { subscribe } = useRealtimeContext();
// Filter/sort state from URL params
@@ -224,10 +228,13 @@ export function TasksListView({
[searchParams, pathname, router]
);
const fetchTasks = useCallback(async () => {
const fetchTasks = useCallback(async (appendOffset?: number) => {
if (!domainId) return;
if (appendOffset === undefined) setLoading(true);
else setLoadingMore(true);
try {
const params = new URLSearchParams({ sort, order, limit: '200' });
const currentOffset = appendOffset ?? 0;
const params = new URLSearchParams({ sort, order, limit: String(LIMIT), offset: String(currentOffset) });
if (statusFilter.length > 0) params.set('status', statusFilter.join(','));
if (priorityFilter.length > 0) params.set('priority', priorityFilter.join(','));
if (searchQuery) params.set('search', searchQuery);
@@ -235,15 +242,28 @@ export function TasksListView({
const response = await fetch(`/api/domains/${domainId}/tasks?${params.toString()}`);
if (!response.ok) throw new Error('Unable to load tasks');
const data = await response.json();
setTasks(data.items || []);
if (appendOffset !== undefined) {
setTasks(prev => [...prev, ...(data.items || [])]);
} else {
setTasks(data.items || []);
setOffset(0);
}
setTotalCount(data.totalItems || 0);
} catch (error) {
console.error('Failed to fetch tasks:', error);
toast.error('Unable to load tasks');
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [domainId, sort, order, statusFilter, priorityFilter, searchQuery]);
function loadMore() {
const newOffset = offset + LIMIT;
setOffset(newOffset);
fetchTasks(newOffset);
}
const fetchDomains = useCallback(async () => {
try {
const res = await fetch('/api/domains?sort=sort_order');
@@ -619,6 +639,19 @@ export function TasksListView({
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between px-4 py-3 text-sm text-muted-foreground">
<span>Showing {tasks.length} of {totalCount} tasks</span>
{tasks.length < totalCount && (
<Button
variant="outline"
size="sm"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? "Loading..." : "Load more"}
</Button>
)}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)}