From e250b2124f68a355cf00f54cd2c49e9ef98f84f3 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 30 Mar 2026 00:42:40 +0000 Subject: [PATCH] Fix display_name TS error + clean rebuild Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/tasks/app/globals.css | 10 ++ apps/tasks/app/tasks/page.tsx | 182 +++++++++++++++++++++++------ apps/tasks/components/TaskCard.tsx | 137 ++++++++++++++++++---- apps/tasks/lib/api.ts | 1 + 4 files changed, 273 insertions(+), 57 deletions(-) diff --git a/apps/tasks/app/globals.css b/apps/tasks/app/globals.css index 3674ad5..0c616ed 100644 --- a/apps/tasks/app/globals.css +++ b/apps/tasks/app/globals.css @@ -356,3 +356,13 @@ main { min-height: 28px !important; } } + +/* Status dot pulse animation for in_progress tasks */ +@keyframes statusPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.status-dot-active { + animation: statusPulse 2s ease-in-out infinite; +} diff --git a/apps/tasks/app/tasks/page.tsx b/apps/tasks/app/tasks/page.tsx index 8ebb4b1..39e6b8b 100644 --- a/apps/tasks/app/tasks/page.tsx +++ b/apps/tasks/app/tasks/page.tsx @@ -6,21 +6,33 @@ import { useAuth } from "@/lib/auth"; import { getTasks, getGroups, createTask, updateTask, Task, Group } from "@/lib/api"; import { useTranslation } from "@/lib/i18n"; import TaskCard from "@/components/TaskCard"; -import GroupSelector from "@/components/GroupSelector"; import TaskModal from "@/components/TaskModal"; import { useSwipeable } from "react-swipeable"; -type StatusFilter = "all" | "pending" | "in_progress" | "done" | "cancelled"; +const STATUS_VALUES = ["pending", "in_progress", "done", "cancelled"] as const; +type StatusValue = (typeof STATUS_VALUES)[number]; + +function statusColor(s: StatusValue | null): string { + switch (s) { + case "pending": return "#FBBF24"; + case "in_progress": return "#60A5FA"; + case "done": return "#34D399"; + case "cancelled": return "#9CA3AF"; + default: return "#7A7A9A"; + } +} export default function TasksPage() { - const { token } = useAuth(); + const { token, user } = useAuth(); const { t } = useTranslation(); const router = useRouter(); const [tasks, setTasks] = useState([]); const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [selectedGroup, setSelectedGroup] = useState(null); - const [statusFilter, setStatusFilter] = useState("all"); + const [selectedStatus, setSelectedStatus] = useState(null); + const [groupOpen, setGroupOpen] = useState(false); + const [showForm, setShowForm] = useState(false); const [swipeDirection, setSwipeDirection] = useState<"left" | "right" | null>(null); const [swipeOverlay, setSwipeOverlay] = useState<{ name: string; icon: string | null } | null>(null); @@ -34,13 +46,18 @@ export default function TasksPage() { return groupOrder.indexOf(selectedGroup); }, [groupOrder, selectedGroup]); + const selectedGroupObj = useMemo( + () => (selectedGroup ? groups.find((g) => g.id === selectedGroup) ?? null : null), + [selectedGroup, groups] + ); + const loadData = useCallback(async () => { if (!token) return; setLoading(true); try { const params: Record = {}; if (selectedGroup) params.group_id = selectedGroup; - if (statusFilter !== "all") params.status = statusFilter; + if (selectedStatus) params.status = selectedStatus; const [tasksRes, groupsRes] = await Promise.all([ getTasks(token, Object.keys(params).length > 0 ? params : undefined), @@ -53,7 +70,7 @@ export default function TasksPage() { } finally { setLoading(false); } - }, [token, selectedGroup, statusFilter]); + }, [token, selectedGroup, selectedStatus]); useEffect(() => { if (!token) { @@ -118,46 +135,141 @@ export default function TasksPage() { } } - const statusOptions: { value: StatusFilter; label: string }[] = [ - { value: "all", label: t("tasks.all") }, - { value: "pending", label: t("tasks.status.pending") }, - { value: "in_progress", label: t("tasks.status.in_progress") }, - { value: "done", label: t("tasks.status.done") }, - { value: "cancelled", label: t("tasks.status.cancelled") }, - ]; - if (!token) return null; + const userInitial = (user?.name || user?.email || "?").charAt(0).toUpperCase(); + return ( -
- {/* Group dropdown + Status pills — single compact row */} -
-
- +
+ {/* Single-row sticky filter header — group dropdown left, status pills right */} +
+ + {/* Groups dropdown — compact, left side */} +
+ + {groupOpen && ( + <> +
setGroupOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 150 }} /> +
+ + {groups.map(g => ( + + ))} +
+ + )}
-
- {statusOptions.map((opt) => ( + + {/* Status pills — horizontal scroll, right side */} +
+
+ {/* "All" pill */} - ))} + {STATUS_VALUES.map(s => ( + + ))} +
{/* Swipeable task list area */} -
+
{/* Swipe overlay */} {swipeOverlay && (
diff --git a/apps/tasks/components/TaskCard.tsx b/apps/tasks/components/TaskCard.tsx index e01a967..511df28 100644 --- a/apps/tasks/components/TaskCard.tsx +++ b/apps/tasks/components/TaskCard.tsx @@ -3,7 +3,6 @@ import { useState, useRef } from "react"; import { Task } from "@/lib/api"; import { useTranslation } from "@/lib/i18n"; -import StatusBadge from "./StatusBadge"; import Link from "next/link"; import { useSwipeable } from "react-swipeable"; @@ -19,19 +18,64 @@ const PRIORITY_COLORS: Record = { low: "#22c55e", }; +const PRIORITY_ICONS: Record = { + urgent: "\u25c6", + high: "\u25b2", + medium: "\u25cf", + low: "\u25bd", +}; + +const STATUS_DOT_COLORS: Record = { + pending: "#9CA3AF", + in_progress: "#F59E0B", + done: "#22C55E", + completed: "#22C55E", + cancelled: "#EF4444", +}; + function isDone(status: string): boolean { return status === "done" || status === "completed"; } +function isInProgress(status: string): boolean { + return status === "in_progress"; +} + +/** Generate a consistent color from a string (user ID) */ +function userColor(userId: string): string { + const colors = [ + "#3B82F6", "#8B5CF6", "#EC4899", "#F59E0B", + "#10B981", "#06B6D4", "#F97316", "#6366F1", + ]; + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash) + userId.charCodeAt(i); + hash |= 0; + } + return colors[Math.abs(hash) % colors.length]; +} + +/** Get initials from user ID (first 2 chars uppercase) */ +function userInitials(userId: string): string { + return userId.slice(0, 2).toUpperCase(); +} + export default function TaskCard({ task, onComplete }: TaskCardProps) { const { t } = useTranslation(); const priorityColor = PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium; + const priorityIcon = PRIORITY_ICONS[task.priority] || PRIORITY_ICONS.medium; + const statusDotColor = STATUS_DOT_COLORS[task.status] || STATUS_DOT_COLORS.pending; const taskDone = isDone(task.status); + const taskActive = isInProgress(task.status); const [swipeOffset, setSwipeOffset] = useState(0); const [swiped, setSwiped] = useState(false); const cardRef = useRef(null); const SWIPE_THRESHOLD = 120; + const MAX_AVATARS = 3; + const assignees = task.assigned_to || []; + const visibleAssignees = assignees.slice(0, MAX_AVATARS); + const extraCount = assignees.length - MAX_AVATARS; const swipeHandlers = useSwipeable({ onSwiping: (e) => { @@ -93,20 +137,67 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) { }} > -
- {/* Priority line on left edge */} -
+
+
-
- {/* Group icon */} - {task.group_icon && ( - {task.group_icon} - )} + {/* LEFT: User avatars (overlapping circles) */} +
0 ? 28 : 0 }}> + {visibleAssignees.length > 0 && ( +
+ {visibleAssignees.map((userId, idx) => ( +
0 ? -8 : 0, + position: "relative", zIndex: MAX_AVATARS - idx, + }} + title={userId} + > + {userInitials(userId)} +
+ ))} + {extraCount > 0 && ( +
+ +{extraCount} +
+ )} +
+ )} +
- {/* Title - single line, truncated */} + {/* Status dot — big colored circle */} +
+ + {/* MIDDLE: Task title — single line, truncated */}

- {/* Status badge */} -
- -
+ {/* RIGHT: Group icon */} + {task.group_icon && ( + {task.group_icon} + )} - {/* Priority dot */} -
+ > + {priorityIcon} +

diff --git a/apps/tasks/lib/api.ts b/apps/tasks/lib/api.ts index 5d07656..d0ae6d0 100644 --- a/apps/tasks/lib/api.ts +++ b/apps/tasks/lib/api.ts @@ -141,6 +141,7 @@ export interface Group { color: string; icon: string | null; sort_order: number; + display_name?: string; } export interface Connector {