From bf12031773075bdd948f8edc66d98dcdb830af16 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Wed, 10 Sep 2025 10:11:46 -0500 Subject: [PATCH] stopping point --- bun.lock | 2 +- convex/statuses.ts | 13 +- .../layout/profile/avatar-upload.tsx | 7 +- .../layout/status/list/history/index.tsx | 146 ++++++ src/components/layout/status/list/index.tsx | 471 +++++++++++------- src/components/layout/status/table/index.tsx | 111 ++++- 6 files changed, 539 insertions(+), 211 deletions(-) create mode 100644 src/components/layout/status/list/history/index.tsx diff --git a/bun.lock b/bun.lock index d06bada..a4bb167 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ "require-in-the-middle": "^7.5.2", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", - "typescript-eslint": "^8.42.0", + "typescript-eslint": "^8.43.0", "vaul": "^1.1.2", "zod": "^4.1.5", }, diff --git a/convex/statuses.ts b/convex/statuses.ts index 1dcc3ec..c25e40b 100644 --- a/convex/statuses.ts +++ b/convex/statuses.ts @@ -14,6 +14,7 @@ type RWCtx = MutationCtx | QueryCtx; type StatusRow = { user: { id: Id<'users'>; + email: string | null; name: string | null; imageUrl: string | null; }; @@ -155,6 +156,8 @@ export const getCurrentForUser = query({ const getName = (u: Doc<'users'>): string | null => 'name' in u && typeof u.name === 'string' ? u.name : null; +const getEmail = (u: Doc<'users'>): string | null => + 'email' in u && typeof u.email === 'string' ? u.email : null; const getImageId = (u: Doc<'users'>): Id<'_storage'> | null => { if (!('image' in u)) return null; @@ -205,6 +208,7 @@ export const getCurrentForAll = query({ : null; updatedByUser = { id: updater._id, + email: getEmail(updater), name: getName(updater), imageUrl: updaterImageUrl, }; @@ -222,6 +226,7 @@ export const getCurrentForAll = query({ return { user: { id: u._id, + email: getEmail(u), name: getName(u), imageUrl: userImageUrl, }, @@ -269,6 +274,7 @@ export const listHistory = query({ const display: StatusRow['user'] = { id: user._id, + email: getEmail(user), name: getName(user), imageUrl: imgUrl, }; @@ -276,13 +282,13 @@ export const listHistory = query({ return display; }; - const page: StatusRow[] = []; + const statuses: StatusRow[] = []; for (const s of result.page) { const owner = await getDisplay(s.userId); const updatedBy = s.updatedBy !== s.userId ? await getDisplay(s.updatedBy) : null; - page.push({ + statuses.push({ user: owner, status: { id: s._id, @@ -292,6 +298,9 @@ export const listHistory = query({ }, }); } + const page = statuses.sort( + (a, b) => (b.status?.updatedAt ?? 0) - (a.status?.updatedAt ?? 0), + ); return { page, diff --git a/src/components/layout/profile/avatar-upload.tsx b/src/components/layout/profile/avatar-upload.tsx index 96b3d3f..8820163 100644 --- a/src/components/layout/profile/avatar-upload.tsx +++ b/src/components/layout/profile/avatar-upload.tsx @@ -24,7 +24,7 @@ import { Loader2, Pencil, Upload, XIcon } from 'lucide-react'; import { type Id } from '~/convex/_generated/dataModel'; type AvatarUploadProps = { - preloadedUser: Preloaded, + preloadedUser: Preloaded; }; const dataUrlToBlob = async ( @@ -94,7 +94,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => { } const uploadResponse = (await result.json()) as { - storageId: Id<'_storage'>, + storageId: Id<'_storage'>; }; await updateUserImage({ storageId: uploadResponse.storageId }); @@ -136,7 +136,8 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => { size={24} /> -
{ + const [pageIndex, setPageIndex] = useState(0); + // cursor for page N is the continueCursor returned from page N-1 + const [cursors, setCursors] = useState<(string | null)[]>([null]); + + const args = useMemo(() => { + return { + paginationOpts: { + numItems: PAGE_SIZE, + cursor: cursors[pageIndex] ?? null, + }, + }; + }, [cursors, pageIndex]); + + const data = useQuery(api.statuses.listHistory, args); + + // Track loading + const isLoading = data === undefined; + + // When a page loads, cache its "next" cursor if we don't have it yet + useEffect(() => { + if (!data) return; + const nextIndex = pageIndex + 1; + setCursors((prev) => { + const copy = [...prev]; + if (copy[nextIndex] === undefined) copy[nextIndex] = data.continueCursor; + return copy; + }); + }, [data, pageIndex]); + + const canPrev = pageIndex > 0; + const canNext = !!data && data.continueCursor !== null; + + const handlePrev = () => { + if (!canPrev) return; + setPageIndex((p) => Math.max(0, p - 1)); + }; + + const handleNext = () => { + if (!canNext) return; + setPageIndex((p) => p + 1); + }; + + const rows = data?.page ?? []; + return ( +
+
+ + {isLoading ? ( +
+
+
+ ) : rows.length === 0 ? ( +
+

No history found

+
+ ) : ( + + + + Name + Status + Updated By + + Date & Time + + + + + {rows.map((r, idx) => ( + + + {r.user.name ?? 'Technician'} + + +
+ {r.status?.message ?? 'No status'} +
+
+ + {r.status?.updatedBy?.name ?? ''} + + + {r.status + ? `${formatTime(r.status.updatedAt)} · ${formatDate( + r.status.updatedAt, + )}` + : '--:-- · --/--'} + +
+ ))} +
+
+ )} + +
+ + + + { + e.preventDefault(); + handlePrev(); + }} + aria-disabled={!canPrev} + className={!canPrev ? 'pointer-events-none opacity-50' : ''} + /> +
+ Page + {pageIndex + 1} +
+ { + e.preventDefault(); + handleNext(); + }} + aria-disabled={!canNext} + className={!canNext ? 'pointer-events-none opacity-50' : ''} + /> +
+
+
+ ); +}; diff --git a/src/components/layout/status/list/index.tsx b/src/components/layout/status/list/index.tsx index d1faf44..ed91c1d 100644 --- a/src/components/layout/status/list/index.tsx +++ b/src/components/layout/status/list/index.tsx @@ -1,6 +1,6 @@ 'use client'; import Link from 'next/link'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react'; import { api } from '~/convex/_generated/api'; import { type Id } from '~/convex/_generated/dataModel'; @@ -14,11 +14,24 @@ import { DrawerTrigger, Input, SubmitButton, + Tabs, + TabsContent, + TabsList, + TabsTrigger, } from '@/components/ui'; import { toast } from 'sonner'; import { ccn, formatTime, formatDate } from '@/lib/utils'; -import { Clock, Calendar, CheckCircle2 } from 'lucide-react'; +import { + Activity, + Clock, + Calendar, + CheckCircle2, + History, + Users, + Zap, +} from 'lucide-react'; import { StatusHistory } from '@/components/layout/status'; +import { HistoryTable } from '@/components/layout/status/list/history'; type StatusListProps = { preloadedUser: Preloaded; @@ -37,10 +50,30 @@ export const StatusList = ({ const [selectAll, setSelectAll] = useState(false); const [statusInput, setStatusInput] = useState(''); const [updatingStatus, setUpdatingStatus] = useState(false); + const [animatingIds, setAnimatingIds] = useState>(new Set()); + const [previousStatuses, setPreviousStatuses] = useState(statuses); const bulkCreate = useMutation(api.statuses.bulkCreate); - const handleSelectUser = (id: Id<'users'>, e: React.MouseEvent) => { + useEffect(() => { + const newAnimatingIds = new Set(); + statuses.forEach((curr) => { + const previous = previousStatuses.find((p) => p.user.id === curr.user.id); + if (previous?.status?.updatedAt !== curr.status?.updatedAt) + newAnimatingIds.add(curr.user.id); + }); + if (newAnimatingIds.size > 0) { + setAnimatingIds(newAnimatingIds); + setTimeout(() => setAnimatingIds(new Set()), 800); + } + setPreviousStatuses( + statuses.sort( + (a, b) => (b.status?.updatedAt ?? 0) - (a.status?.updatedAt ?? 0), + ), + ); + }, [statuses]); + + const handleSelectUser = (id: Id<'users'>) => { setSelectedUserIds((prev) => prev.some((i) => i === id) ? prev.filter((prevId) => prevId !== id) @@ -62,7 +95,7 @@ export const StatusList = ({ throw new Error('Status must be between 3 & 80 characters'); if (selectedUserIds.length === 0 && user?.id) await bulkCreate({ message, userIds: [user.id] }); - await bulkCreate({ message, userIds: selectedUserIds }); + else await bulkCreate({ message, userIds: selectedUserIds }); toast.success('Status updated.'); setSelectedUserIds([]); setSelectAll(false); @@ -74,226 +107,278 @@ export const StatusList = ({ } }; + const getStatusAge = (updatedAt: number) => { + const diff = Date.now() - updatedAt; + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + if (hours > 0) return `${hours}h ${minutes}m ago`; + if (minutes > 0) return `${minutes}m ago`; + return 'Just now'; + }; + const containerCn = ccn({ context: tvMode, - className: - 'flex flex-col mx-auto items-center\ - sm:w-5/6 md:w-3/4 lg:w-2/3 xl:w-1/2 min-w-[450px]', - on: 'mt-8', - off: 'px-10', + className: 'w-full max-w-6xl mx-auto', + on: 'p-8', + off: 'px-6 py-4', + }); + + const tabsCn = ccn({ + context: tvMode, + className: 'w-full py-8', + on: 'hidden', + off: '', }); const headerCn = ccn({ context: tvMode, - className: 'w-full', + className: 'w-full mb-2', on: 'hidden', - off: 'flex mb-3 justify-between items-center', - }); - - const selectAllIconCn = ccn({ - context: selectAll, - className: 'w-4 h-4', - on: 'text-green-500', - off: '', - }); - - const cardContainerCn = ccn({ - context: tvMode, - className: 'w-full space-y-2', - on: 'text-primary', - off: '', + off: 'flex justify-end items-center', }); return (
-
-
- - {!tvMode && ( -
- Miss the old table? - - Find it here! - + + + +
+ +

Team Status

- )} -
-
+ + +
+ +

Status History

+
+
+ + +
+
+
+ + {statuses.length} members +
+
+ + Miss the old table? + +
+
+
+
+ {statuses.map((statusData) => { + const { user: u, status: s } = statusData; + const isSelected = selectedUserIds.includes(u.id); + const isAnimating = animatingIds.has(u.id); + const isUpdatedByOther = s?.updatedBy?.id !== u.id; + return ( +
handleSelectUser(u.id) : undefined} + > + {/* Selection indicator */} + {isSelected && !tvMode && ( +
+ +
+ )} -
- {statuses.map((status) => { - const { user: u, status: s } = status; - const isSelected = selectedUserIds.includes(u.id); - const isUpdatedByOther = !!s?.updatedBy; - return ( - handleSelectUser(u.id, e)} - > - -
-
- -
-
-
-
+ {/* Enhanced animation effect */} + {isAnimating && ( +
+ )} + +
+ {/* Avatar */} +
+ +
+ + {/* Main Content */} +
+

- {u.name ?? 'Technician'} + {u.name ?? u.email ?? 'User'}

-
-

{s?.message ?? 'No status yet.'}

-
+ {isUpdatedByOther && s?.updatedBy && ( +
+ + via + + + + {s.updatedBy.name ?? + s.updatedBy.email ?? + 'another user'} + +
+ )}
+
+ {s?.message ?? 'No status yet.'} +
+ + {/* Time Info */} +
+
+ + + {s ? formatTime(s.updatedAt) : '--:--'} + +
+
+ + + {s ? formatDate(s.updatedAt) : '--/--'} + +
+ {s && ( +
+ + + {getStatusAge(s.updatedAt)} + +
+ )} +
+
+ + {/* History Drawer */} + {!tvMode && ( -
-
- - - {s ? formatTime(s.updatedAt) : '--:--'} - -
-
- - - {s ? formatDate(s.updatedAt) : '--/--'} - -
- - {isUpdatedByOther && s.updatedBy && ( -
- - -
-

Updated by

- {s.updatedBy.name ?? 'User'} -
-
-
- )} -
+ History +
+ )} +
+
+ ); + })} +
+ {/* Update Status Section */} + {!tvMode && ( + + +
+
+ +

Update Status

+ {selectedUserIds.length > 0 && ( + + {selectedUserIds.length} selected + + )} +
+ +
+ setStatusInput(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === 'Enter' && + !e.shiftKey && + !updatingStatus + ) { + e.preventDefault(); + void handleUpdateStatus(); + } + }} + /> + + {selectedUserIds.length > 0 + ? `Update ${selectedUserIds.length} ${selectedUserIds.length > 1 ? 'users' : 'user'}` + : 'Update Status'} + +
+ +
+
+ +
+
+ {statusInput.length}/80 characters
- ); - })} -
- - {statuses.length === 0 && ( - -

- No status updates have been made in the past day. -

-
- )} - - {!tvMode && ( - -
-

Update Status

-
-
- setStatusInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && !updatingStatus) { - e.preventDefault(); - void handleUpdateStatus(); - } - }} - /> - - {selectedUserIds.length > 0 - ? `Update ${selectedUserIds.length} - ${selectedUserIds.length > 1 ? 'users' : 'user'}` - : 'Update Status'} - -
-
-
- - - - - - -
-
-
- )} + )} + + + + +
); }; diff --git a/src/components/layout/status/table/index.tsx b/src/components/layout/status/table/index.tsx index 36242e3..4c4dfe7 100644 --- a/src/components/layout/status/table/index.tsx +++ b/src/components/layout/status/table/index.tsx @@ -89,8 +89,8 @@ export const StatusTable = ({ const thCn = ccn({ context: tvMode, className: 'py-4 px-4 border font-semibold ', - on: 'lg:text-5xl xl:min-w-[420px]', - off: 'lg:text-4xl xl:min-w-[320px]', + on: 'lg:text-6xl xl:min-w-[420px]', + off: 'lg:text-5xl xl:min-w-[320px]', }); const tdCn = ccn({ context: tvMode, @@ -174,8 +174,10 @@ export const StatusTable = ({ className={tvMode ? 'w-16 h-16' : 'w-12 h-12'} />
-

- {u.name ?? 'Technician #' + (i+1)} +

+ {u.name ?? 'Technician #' + (i + 1)}

{s?.updatedBy && s.updatedBy.id !== u.id && (
@@ -192,19 +194,104 @@ export const StatusTable = ({
- - - - - - - - + + + {s?.message} + + + + + + +
+
+
+ + {s ? formatTime(s.updatedAt) : '--:--'} +
+
+ + {s ? formatDate(s.updatedAt) : '--:--'} +
+
+
+
+ +
+ ); })} + {statuses.length === 0 && ( +
+

+ No status updates yet +

+
+ )} + {!tvMode && ( +
+ setStatusInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && !updatingStatus) { + e.preventDefault(); + void handleUpdateStatus(); + } + }} + /> + + {selectedUserIds.length > 0 + ? `Update status for ${selectedUserIds.length} + ${selectedUserIds.length > 1 ? 'users' : 'user'}` + : 'Update status'} + +
+ )} + + {/* Global Status History Drawer */} + {!tvMode && ( +
+ + + + + + +
+ )}
); };