From 69de8703503ed2064228ce627b744f6648b83062 Mon Sep 17 00:00:00 2001 From: Craig Haseler Date: Tue, 29 Apr 2025 23:17:42 -0400 Subject: [PATCH 1/3] feat: Facilitator controlled stopwatch function that is synced across all participant screens. --- web/app/api/stopwatch/control/route.ts | 195 +++++++ web/app/api/stopwatch/state/route.ts | 42 ++ web/app/api/stopwatch/stream/route.ts | 95 ++++ web/app/utils/stopwatchState.ts | 222 ++++++++ .../stopwatch/StopwatchAdminControls.tsx | 72 +++ .../stopwatch/StopwatchContainer.tsx | 69 +++ web/components/stopwatch/StopwatchDisplay.tsx | 17 + .../stopwatch/StopwatchUserCount.tsx | 17 + web/components/stopwatch/documentation.md | 160 ++++++ web/components/stopwatch/stopwatch.tsx | 524 ++++++++++++++++++ 10 files changed, 1413 insertions(+) create mode 100644 web/app/api/stopwatch/control/route.ts create mode 100644 web/app/api/stopwatch/state/route.ts create mode 100644 web/app/api/stopwatch/stream/route.ts create mode 100644 web/app/utils/stopwatchState.ts create mode 100644 web/components/stopwatch/StopwatchAdminControls.tsx create mode 100644 web/components/stopwatch/StopwatchContainer.tsx create mode 100644 web/components/stopwatch/StopwatchDisplay.tsx create mode 100644 web/components/stopwatch/StopwatchUserCount.tsx create mode 100644 web/components/stopwatch/documentation.md create mode 100644 web/components/stopwatch/stopwatch.tsx diff --git a/web/app/api/stopwatch/control/route.ts b/web/app/api/stopwatch/control/route.ts new file mode 100644 index 00000000..85fb829a --- /dev/null +++ b/web/app/api/stopwatch/control/route.ts @@ -0,0 +1,195 @@ +// web/app/api/stopwatch/control/route.ts +import { type NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { + isAdmin, + isLeadForAssessment, + isFacForAssessment, +} from '@/app/(frontend)/(logged-in)/utils/permissions'; +import { + getCountdownState, + updateCountdownState, + broadcastCountdownState, + type CountdownState, +} from '@/app/utils/stopwatchState'; + +interface ControlPayload { + assessmentId: string; + action: 'start' | 'pause' | 'reset' | 'add_time'; + duration?: number; + timeToAddMs?: number; +} + +export async function POST(req: NextRequest) { + const session = await auth(); + + if (!session?.user?.id) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + let payload: ControlPayload; + try { + payload = await req.json(); + } catch (error) { + return new NextResponse('Invalid JSON body', { status: 400 }); + } + + const { assessmentId, action, duration, timeToAddMs } = payload; + + if (!assessmentId || !action) { + return new NextResponse('Missing assessmentId or action in payload', { + status: 400, + }); + } + + const isUserAdmin = isAdmin(session); + const isUserLeadFac = isLeadForAssessment(session, assessmentId); + const isUserFac = isFacForAssessment(session, assessmentId); + + const canControl = isUserAdmin || isUserLeadFac || isUserFac; + + if (!canControl) { + return new NextResponse( + JSON.stringify({ + error: + 'User is not authorized to control the stopwatch for this assessment.', + }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + const state = getCountdownState(assessmentId); + + switch (action) { + case 'reset': { + if (typeof duration !== 'number' || duration <= 0) { + return new NextResponse( + 'Missing or invalid duration for reset action', + { status: 400 }, + ); + } + updateCountdownState(assessmentId, { + duration: duration, + startTime: null, + pausedAt: null, + remainingDurationOnPause: duration, // Initially, remaining is the full duration + isRunning: false, + }); + broadcastCountdownState(assessmentId); + return new NextResponse('Timer reset successfully', { status: 200 }); + } + + case 'start': { + let currentDuration = state.duration; + // If duration isn't set, default to 4 minutes (240000 ms) + if (currentDuration === null) { + currentDuration = 4 * 60 * 1000; + updateCountdownState(assessmentId, { + duration: currentDuration, + startTime: null, + pausedAt: null, + remainingDurationOnPause: currentDuration, // Full duration remaining + isRunning: false, + }); + } + + if (state.isRunning) { + return new NextResponse('Timer is already running', { status: 200 }); + } + + let newStartTime: number; + if (state.remainingDurationOnPause !== null && currentDuration !== null) { + const elapsedBeforePause = + currentDuration - state.remainingDurationOnPause; + newStartTime = Date.now() - elapsedBeforePause; + } else { + newStartTime = Date.now(); + } + + updateCountdownState(assessmentId, { + duration: currentDuration, + startTime: newStartTime, + pausedAt: null, + remainingDurationOnPause: null, + isRunning: true, + }); + + broadcastCountdownState(assessmentId); + return new NextResponse('Timer started successfully', { status: 200 }); + } + + case 'pause': { + if (state.duration === null) { + return new NextResponse('Timer duration not set.', { status: 400 }); + } + if (!state.isRunning) { + return new NextResponse('Timer is not running', { status: 200 }); // Or 409? + } + if (state.startTime === null) { + return new NextResponse('Internal timer state error', { + status: 500, + }); + } + + const pausedTimestamp = Date.now(); + const elapsed = pausedTimestamp - state.startTime; + const remainingDuration = Math.max(0, state.duration - elapsed); + + updateCountdownState(assessmentId, { + pausedAt: pausedTimestamp, + remainingDurationOnPause: remainingDuration, + isRunning: false, + }); + broadcastCountdownState(assessmentId); + return new NextResponse('Timer paused successfully', { status: 200 }); + } + + case 'add_time': { + const timeToAdd = timeToAddMs ?? 60000; // Default to 1 minute if not specified + if (typeof timeToAdd !== 'number' || timeToAdd <= 0) { + return new NextResponse( + 'Invalid timeToAddMs for add_time action', + { status: 400 }, + ); + } + + let newDuration: number; + let newRemainingDuration: number | null = null; + let newStateChanges: Partial = {}; + + const baseDuration = state.duration ?? (4 * 60 * 1000); // Default to 4 mins if unset + + if (state.isRunning && state.startTime !== null) { + newDuration = baseDuration + timeToAdd; + // Keep the same elapsed time by adjusting startTime backwards + const currentElapsed = Date.now() - state.startTime; + const newStartTime = Date.now() - currentElapsed; // Keep elapsed the same + + newStateChanges = { + duration: newDuration, + startTime: newStartTime, // Adjust start time to reflect added duration + }; + + } else { + const currentRemaining = state.remainingDurationOnPause ?? baseDuration; + newRemainingDuration = currentRemaining + timeToAdd; + newDuration = baseDuration + timeToAdd; // Also increase the base duration + + newStateChanges = { + duration: newDuration, + remainingDurationOnPause: newRemainingDuration, + }; + } + + updateCountdownState(assessmentId, newStateChanges); + broadcastCountdownState(assessmentId); + return new NextResponse('Time added successfully', { status: 200 }); + } + + default: + return new NextResponse('Invalid action specified', { status: 400 }); + } +} \ No newline at end of file diff --git a/web/app/api/stopwatch/state/route.ts b/web/app/api/stopwatch/state/route.ts new file mode 100644 index 00000000..03aa7790 --- /dev/null +++ b/web/app/api/stopwatch/state/route.ts @@ -0,0 +1,42 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { + getCountdownState, + type CountdownState, +} from '@/app/utils/stopwatchState'; + +// Removed unused calculateRemainingDuration function + +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session?.user?.id) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const searchParams = req.nextUrl.searchParams; + const assessmentId = searchParams.get('assessmentId'); + + if (!assessmentId) { + return new NextResponse('Missing assessmentId query parameter', { status: 400 }); + } + + try { + const state = getCountdownState(assessmentId); + // No need to calculate remainingDuration here, client does that. + const clientCount = state.clients.size; + + // Return the full state needed by the client, consistent with SSE format + const currentState = { + duration: state.duration, + startTime: state.startTime, // Add startTime + pausedAt: state.pausedAt, // Add pausedAt + isRunning: state.isRunning, + clientCount, + }; + + return NextResponse.json(currentState); + + } catch (error) { + return new NextResponse('Internal Server Error', { status: 500 }); + } +} \ No newline at end of file diff --git a/web/app/api/stopwatch/stream/route.ts b/web/app/api/stopwatch/stream/route.ts new file mode 100644 index 00000000..e941cf6a --- /dev/null +++ b/web/app/api/stopwatch/stream/route.ts @@ -0,0 +1,95 @@ +// web/app/api/stopwatch/stream/route.ts +import { type NextRequest } from 'next/server'; +import { + getCountdownState, + removeClient, + sendKeepAlive, + // calculateCurrentRemainingDuration, // No longer needed here + broadcastCountdownState, // Import broadcast function +} from '@/app/utils/stopwatchState'; + +const KEEP_ALIVE_INTERVAL = 30000; + + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + const assessmentId = searchParams.get('assessmentId'); + + if (!assessmentId) { + return new Response('Missing assessmentId query parameter', { status: 400 }); + } + + let controller: ReadableStreamDefaultController | null = null; + let keepAliveIntervalId: NodeJS.Timeout | null = null; + + const stream = new ReadableStream({ + start(streamController) { + controller = streamController; + const encoder = new TextEncoder(); + const state = getCountdownState(assessmentId); + state.clients.add(controller); + try { + controller.enqueue(encoder.encode('retry: 1000\n\n')); + + // Send the *full relevant server state* needed by the client reducer + // Consistent with broadcastCountdownState + const initialStateData = { + // remainingDuration: calculateCurrentRemainingDuration(state), // Client can calculate + isRunning: state.isRunning, + clientCount: state.clients.size, + duration: state.duration, + startTime: state.startTime, // Include startTime + pausedAt: state.pausedAt, // Include pausedAt + }; + controller.enqueue( + encoder.encode( + `event: update\ndata: ${JSON.stringify(initialStateData)}\n\n`, + ), + ); + // Broadcast the updated state (mainly for client count) to everyone + broadcastCountdownState(assessmentId); + } catch (error) { + // If sending initial state fails, remove the client and stop + removeClient(assessmentId, controller); + // Also broadcast the removal in case the count was briefly incremented + broadcastCountdownState(assessmentId); + return; // Stop further processing for this stream + } + + // Setup keep-alive after successful initial send and broadcast + keepAliveIntervalId = setInterval(() => { + if (!controller) { + if (keepAliveIntervalId) clearInterval(keepAliveIntervalId); + return; + } + try { + sendKeepAlive(controller, encoder); + } catch (error) { + if (keepAliveIntervalId) clearInterval(keepAliveIntervalId); + removeClient(assessmentId, controller); + } + }, KEEP_ALIVE_INTERVAL); + + req.signal.addEventListener('abort', () => { + if (keepAliveIntervalId) clearInterval(keepAliveIntervalId); + if (controller) { + removeClient(assessmentId, controller); + } + }); + }, + cancel(reason) { + if (keepAliveIntervalId) clearInterval(keepAliveIntervalId); + if (assessmentId && controller) { + removeClient(assessmentId, controller); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} \ No newline at end of file diff --git a/web/app/utils/stopwatchState.ts b/web/app/utils/stopwatchState.ts new file mode 100644 index 00000000..72a6503d --- /dev/null +++ b/web/app/utils/stopwatchState.ts @@ -0,0 +1,222 @@ +// web/app/utils/stopwatchState.ts + +/** + * Represents the state of a single countdown timer instance. + */ +export interface CountdownState { + duration: number | null; // Total duration in milliseconds + startTime: number | null; // Timestamp when the timer was started or resumed + pausedAt: number | null; // Timestamp when the timer was paused + remainingDurationOnPause: number | null; // Remaining duration when paused (ms) + isRunning: boolean; + clients: Set>; // Connected SSE clients +} + +/** + * In-memory store for all active countdown timer states, keyed by assessmentId. + */ +declare global { + // eslint-disable-next-line no-var + var __countdownStates__: Map | undefined; +} + +const countdownStates = globalThis.__countdownStates__ ?? new Map(); +if (process.env.NODE_ENV !== 'production') globalThis.__countdownStates__ = countdownStates; + +/** + * Retrieves the state for a given assessmentId, creating it if it doesn't exist. + * @param assessmentId - The ID of the assessment. + * @returns The state object for the assessment. + */ +export function getCountdownState(assessmentId: string): CountdownState { + if (!countdownStates.has(assessmentId)) { + countdownStates.set(assessmentId, { + duration: null, + startTime: null, + pausedAt: null, + remainingDurationOnPause: null, + isRunning: false, + clients: new Set(), + }); + } + // Non-null assertion is safe because we just created it if it didn't exist. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return countdownStates.get(assessmentId)!; +} + +/** + * Calculates the current remaining duration based on the timer's state. + * @param state - The CountdownState object. + * @returns The remaining duration in milliseconds. Returns 0 if duration is not set. + */ +export function calculateCurrentRemainingDuration(state: CountdownState): number { + if (state.duration === null) { + return 0; + } + + if (!state.isRunning) { + return state.remainingDurationOnPause ?? state.duration; + } + + if (state.startTime === null) { + // Should not happen if isRunning is true, but handle defensively + return state.duration; + } + + const elapsed = Date.now() - state.startTime; + const remaining = state.duration - elapsed; + + return Math.max(0, remaining); +} + +/** + * Encodes and sends an SSE message using the stream controller. + * @param controller - The ReadableStreamDefaultController for the client. + * @param encoder - A TextEncoder instance. + * @param event - The event name. + * @param data - The data payload (will be JSON.stringify'd). + */ +function enqueueEvent( + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + event: string, + data: unknown, +): void { + try { + // Check if the stream is still writable before attempting to enqueue + // desiredSize will be null if closed or errored, or <= 0 if backpressure applies + if (controller.desiredSize === null || controller.desiredSize <= 0) { + return; + } + controller.enqueue(encoder.encode(`event: ${event}\n`)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + } catch (error) { + // If enqueue fails, the stream is likely broken or closed. + // Attempt to close it from our side. + try { + controller.close(); + } catch { + // Ignore errors closing an already potentially broken controller + } + // We should ensure this controller is removed from the active set, + // but that responsibility lies with the caller (broadcastCountdownState or keepAlive) + // which has the assessmentId context. + // Re-throw the error to signal the caller to remove the client. + throw error; + } +} + +/** + * Broadcasts the current countdown state (remaining duration and running status) + * to all connected clients for a specific assessment using the 'update' event. + * @param assessmentId - The ID of the assessment. + */ +export function broadcastCountdownState(assessmentId: string): void { + const state = countdownStates.get(assessmentId); + if (!state) { + return; + } + + const remainingDuration = calculateCurrentRemainingDuration(state); + const clientCount = state.clients.size; + // Send the *full relevant server state* needed by the client reducer + const dataToSend = { + // remainingDuration, // Client can calculate this now + isRunning: state.isRunning, + clientCount, + duration: state.duration, + startTime: state.startTime, + pausedAt: state.pausedAt, + }; + + // console.log(`[Broadcast ${assessmentId}] Broadcasting state (Clients: ${clientCount}):`, dataToSend); // REMOVED DEBUG LOG + const encoder = new TextEncoder(); + const controllersToRemove = new Set< + ReadableStreamDefaultController + >(); + + state.clients.forEach((controller) => { + try { + // console.log(`[Broadcast ${assessmentId}] Attempting to send to client controller...`); // REMOVED DEBUG LOG + // Always use the 'update' event for broadcasting state + enqueueEvent(controller, encoder, 'update', dataToSend); + // console.log(`[Broadcast ${assessmentId}] Send successful for one client.`); // REMOVED DEBUG LOG + } catch (error) { + // enqueueEvent now throws on failure, indicating the controller should be removed. + // console.error(`[Broadcast ${assessmentId}] Error sending to client controller:`, error); // REMOVED DEBUG LOG + controllersToRemove.add(controller); + } + }); + + controllersToRemove.forEach((controller) => { + removeClient(assessmentId, controller); + }); +} + +/** + * Removes a client controller from the set for a given assessmentId and closes its stream. + * @param assessmentId - The ID of the assessment. + * @param controller - The controller to remove. + */ +export function removeClient( + assessmentId: string, + controller: ReadableStreamDefaultController, +): void { + const state = countdownStates.get(assessmentId); + if (state && state.clients.has(controller)) { + state.clients.delete(controller); + try { + controller.close(); + } catch (error) { + // Ignore errors if the controller is already closed or errored + } + // After successfully removing a client, broadcast the new state (mainly client count) + broadcastCountdownState(assessmentId); + + } else if (state) { + } +} + +/** + * Sends a keep-alive message using the stream controller. + * @param controller - The ReadableStreamDefaultController for the client. + * @param encoder - A TextEncoder instance. + */ +export function sendKeepAlive( + controller: ReadableStreamDefaultController, + encoder: TextEncoder, +): void { + try { + // Check desiredSize before sending keep-alive as well + if (controller.desiredSize === null || controller.desiredSize <= 0) { + throw new Error('Stream not writable for keep-alive'); // Throw to signal failure + } + // SSE comments start with ':' + controller.enqueue(encoder.encode(': keep-alive\n\n')); + } catch (error) { + // If sending keep-alive fails, assume the client is gone. + try { + controller.close(); + } catch { + // Ignore errors closing an already potentially broken controller + } + // Re-throw the error so the interval knows it failed and can trigger removal. + throw error; + } +} + +// Added function to update state - useful for the control API +/** + * Updates the state for a given assessmentId. + * @param assessmentId - The ID of the assessment. + * @param updates - Partial state updates. + */ +export function updateCountdownState( + assessmentId: string, + updates: Partial, +): CountdownState { + const state = getCountdownState(assessmentId); + const newState = { ...state, ...updates }; + countdownStates.set(assessmentId, newState); + return newState; +} \ No newline at end of file diff --git a/web/components/stopwatch/StopwatchAdminControls.tsx b/web/components/stopwatch/StopwatchAdminControls.tsx new file mode 100644 index 00000000..d2621669 --- /dev/null +++ b/web/components/stopwatch/StopwatchAdminControls.tsx @@ -0,0 +1,72 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Play, Pause, RotateCcw, Plus } from 'lucide-react'; + +interface StopwatchAdminControlsProps { + isRunning: boolean; + remainingTime: number | null; + customDurationInput: string; + onTogglePlayPause: () => Promise; + onReset: () => void; + onAddMinute: () => void; + onPresetDuration: (minutes: number) => void; + onCustomDurationInputChange: (e: React.ChangeEvent) => void; + onSetCustomDuration: () => void; +} + +const StopwatchAdminControls: React.FC = ({ + isRunning, + remainingTime, + customDurationInput, + onTogglePlayPause, + onReset, + onAddMinute, + onPresetDuration, + onCustomDurationInputChange, + onSetCustomDuration, +}) => { + return ( +
+
+ + + +
+ + {!isRunning && ( + <> + {/* Row 2: Presets */} +
+ + + +
+ + {/* Row 3: Custom Input */} +
+ + +
+ + )} +
+ ); +}; + +export default StopwatchAdminControls; \ No newline at end of file diff --git a/web/components/stopwatch/StopwatchContainer.tsx b/web/components/stopwatch/StopwatchContainer.tsx new file mode 100644 index 00000000..d96cb3c1 --- /dev/null +++ b/web/components/stopwatch/StopwatchContainer.tsx @@ -0,0 +1,69 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Settings, Minimize2, Timer } from 'lucide-react'; + +interface StopwatchContainerProps { + isMinimized: boolean; + canControl: boolean; + showAdminControls: boolean; + onToggleMinimize: () => void; + onToggleAdminControls: () => void; + isFlashing: boolean; + children: React.ReactNode; +} + +const StopwatchContainer: React.FC = ({ + isMinimized, + canControl, + showAdminControls, + onToggleMinimize, + onToggleAdminControls, + isFlashing, + children +}) => { + // Minimized view + if (isMinimized) { + return ( +
+ +
+ ); + } + + // Full view + return ( +
+ + + {canControl && ( + + )} + + {children} +
+ ); +}; + +export default StopwatchContainer; \ No newline at end of file diff --git a/web/components/stopwatch/StopwatchDisplay.tsx b/web/components/stopwatch/StopwatchDisplay.tsx new file mode 100644 index 00000000..362929a9 --- /dev/null +++ b/web/components/stopwatch/StopwatchDisplay.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface StopwatchDisplayProps { + displayTimeString: string; +} + +const StopwatchDisplay: React.FC = ({ displayTimeString }) => { + return ( +
+ + {displayTimeString} + +
+ ); +}; + +export default StopwatchDisplay; \ No newline at end of file diff --git a/web/components/stopwatch/StopwatchUserCount.tsx b/web/components/stopwatch/StopwatchUserCount.tsx new file mode 100644 index 00000000..8d3512ce --- /dev/null +++ b/web/components/stopwatch/StopwatchUserCount.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Users } from 'lucide-react'; + +interface StopwatchUserCountProps { + count: number; +} + +const StopwatchUserCount: React.FC = ({ count }) => { + return ( +
+ + {count} {count === 1 ? 'user' : 'users'} connected +
+ ); +}; + +export default StopwatchUserCount; \ No newline at end of file diff --git a/web/components/stopwatch/documentation.md b/web/components/stopwatch/documentation.md new file mode 100644 index 00000000..ef5a9fe0 --- /dev/null +++ b/web/components/stopwatch/documentation.md @@ -0,0 +1,160 @@ +# Stopwatch Feature Documentation + +## Overview + +The Stopwatch feature provides a real-time synchronized timer for each assessment that can be controlled by facilitators. This component enables coordinated timing across all participants in an assessment, ensuring everyone stays synchronized during timed activities. + +## Architecture + +### Frontend Components +- Built using Next.js 15 App Router and React Components +- Main component: `Stopwatch.tsx` with subcomponents for modular functionality +- Uses Server-Sent Events (SSE) via `EventSource` for real-time updates + +### Backend Structure +- Implemented using Next.js API Routes (`/api/stopwatch/*`) +- Utilizes SSE stream for real-time state broadcasting +- Manages state in-memory for immediate responsiveness + +### Communication Flow +1. Client connects to SSE stream for real-time updates +2. Control actions sent via POST requests +3. Server broadcasts state updates through SSE +4. All clients receive synchronized updates + +## Backend Details + +### State Management (`stopwatchState.ts`) + +The state management system uses a `CountdownState` interface and maintains state in an in-memory `Map` keyed by `assessmentId`. The system uses `globalThis` to ensure consistent state across development environments. + +Key Functions: +- `getCountdownState`: Retrieves current state for an assessment +- `updateCountdownState`: Modifies timer state +- `removeClient`: Handles client disconnection +- `broadcastCountdownState`: Notifies all clients of state changes +- `calculateCurrentRemainingDuration`: Computes current remaining time + +### SSE Stream (`/api/stopwatch/stream/route.ts`) + +Handles real-time communication with clients: +- Processes GET requests for stream initialization +- Manages client connections in the `clients` set +- Sends initial state on connection +- Maintains connection with keep-alive messages +- Handles client disconnection via `req.signal.onabort` + +### Control Endpoint (`/api/stopwatch/control/route.ts`) + +Manages stopwatch control operations: +- Handles POST requests for timer control +- Required parameters: + - `assessmentId`: Identifies the specific assessment + - `action`: Supports `start`, `pause`, `reset`, `add_time` + - Optional: `duration` or `timeToAddMs` for specific actions +- Performs authorization checks (admin/facilitator only) +- Updates state and triggers broadcasts + +## Frontend Details + +### Main Component (`Stopwatch.tsx`) + +Core functionality: +- Uses `useParams` to get `assessmentId` +- State management: + - `useReducer` for server state and UI flags + - `useState` for display time and flash effect +- Effect handlers: + - Permissions verification + - SSE connection management + - Display updates via `requestAnimationFrame` +- Event handlers for control actions + +### Subcomponents + +1. `StopwatchContainer.tsx` + - Manages layout structure + - Handles minimize/maximize states + - Implements flash effect background + +2. `StopwatchDisplay.tsx` + - Renders formatted time string + - Pure presentation component + +3. `StopwatchAdminControls.tsx` + - Contains control buttons and inputs + - Available only to facilitators + +4. `StopwatchUserCount.tsx` + - Shows number of connected users + - Updates in real-time + +### State Flow +1. User initiates action +2. Handler triggers `sendControlAction` +3. API request sent to server +4. Server updates internal state +5. `broadcastCountdownState` notifies all clients +6. Clients receive 'update' via SSE +7. Reducer updates `serverState` +8. `requestAnimationFrame` recalculates time +9. `displayTimeString` updates +10. UI reflects changes + +### Flash Effect Implementation +- Logic in `Stopwatch.tsx` detects specific time marks +- Uses `useState` for flash state management +- `setTimeout` controls flash duration +- `StopwatchContainer` applies visual effect + +## Key Concepts + +1. Server-Sent Events (SSE) + - Enables efficient real-time updates + - Maintains persistent connection + - Reduces server load compared to polling + +2. Single Source of Truth + - Server maintains authoritative state + - Prevents desynchronization issues + - Ensures consistency across clients + +3. Client-Side Time Calculation + - Uses `requestAnimationFrame` for smooth updates + - Reduces server load + - Maintains visual accuracy + +4. In-Memory State Considerations + - Fast access and updates + - Not persistent across server restarts + - Limited by single-server architecture + +## Integration + +- Component designed for assessment layouts +- Typically used in `[assessmentId]/layout.tsx` +- Requires `assessmentId` in URL parameters +- Integrates with assessment permission system + +## Future Enhancement Opportunities + +1. State Persistence + - Implement Redis backing store + - Enable horizontal scaling + - Provide restart recovery + +2. Additional Features + - Countdown mode + - Multiple timer support + - Custom alert thresholds + - Advanced notification options + +3. Performance Optimizations + - WebSocket alternative for bi-directional communication + - Client-side state prediction + - Reduced broadcast frequency + +4. Reliability Improvements + - Automatic reconnection handling + - Offline mode support + - State reconciliation mechanisms \ No newline at end of file diff --git a/web/components/stopwatch/stopwatch.tsx b/web/components/stopwatch/stopwatch.tsx new file mode 100644 index 00000000..63ca1035 --- /dev/null +++ b/web/components/stopwatch/stopwatch.tsx @@ -0,0 +1,524 @@ +'use client'; + +import React, { useEffect, useRef, useCallback, useReducer, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { useUser } from '@clerk/nextjs'; +import { toast } from '@/components/ui/use-toast'; +import { isAdmin, isFacForAssessment } from '@/app/(frontend)/(logged-in)/utils/permissions'; +import StopwatchAdminControls from './StopwatchAdminControls'; +import StopwatchDisplay from './StopwatchDisplay'; +import StopwatchContainer from './StopwatchContainer'; +import StopwatchUserCount from './StopwatchUserCount'; + +const formatTime = (ms: number | null | undefined): string => { + if (ms === null || ms === undefined || ms < 0) { + return '--:--'; + } + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart( + 2, + '0', + )}`; +}; + +// Parses duration input string (e.g., "5", "5.5", "5:20") into milliseconds +const parseDurationInput = (input: string): number | null => { + input = input.trim(); + if (!input) return null; + + if (input.includes(':')) { + const parts = input.split(':'); + if (parts.length === 2 && parts[0] !== undefined && parts[1] !== undefined) { + const minutes = parseInt(parts[0], 10); + const seconds = parseInt(parts[1], 10); + if (!isNaN(minutes) && !isNaN(seconds) && seconds >= 0 && seconds < 60 && minutes >= 0) { + return (minutes * 60 + seconds) * 1000; + } + } + } + + const minutesFloat = parseFloat(input); + if (!isNaN(minutesFloat) && minutesFloat > 0) { + return minutesFloat * 60 * 1000; + } + + return null; +}; + + +interface ServerState { + duration: number | null; + startTime: number | null; + pausedAt: number | null; + isRunning: boolean; +} + +interface StopwatchState { + // isInitialized: boolean; // Removed - we use serverState === null to check if initial state arrived + serverState: ServerState | null; + canControl: boolean; + showAdminControls: boolean; + isMinimized: boolean; + connectedClientCount: number; + customDurationInput: string; +} + +type StopwatchAction = + // | { type: 'INITIALIZE'; payload: { ... } } // Removed INITIALIZE action type + | { type: 'SET_CAN_CONTROL'; payload: boolean } + | { type: 'UPDATE_FROM_SSE'; payload: { // This now handles initial state too + duration?: number | null; + startTime?: number | null; + pausedAt?: number | null; + isRunning: boolean; + clientCount: number; + } } + | { type: 'TOGGLE_ADMIN_CONTROLS' } + | { type: 'TOGGLE_MINIMIZE' } + | { type: 'SET_CUSTOM_DURATION_INPUT'; payload: string }; + +const initialState: StopwatchState = { + // isInitialized: false, // Removed + serverState: null, // Initial state is null until first SSE message + canControl: false, + showAdminControls: false, + isMinimized: false, + connectedClientCount: 0, + customDurationInput: '5', +}; + +const stopwatchReducer = (state: StopwatchState, action: StopwatchAction): StopwatchState => { + switch (action.type) { + // case 'INITIALIZE': { ... } // Removed INITIALIZE case + + case 'SET_CAN_CONTROL': + return { ...state, canControl: action.payload }; + + case 'UPDATE_FROM_SSE': { + // This action now handles both initial state and subsequent updates. + // No need for isInitialized check anymore. + + // Update the full server state based on SSE payload + const serverState: ServerState = { + duration: action.payload.duration ?? state.serverState?.duration ?? null, + startTime: action.payload.startTime ?? state.serverState?.startTime ?? null, + pausedAt: action.payload.pausedAt ?? state.serverState?.pausedAt ?? null, + isRunning: action.payload.isRunning + }; + return { + ...state, + serverState, // Set/update serverState + connectedClientCount: action.payload.clientCount, + // isInitialized: true // No longer needed + }; + } + + case 'TOGGLE_ADMIN_CONTROLS': + return { ...state, showAdminControls: !state.showAdminControls }; + + case 'TOGGLE_MINIMIZE': + return { ...state, isMinimized: !state.isMinimized }; + + case 'SET_CUSTOM_DURATION_INPUT': + return { ...state, customDurationInput: action.payload }; + + default: + return state; + } +}; + + +const Stopwatch: React.FC = () => { + const params = useParams(); + const assessmentId = params.assessmentId as string | undefined; + const { user } = useUser(); + const eventSourceRef = useRef(null); + const animationFrameRef = useRef(null); + const prevSecondsRef = useRef(null); + const flashTimeoutRef = useRef([]); + const hasZeroFlashedRef = useRef(false); + const [displayTimeString, setDisplayTimeString] = useState('--:--'); + const [isFlashing, setIsFlashing] = useState(false); + + // Helper function to calculate remaining time from server state + const calculateRemainingTime = (state: ServerState): number | null => { + if (!state.duration) return null; + if (!state.isRunning) return state.duration; + + if (state.startTime) { + const elapsed = Date.now() - state.startTime; + return Math.max(0, state.duration - elapsed); + } + + return state.duration; + }; + + // Use reducer for state management + const [state, dispatch] = useReducer(stopwatchReducer, initialState); + const { + // isInitialized, // Removed + serverState, + canControl, + showAdminControls, + isMinimized, + connectedClientCount, + customDurationInput, + } = state; + + // Derive values from serverState + const isRunning = serverState?.isRunning ?? false; + const remainingTime = serverState ? calculateRemainingTime(serverState) : null; + + // Effect to determine control permissions + useEffect(() => { + const userMetadata = user?.publicMetadata; + if (userMetadata && assessmentId) { + const controlPermission = + isAdmin({ user: userMetadata }) || + isFacForAssessment({ user: userMetadata }, assessmentId); + dispatch({ type: 'SET_CAN_CONTROL', payload: controlPermission }); + } else { + dispatch({ type: 'SET_CAN_CONTROL', payload: false }); + } + }, [user, assessmentId]); + + // Removed the separate useEffect hook for fetching initial state via HTTP. + // State initialization is now handled by the first message from the SSE stream. + + // Effect for SSE Connection + useEffect(() => { + if (!assessmentId) return; + + const connectSSE = () => { + const url = `/api/stopwatch/stream?assessmentId=${assessmentId}`; + eventSourceRef.current = new EventSource(url); + + eventSourceRef.current.onopen = () => {}; + + eventSourceRef.current.onerror = () => {}; + + eventSourceRef.current.addEventListener('update', (event) => { + try { + const data = JSON.parse(event.data); + // console.log('[Stopwatch SSE] Received "update" event. Data:', data); // REMOVED DEBUG LOG + dispatch({ + type: 'UPDATE_FROM_SSE', + payload: { + duration: data.duration ?? null, + startTime: data.startTime ?? null, + pausedAt: data.pausedAt ?? null, + isRunning: data.isRunning ?? false, + clientCount: data.clientCount ?? 0, + } + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to process timer update.', + variant: 'destructive', + }); + } + }); + + eventSourceRef.current.onerror = (error) => { + // Attempt to reconnect or handle error appropriately + eventSourceRef.current?.close(); + // Maybe add a delay and retry connection here? + }; + }; + + connectSSE(); + + // Cleanup function + return () => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, [assessmentId]); + + // Effect for animation frame-based timer updates + useEffect(() => { + // Start ticking only after the initial state has arrived via SSE + if (serverState === null) return; + + const tick = () => { + if (!serverState) { + setDisplayTimeString('--:--'); + return; + } + + if (serverState.isRunning && serverState.startTime && serverState.duration) { + const now = Date.now(); + const elapsed = now - serverState.startTime; + const remaining = Math.max(0, serverState.duration - elapsed); + const currentSeconds = Math.floor(remaining / 1000); + + // Store previous seconds before we check for zero + const prevSeconds = prevSecondsRef.current; + prevSecondsRef.current = currentSeconds; + + // Detect minute mark transitions + // Only flash if we have a previous value and we're not just starting + if (prevSeconds !== null && + elapsed > 1000 && // Ensure we're not just starting + Math.floor(prevSeconds % 60) === 0 && + Math.floor(currentSeconds % 60) === 59 && + remaining > 0) { + // Clear any existing timeouts + flashTimeoutRef.current.forEach(timeout => clearTimeout(timeout)); + flashTimeoutRef.current = []; + + // Trigger minute flash + setIsFlashing(true); + const timeout = setTimeout(() => setIsFlashing(false), 1000); + flashTimeoutRef.current.push(timeout); + } + + // Detect transition to zero - check if we just crossed the zero threshold + if (currentSeconds === 0 && typeof prevSeconds === 'number' && prevSeconds > 0) { + // Clear any existing timeouts + flashTimeoutRef.current.forEach(timeout => clearTimeout(timeout)); + flashTimeoutRef.current = []; + + // Original triple flash sequence + setIsFlashing(true); + const timeouts = [ + setTimeout(() => setIsFlashing(false), 300), // Flash 1 off + setTimeout(() => setIsFlashing(true), 400), // Flash 2 on + setTimeout(() => setIsFlashing(false), 700), // Flash 2 off + setTimeout(() => setIsFlashing(true), 800), // Flash 3 on + setTimeout(() => setIsFlashing(false), 1100), // Flash 3 off + ]; + + flashTimeoutRef.current = timeouts; + } + + const formattedTime = formatTime(remaining); + setDisplayTimeString(formattedTime); + + // Continue animation frame for a short while after zero to ensure flash completes + if (remaining > 0 || (remaining === 0 && flashTimeoutRef.current.length > 0)) { + animationFrameRef.current = requestAnimationFrame(tick); + } + } else { + // Timer is not running (paused or reset) + let timeToDisplay: number | null = null; + if (serverState.pausedAt && serverState.startTime && serverState.duration) { + // Timer is PAUSED: Calculate remaining time at the moment of pause + const elapsedBeforePause = serverState.pausedAt - serverState.startTime; + timeToDisplay = Math.max(0, serverState.duration - elapsedBeforePause); + // console.log('[Stopwatch Tick] Paused. Using calculated remaining time at pause (ms):', timeToDisplay); // REMOVED DEBUG LOG + } else { + // Timer is RESET or in initial state: Use the full duration + timeToDisplay = serverState.duration ?? null; + // console.log('[Stopwatch Tick] Reset/Initial. Using duration (ms):', timeToDisplay); // REMOVED DEBUG LOG + } + const formattedTime = formatTime(timeToDisplay); + // console.log('[Stopwatch Tick] Not Running. Setting displayTimeString:', formattedTime); // REMOVED DEBUG LOG + setDisplayTimeString(formattedTime); + } + }; + + tick(); + + // Cleanup function + return () => { + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + // Clear any active flash timeouts + flashTimeoutRef.current.forEach(timeout => clearTimeout(timeout)); + flashTimeoutRef.current = []; + }; + // serverState is now the dependency that triggers starting/updating the tick loop + }, [serverState]); + + const sendControlAction = useCallback( + async ( + action: 'start' | 'pause' | 'reset' | 'add_time', + payload?: { duration?: number; timeToAddMs?: number }, + ) => { + if (!assessmentId) { + toast({ + title: 'Error', + description: 'Assessment ID is missing.', + variant: 'destructive', + }); + return false; + } + + const bodyPayload = { + assessmentId, + action, + ...payload, + }; + + + try { + const response = await fetch('/api/stopwatch/control', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(bodyPayload), + }); + + if (!response.ok) { + let errorText = `HTTP error! status: ${response.status}`; + try { + const text = await response.text(); + if (text) { + errorText = `HTTP error! status: ${response.status} - ${text}`; + } + } catch (textError) { + } + throw new Error(errorText); + } + return true; + } catch (error) { + toast({ + title: 'API Error', + description: `Failed to ${action} stopwatch. ${ + error instanceof Error ? error.message : String(error) + }`, + variant: 'destructive', + }); + return false; + } + }, + [assessmentId], // sendControlAction depends only on assessmentId + ); + + // Handler to set a specific duration (used by presets and custom input) + const handleSetDuration = useCallback(async (durationMs: number) => { + await sendControlAction('reset', { duration: durationMs }); + // State updates handled by SSE via reducer + }, [sendControlAction]); + + // Handler for the custom duration input field + const handleCustomDurationInputChange = (e: React.ChangeEvent) => { + dispatch({ type: 'SET_CUSTOM_DURATION_INPUT', payload: e.target.value }); + }; + + // Handler for the "Set" button next to the custom duration input + const handleSetCustomDuration = () => { + const durationMs = parseDurationInput(customDurationInput); + if (durationMs !== null && durationMs > 0) { + handleSetDuration(durationMs); + } else { + toast({ + title: 'Invalid Duration', + description: 'Use numbers (e.g., 5, 2.5) or MM:SS (e.g., 2:30).', + variant: 'destructive', + }); + } + }; + + // Handler for preset duration buttons + const handlePresetDuration = (minutes: number) => { + handleSetDuration(minutes * 60 * 1000); + }; + + // Handler for Start/Pause button + const handleTogglePlayPause = async () => { + if (isRunning) { + await sendControlAction('pause'); + // State updates handled by SSE via reducer + } else { + // Only start if there's time remaining + if ((remainingTime ?? 0) > 0) { + await sendControlAction('start'); + // State updates handled by SSE via reducer + } else { + toast({ + title: 'Cannot Start', + description: 'Timer is at zero. Reset or set a duration.', + variant: 'destructive', + }); + } + } + }; + + // Handler for Reset button + const handleReset = () => { + // Use the last duration from server state, or fallback to a default (e.g., 4 minutes) + const resetDuration = serverState?.duration ?? (4 * 60 * 1000); + sendControlAction('reset', { duration: resetDuration }); + if (!serverState?.duration) { + toast({ + title: 'Resetting', + description: 'Resetting to default 4 minutes (no duration previously set).', + }); + } + // State updates handled by SSE via reducer + }; + + // Handler for "+1 Minute" button + const handleAddMinute = () => { + sendControlAction('add_time', { timeToAddMs: 60000 }); + // State updates handled by SSE via reducer + }; + + // Handler for Minimize/Maximize button + const handleToggleMinimize = () => { + dispatch({ type: 'TOGGLE_MINIMIZE' }); + }; + + // Handler for Show/Hide Admin Controls button + const handleToggleAdminControls = () => { + dispatch({ type: 'TOGGLE_ADMIN_CONTROLS' }); + }; + + + if (!assessmentId) { + return null; + } + + // Show loading state until the first SSE message arrives and populates serverState + if (serverState === null) { + return ( +
+ Loading Timer... +
+ ); + } + + // Calculate user count display (exclude self if controller) + const displayUserCount = canControl ? Math.max(0, connectedClientCount - 1) : connectedClientCount; + + return ( + + + + {canControl && showAdminControls && ( + + )} + + {canControl && ( + + )} + + ); +}; + +export default Stopwatch; \ No newline at end of file From 1795e5a2e7334040e1befc0b6e682f1072502a4f Mon Sep 17 00:00:00 2001 From: Craig Haseler Date: Tue, 29 Apr 2025 23:31:00 -0400 Subject: [PATCH 2/3] fix: also include relevant layout file --- .../assessments/[assessmentId]/layout.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/layout.tsx b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/layout.tsx index 1ead275d..725b62f2 100644 --- a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/layout.tsx +++ b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/layout.tsx @@ -1,9 +1,10 @@ +import React from "react" import NotFound from "@/app/(frontend)/components/notFound" import { fetchAssessment, fetchAssessmentType, } from "../../../utils/dataFetchers" - +import Stopwatch from "@/components/stopwatch/stopwatch" export default async function RootLayout( props: Readonly<{ children: React.ReactNode @@ -28,7 +29,13 @@ export default async function RootLayout( }, ] if (assessment) { - return children + // Wrap children and add Stopwatch. Stopwatch uses absolute positioning. + return ( + <> + {children} + + + ) } return } From c676d5f9afffd01b2324614760ca8cc6998b701d Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 30 Jun 2025 22:05:23 -0400 Subject: [PATCH 3/3] wip --- .roomodes | 41 +++ memory-bank/activeContext.md | 12 + memory-bank/progress.md | 24 ++ memory-bank/projectbrief.md | 22 ++ memory-bank/systemPatterns.md | 78 +++++ research-findings.md | 55 +++ .../dashboard/DashboardClientUI.tsx | 138 ++++++++ .../dashboard/ProgressTable.tsx | 107 ++++++ .../[assessmentId]/dashboard/columns.tsx | 93 ++++++ .../dashboard/data-table-pagination.tsx | 110 ++++++ .../dashboard/data-table-toolbar.tsx | 27 ++ .../dashboard/data-table-view-options.tsx | 55 +++ .../[assessmentId]/dashboard/data-table.tsx | 129 ++++++++ .../[assessmentId]/dashboard/page.tsx | 313 ++++++++++++++++++ 14 files changed, 1204 insertions(+) create mode 100644 .roomodes create mode 100644 memory-bank/activeContext.md create mode 100644 memory-bank/progress.md create mode 100644 memory-bank/projectbrief.md create mode 100644 memory-bank/systemPatterns.md create mode 100644 research-findings.md create mode 100644 web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/DashboardClientUI.tsx create mode 100644 web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/ProgressTable.tsx create mode 100644 web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/columns.tsx create mode 100644 web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-pagination.tsx create mode 100644 web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-toolbar.tsx create mode 100644 web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-view-options.tsx create mode 100644 web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table.tsx create mode 100644 web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/page.tsx diff --git a/.roomodes b/.roomodes new file mode 100644 index 00000000..80bfc5c8 --- /dev/null +++ b/.roomodes @@ -0,0 +1,41 @@ +{ + "customModes": [ + { + "slug": "researcher", + "name": "Researcher", + "roleDefinition": "You are a dedicated documentation researcher responsible for conducting thorough research using authorized MCP tools such as context7, mastra, perplexity, postgres, or mssql. Your job is to gather and summarize relevant findings, either directly in your task-completion response or by writing them to a Markdown file (e.g., `research-findings.md`) and referencing it clearly. You also have access to read and edit Markdown files. Any relevant insights *must* be preserved in the final output—intermediate discussion, scratchpad notes, or chat logs will not persist, and you must assume they will be discarded.", + "customInstructions": "# Researcher Protocol\n\n- Use MCP search APIs to perform research when needed, using the best tool for the job\n- You may consult multiple sources but must cite or summarize comprehensively\n- Include your findings in **one** of the following ways:\n - Inline, directly in your response (clear, organized, attributed where possible)\n - In a new or updated markdown file, which you reference directly in your output\n- All research outcomes must be **preserved** in your deliverables\n- You may read and edit `.md` files to log or reference research outputs\n\n### Requirements\n- Do NOT rely on ephemeral conversation history to carry findings forward\n- Do NOT skip writing your results down just because they’re mentioned in chat\n- Always leave a durable record of insights, URLs, data, or context gathered", + "groups": [ + "read", + "mcp", + [ + "edit", + { + "fileRegex": "\\.md$", + "description": "Markdown files only" + } + ] + ], + "source": "project" + }, + { + "slug": "orchestrator", + "name": "Orchestrator - CAH", + "roleDefinition": "You are Roo, an expert workflow orchestration agent who breaks down complex projects into well-defined subtasks and delegates them to specialized agents. You excel at strategic task decomposition, specialized agent coordination, delegation management via the new_task tool, comprehensive progress tracking, and quality assurance. As the central coordination layer between users and implementation agents, you ensure all components work together seamlessly throughout the workflow lifecycle.", + "customInstructions": "# Workflow Orchestration Protocol\n\nAs Roo, coordinate complex workflows by integrating with Cline's Memory Bank and delegating tasks to specialized team members following this protocol:\n\n## 1. Memory Bank Integration\n- Begin every task by reading ALL Memory Bank files (especially projectbrief.md, activeContext.md, and progress.md)\n- Verify current system state, patterns, and technical context before proceeding\n- Update Memory Bank files after each significant milestone\n\n## 2. Team Member Selection Guide\n- **Architect Mode**: System design, project structure, patterns/standards definition\n- **Code Mode**: Implementation, coding, feature development, refactoring\n- **Researcher Mode**: Documentation, explanations, knowledge sharing\n- **Debug Mode**: Troubleshooting, problem-solving, issue resolution\n\n## 3. Workflow Process\n1. **Initial Engagement**\n - Assess request validity and gather requirements through targeted questions\n - Determine planning vs. execution mode\n - Reference Memory Bank to align with established patterns\n\n2. **Task Decomposition**\n - Break projects into components with clear dependencies and acceptance criteria\n - Document using structured task definitions, inputs/outputs, and complexity\n - Ensure alignment with systemPatterns.md\n - In planning mode: present breakdown for user approval before proceeding\n\n3. **Task Delegation**\n - Match tasks to appropriate team members based on their specializations\n - Use new_task tool with complete parameters (agent, task, context, criteria, dependencies)\n - Include relevant Memory Bank context with each delegation. Unless you provide the context in your message, or specify a specific file to read, the agent you are assigning the task to has NO IDEA what the greater context is.\n\n4. **Progress Monitoring**\n - Track workflow status (Pending, In Progress, Completed, Blocked)\n - After each completion: update activeContext.md, progress.md, and other relevant files\n - Check in with user after each task with results summary and next steps\n - In planning mode: wait for user confirmation before proceeding\n\n5. **Quality Assurance**\n - Validate deliverables against acceptance criteria\n - For issues: create remediation briefs (issue, impact, cause, solution)\n - Document lessons learned in activeContext.md\n\n6. **Project Completion**\n - Compile components into cohesive deliverables with documentation\n - Perform comprehensive update of ALL Memory Bank files\n - For \"**update memory bank**\" requests: review all files completely\n\n## 4. Core Principles\n- You serve EXCLUSIVELY as orchestration layer, never producing code directly\n- All significant changes must be documented in Memory Bank\n- In planning mode: explicit user approval required before each new task\n- In execution mode: inform user of progress and continue unless directed otherwise\n\nFocus on effective coordination, specialist selection, and comprehensive Memory Bank maintenance throughout the entire process.", + "groups": [ + "read", + "mcp", + [ + "edit", + { + "fileRegex": "\\.md$", + "description": "Markdown files only" + } + ], + "command" + ], + "source": "project" + } + ] +} \ No newline at end of file diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md new file mode 100644 index 00000000..0f6ad27f --- /dev/null +++ b/memory-bank/activeContext.md @@ -0,0 +1,12 @@ +# Active Context + +*This file tracks the current understanding of the system, key decisions, and ongoing activities.* + +**Current Activity:** Implementing Facilitator Dashboard enhancements (2025-04-29) + +* **Completed:** Added an interactive "Progress" table to the Assessment Facilitator Dashboard (`web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/`). + * Shows user completion status (checkmark or blank) for each assessment attribute. + * Implemented using Prisma for data fetching, Shadcn UI components (`Table`, `Accordion`, `Card`), and React client/server components (`page.tsx`, `DashboardClientUI.tsx`, `ProgressTable.tsx`). + * Includes interactive filtering options within a collapsible accordion. + +*(This context will evolve as the exploration progresses.)* \ No newline at end of file diff --git a/memory-bank/progress.md b/memory-bank/progress.md new file mode 100644 index 00000000..b82902bc --- /dev/null +++ b/memory-bank/progress.md @@ -0,0 +1,24 @@ +# Project Progress Tracker + +*This file tracks the status of major tasks and milestones.* + +## Facilitator Dashboard (Initial Implementation - 2025-04-29) + +* **Status:** Paused (Initial implementation and refinements complete; filtering attempt reverted; build error deferred) +* **File:** `web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/page.tsx` +* **Completed:** + * Created page structure with role-based permission checks (Admin, Lead, Facilitator). + * Implemented cards displaying Assessment details (Type, Status, Dates), Assessment Parts (Name, Status), and Assessment User Groups (Name, Status) using direct Prisma queries. + * Implemented Assessment Users table displaying User Name, Email, Role, User Group (or 'N/A'), Last completed (attributeId or blank), Last Sign In (Clerk, formatted or "Not Signed Up"), and Sign In Method (Clerk, "PARS" or "Email") using Prisma and Clerk SDK. + * Refactored layout to a 3-column grid with Assessment Name in the page title. + * Resolved TypeScript errors encountered during development. + * Replaced basic user table with reusable `DataTable` component (adding sorting, filtering capabilities). + * Wrapped Assessment Users `DataTable` in a collapsible `Accordion` (default open). + * Created local copy of `DataTable` component and dependencies within dashboard directory for customization. + * Reverted failed attempts to add faceted filtering to the user table. + * Implemented interactive "Progress" table showing user completion status per attribute, with filtering options, within a collapsible accordion (`DashboardClientUI.tsx`, `ProgressTable.tsx`). +* **Deferred:** + * Completion of Assessment Users table (Status, Progress %, full conditional styling, faceted filtering). + * Resolution of remaining build error in `[sectionId]/page.tsx`. + +*(Progress will be updated as tasks are completed.)* \ No newline at end of file diff --git a/memory-bank/projectbrief.md b/memory-bank/projectbrief.md new file mode 100644 index 00000000..9c9fa6f7 --- /dev/null +++ b/memory-bank/projectbrief.md @@ -0,0 +1,22 @@ +# Project Brief: EMPACT + +*This file provides a high-level overview of the EMPACT project.* + +## Project Description + +**EMPACT (Environment and Maturity Program Assessment and Control Tool)** is an open-source web and desktop application designed to implement the **IP2M METRR Environmental and Maturity evaluation model**. Sponsored by the Department of Energy's Office of Project Management, it provides a tool for assessing project management maturity and environmental factors. + +**Note:** While based on DOE-funded research (IP2M METRR by ASU), this project is independently developed and not affiliated with ASU's proprietary software. + +## Key Goals & Features (Identified/Inferred) + +* **Implement IP2M METRR Model:** Provide a platform for conducting assessments based on the defined model structure (Environment & Maturity parts, sections, attributes, levels). +* **Dual Deployment:** Function as both a web application (likely using Docker) and an installable offline desktop application (using Tauri) from a single Next.js codebase. +* **Assessment Management:** Allow creation, management, and participation in assessments. Support grouping assessments into collections. +* **Data Analysis:** Enable analysis of assessment results, potentially broken down by groups or aggregated over time (AI features mentioned in README but not yet observed in code analysis). +* **User Roles & Permissions:** Implement a multi-layered permission system (System Admins, Collection Managers, Assessment Leads/Facilitators/Participants). +* **Authentication:** Utilize Clerk for authentication, supporting SSO/OIDC and local accounts, with custom role/permission synchronization via middleware. +* **Technology Stack:** Next.js (App Router), React, TypeScript, Prisma (MSSQL primary, Postgres/SQLite secondary), Tailwind CSS, Shadcn UI, Playwright (E2E Testing), Tauri (Desktop). + + +*(This brief summarizes the project based on initial analysis. Details may evolve.)* \ No newline at end of file diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md new file mode 100644 index 00000000..e375dfc3 --- /dev/null +++ b/memory-bank/systemPatterns.md @@ -0,0 +1,78 @@ +# System Patterns and Standards + +*This document outlines identified architectural patterns, coding standards, and conventions used within the EMPACT project, based on the codebase analysis.* + +## Identified Patterns + +*This section details the core architectural and implementation patterns observed.* + +### Architecture & Deployment +- **Monorepo:** Uses Yarn Workspaces to manage `web` (Next.js) and `src-tauri` (Desktop) packages (`README.md`). +- **Shared Codebase Architecture:** Core UI components (likely Next.js) are shared between the web and desktop application builds (`EMPACT Architecture.drawio`). +- **Platform-Specific Builds:** + - **Web:** Intended to use Next.js Standalone Export packaged into Docker, but `output: "standalone"` is currently commented out in `next.config.mjs` due to "Prisma issues". A custom `build-linux` script in `package.json` exists as a workaround, manually copying Prisma binaries. The `Dockerfile` is single-stage, explicitly installs `openssl`, and sets execute permissions for Prisma engines. Uses standard SQL DB (MSSQL primary) via Prisma. + - **Desktop:** Next.js Static Export bundled with SQLite DB using Tauri (`EMPACT Architecture.drawio`, `src-tauri/`). Uses SQLite via Prisma. Data access via TypeScript calling Rust functions. + +### Data Management +- **ORM:** Prisma is used for database interaction. + - **Multi-Database Support:** Separate, largely equivalent schemas are maintained for MSSQL, PostgreSQL, and SQLite in `web/prisma/`. The web application targets SQL Server (`web/lib/db.ts`). The `web/package.json` `build` script generates clients for all three. + - **Seeding:** Database seeding is handled by `web/prisma/seed.ts`, currently prioritizing MSSQL. It populates core assessment model structure (Types, Parts, Sections, Attributes, Levels) from JSON definitions (`web/prisma/seed/ip2m_model/`) and adds test data (users, roles, assessments). +- **Data Modeling (Assessment Structure):** + - **Template/Instance Pattern:** Generic assessment structures (`AssessmentType`, `Part`, `Section`, `Attribute`, `Level`) define reusable templates. Specific `Assessment` instances link to these templates via join tables (`AssessmentPart`, `AssessmentAttribute`). + - **Responses:** User answers are stored in `AssessmentUserResponse`, linking `User`, `Assessment`, `Attribute`, and chosen `Level`. +- **Data Access (Client-Side): Server Action Prisma Wrappers:** Standardized pattern used in `web/app/utils/`. Each file corresponds to a Prisma model and re-exports basic CRUD operations (e.g., `findMany`, `create`, `update`) as Next.js Server Actions (`"use server"`). This provides a secure way for client components to invoke database operations via the server. These wrappers contain no custom business logic but are noted as the intended location for future permission checks. +- **Data Fetching (Server-Side): Server Components + Helper Functions:** Server Components (`page.tsx`, `layout.tsx`) handle primary data fetching using async/await. They call helper functions located in `web/app/(frontend)/(logged-in)/utils/dataFetchers.ts` which encapsulate direct Prisma client calls (`db..`). +- **Mutations (Client-Side): Client Components + Server Actions:** Client Components (`"use client"`) trigger data mutations (create, update, delete) by calling Server Actions defined in `web/app/(frontend)/(logged-in)/utils/dataActions.ts`. These actions likely perform the necessary database operations (potentially using the Prisma wrappers from `web/app/utils/`). +- **Server Action Validation:** Server Actions used for mutations (e.g., in `admin/actions.ts`) utilize **Zod** for input validation before performing database operations. +- **Data Refresh after Mutation:** Client components typically call `revalidatePath()` or `router.refresh()` after successful Server Action calls to update the displayed data. +- **Database Documentation:** A DBML file (`web/prisma/sqlite/dbml/schema.dbml`) is generated for the SQLite schema, likely for documentation/visualization. + +### Authentication & Authorization (RBAC) +- **Authentication:** Uses Clerk. Leverages Next.js middleware (`web/middleware.ts`) for route protection and **syncing custom DB permissions/roles to Clerk public metadata** upon login (includes `databaseId`, `systemRoles`, `assessmentUser`, `assessmentCollectionUser`). Middleware also handles DB user creation/updates and redirects unauthorized users. An `auth.ts` adapter maps Clerk session + metadata to an internal `Session` type for compatibility. +- **Authorization/Permissions:** Permission logic (Role-Based Access Control - RBAC) is checked within Server Components and dedicated helper functions (`web/app/(frontend)/(logged-in)/utils/permissions.ts`), often using data fetched from the user's session (`auth()`) and the database. This determines UI visibility (buttons, links) and guards Server Action execution. +- **User Roles/Permissions (Data Model):** Multi-layered roles exist: `SystemRole` (global), `AssessmentCollectionUser` (collection management), `AssessmentUser` (assessment participation/facilitation) linked to granular `Permission`s. +- **Session Context Augmentation:** Uses global type declarations (`web/types/globals.d.ts`) to augment Clerk's JWT session claims (`CustomJwtSessionClaims`) with application-specific metadata (`databaseId`, `systemRoles`, `assessmentUser`, `assessmentCollectionUser`), making user context readily available for authorization checks. +- **Authentication State Handling:** Uses a specific wrapper (`multisessionAppSupport.tsx`) with React `key` based on Clerk `session.id` to force remounts on auth changes. + +### UI & Components +- **UI Components:** Uses Shadcn UI (`web/tailwind.config.ts`, `web/components/ui/`) as the base component library. + - **Customized DataTable:** The standard Shadcn DataTable (`web/components/ui/data-table/`) is enhanced with clickable rows that navigate to detail pages based on a `urlHeader` prop. +- **User Management UI:** Uses a combination of `DataTable` (Tanstack Table) for displaying lists, `Dialog` components for editing individual records (e.g., `AssessmentUser`, system `User`), `AlertDialog` for delete confirmations, and `AgGridReact` for bulk-add scenarios with inline configuration (e.g., adding multiple assessment users with roles/groups). Inline editing is also used in simpler tables (e.g., managing `AssessmentUserGroup`s). +- **UI Composition:** Complex views (e.g., assessment participation, editing, user management) are built by composing smaller Server and Client Components, often nested via layouts. Reusable components like `DataTable` are employed for lists. Client components often use `useRouter().refresh()` after mutations to update the view. +- **Theming:** Uses `next-themes` integrated with Shadcn (`theme-provider.tsx`, `mode-toggle.tsx`). + +### Development Workflow & Testing +- **Custom Types:** Defines specific types for application logic (e.g., `AssessmentPartToAdd` in `web/types/assessment.ts`). +- **Developer Utilities:** Conditionally renders debugging components (`debug-session-info.tsx`, `tailwind-indicator.tsx`) based on environment variables. +- **Testing:** Uses **Playwright** for End-to-End (E2E) tests (`web/tests/e2e/`). Focuses primarily on authentication and role-based access control (RBAC). Leverages Playwright fixtures (`fixtures.ts`) and global setup (`global-setup.ts`) to manage pre-authenticated states for different user roles. Test documentation is maintained in `web/tests/testing-and-auth.md`. No unit/integration tests were identified in this structure. + +## Coding Standards + +*This section outlines specific coding standards enforced or observed.* + +- **Commit Messages:** Follows Angular commit convention, used by Semantic Release (`.releaserc.json`). Specific types (`docs`, `test`, `ci`, `scope: no-release`) do not trigger releases. `refactor`, `chore`, `build`, `style` trigger patch releases. +- **Dependency Management:** Uses Yarn v1 (`web/package.json`). Uses **canary** versions of Next.js (`^15.3.0-canary.14`) and React 19 (`web/package.json`). +- **Database Schema Conventions:** + - Most primary keys are auto-incrementing integers, but core template models (`Section`, `Attribute`, `Level`) use non-auto-incrementing IDs (likely predefined constants/identifiers). + - Referential integrity uses `onDelete: NoAction` extensively, implying application-level logic is required for safe deletion of template data. + - Enum-like fields (`status`, `role`) are defined as `String` in the schema; constraints are likely handled in application code. +- *(Further standards to be identified from `.eslintrc.json`, `prettier.config.cjs`, etc.)* + +## Conventions + +*This section describes established practices and configurations.* + +- **Automated Versioning:** `semantic-release` manages version bumps and releases based on commits to `main` (`.releaserc.json`). Version numbers are automatically updated in multiple files (`package.json`, `tauri.conf.json`, `Cargo.toml`, specific `.tsx` and `.ts` files). +- **Automated Dependency Updates:** Renovate bot manages dependency updates, grouping non-major updates and requiring approval for majors. Specific rules for automerging dev dependencies and patch updates (`renovate.json`). +- **CI/CD:** GitHub Actions are used for CI processes, including Docker builds. Note the custom `build-linux` script workaround for standalone builds (`web/package.json`). +- **Licensing:** Code/Documentation is licensed under CC BY 4.0 (`LICENSE`, `README.md`). The "EMPACT" name and logo are trademarked (`TrademarkPolicy`, `README.md`). +- **Configuration Files:** Standard configuration files are present. Notable non-standard settings: + - `next.config.mjs`: `output: "standalone"` commented out, uses deprecated `experimental.nodeMiddleware`, explicit `tsconfigPath`. + - `tailwind.config.ts`: Includes custom `sidebar` color palette extension. + - `package.json`: Includes `@tauri-apps/cli` and `pkg` config, suggesting potential non-web deployment targets. +- **Directory Structure (`web/`):** Follows standard Next.js App Router structure (`app/`, `components/`, `lib/`, `public/`, `hooks/`, `tests/`, `types/`). `prisma/` subdirectory contains multi-DB setup (MSSQL, Postgres, SQLite). +- **Directory Structure (`web/app/(frontend)/`):** Uses Next.js Route Groups (`(logged-in)`, `(logged-out)`) to organize routes based on authentication state without affecting URL paths. Contains shared frontend components. +- **Directory Structure (`web/app/(frontend)/(logged-in)/.../assessments/`):** Utilizes nested dynamic routes (`[assessmentGroupId]`, `[assessmentId]`, `[roleName]`, `[partName]`, `[sectionId]`, `[attributeId]`) to structure the assessment workflow. Specific functionality (add, edit, view, respond) is further organized into subdirectories. +- **Directory Structure (`web/app/(frontend)/(logged-in)/.../users/`):** Organizes user management by context (assessment vs. collection) using subdirectories (`assessment/[assessmentId]`, `collection/[collectionId]`). Further subdirectories handle specific actions like adding users (`add-assessment-users`, `add-collection-managers`) or managing related entities (`manage-user-groups`). +- **Directory Structure (`web/app/(frontend)/(logged-in)/(home)/admin/`):** Relatively flat structure containing components (`data-table.tsx`, `columns.tsx`, `add-users-dialog.tsx`), Server Actions (`actions.ts`), and the main page (`page.tsx`) for system user management. Includes a placeholder `dashboard/` subdirectory. +- **Directory Structure (`web/components/`):** Contains custom shared components (`theme-provider.tsx`, `mode-toggle.tsx`, `multisessionAppSupport.tsx`, `userback-provider-client.tsx`, dev utilities) alongside the `ui/` subdirectory for Shadcn components. \ No newline at end of file diff --git a/research-findings.md b/research-findings.md new file mode 100644 index 00000000..bf455724 --- /dev/null +++ b/research-findings.md @@ -0,0 +1,55 @@ +# Research Findings: Test Suite Analysis (`web/tests/`) + +This document summarizes the analysis of the test suite located in `/home/ubuntu/projects/EMPACT/web/tests/`. + +## 1. Directory Contents (`web/tests/`) + +The directory contains the following files and subdirectories: + +* **`testing-and-auth.md`**: Documentation file explaining the testing setup, authentication mechanism for tests, and guidelines for writing tests. +* **`e2e/` (directory)**: Contains the End-to-End (E2E) test suite. + * **`auth.test.ts`**: Contains Playwright tests specifically for authentication flows, including login success/failure, redirects for unauthenticated users, and verifying access for different user roles. + * **`fixtures.ts`**: Defines custom Playwright test fixtures. Notably, it includes fixtures like `test.asAdmin`, `test.asParticipant`, etc., which allow tests to run pre-authenticated as a specific user role. + * **`global-setup.ts`**: A script configured in `playwright.config.ts` to run once before the entire test suite. It likely handles setting up the necessary authentication state (e.g., logging in test users and saving the state) required by the role-specific fixtures. + * **`header.test.ts`**: Contains Playwright tests likely focused on verifying the functionality and appearance of the main application header component for logged-in users. + * **`test-accounts.ts`**: Exports an object containing the email addresses used for the predefined test accounts corresponding to different application roles (e.g., `admin`, `collectionManager`). + +## 2. Testing Framework + +* The project utilizes **Playwright** as its E2E testing framework. +* **Evidence:** + * Presence of `web/playwright.config.ts`. + * `playwright.config.ts` imports `@playwright/test`. + * Configuration specifies `testDir: "./tests/e2e"` and `globalSetup: "./tests/e2e/global-setup.ts"`. + * Test files (`*.test.ts`) use Playwright's `test` function and `expect` assertions. + +## 3. Purpose of `e2e/` Subdirectory and Contents + +* This directory houses the **End-to-End (E2E) test suite**. +* **Goal:** To test the application flow from a user's perspective by interacting with the UI in a real browser environment. +* **Coverage Focus:** + * **Authentication:** Login/logout, credential validation, redirects. + * **Authorization / Role-Based Access Control (RBAC):** Ensuring users with different roles can access appropriate pages/features and are restricted from others. This is heavily reliant on the custom fixtures in `fixtures.ts`. + * **Basic UI Functionality:** Testing core components like the header and form interactions. +* **Key Files:** + * `auth.test.ts` & `header.test.ts`: Contain the actual test cases. + * `fixtures.ts`, `global-setup.ts`, `test-accounts.ts`: Provide the infrastructure and data to support the tests, particularly the role-based authentication simulation. + +## 4. Purpose of `testing-and-auth.md` + +* This file is **developer documentation** for the testing framework and process. +* It details: + * How to execute tests. + * The test accounts available. + * How the test authentication system works (using fixtures and global setup). + * Examples and best practices for writing new E2E tests, especially concerning different user roles. + * Guidance on checking user roles within the application's components. + * Required environment setup (`.env.test`). + +## 5. Overall Testing Strategy Summary + +* The application employs an **E2E testing strategy using Playwright**. +* The **primary focus** is on validating **authentication and authorization (RBAC)** flows for various user roles, ensuring the application behaves correctly from an end-user perspective regarding access control. +* The strategy leverages Playwright **fixtures** and **global setup** to efficiently manage test preconditions, especially user authentication states. +* Comprehensive **documentation (`testing-and-auth.md`)** supports developer understanding and contribution to the test suite. +* Based on the contents of `web/tests/`, there is **no indication of unit or integration tests** being part of this specific test suite structure. The emphasis is on higher-level, browser-based validation. \ No newline at end of file diff --git a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/DashboardClientUI.tsx b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/DashboardClientUI.tsx new file mode 100644 index 00000000..245a6b89 --- /dev/null +++ b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/DashboardClientUI.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState, useEffect, useMemo } from 'react'; +import { Card, CardContent } from "@/components/ui/card"; // Add Card imports back +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { ProgressTable, type AssessmentUserWithDetails } from "./ProgressTable"; +import type { PrismaClient as PrismaTypes } from '@/lib/db'; + +// Define the expected props based on data fetched in page.tsx +interface DashboardClientUIProps { + // Adjust type to match exactly what's passed from page.tsx's getData + initialAssessmentUsers: AssessmentUserWithDetails[]; // Use the exported type directly + initialSortedAttributes: PrismaTypes.AssessmentAttribute[]; + initialUserResponses: PrismaTypes.AssessmentUserResponse[]; +} + +export function DashboardClientUI({ + initialAssessmentUsers, + initialSortedAttributes, + initialUserResponses, +}: DashboardClientUIProps) { + + // --- State for Filters --- + const [showEnvironment, setShowEnvironment] = useState(true); + const [showMaturity, setShowMaturity] = useState(true); + const [showComplete, setShowComplete] = useState(true); + const [showNotStarted, setShowNotStarted] = useState(true); + + // --- State for Filtered Data --- + const [filteredAttributes, setFilteredAttributes] = useState(initialSortedAttributes); + const [filteredUsers, setFilteredUsers] = useState(initialAssessmentUsers); // Use prop directly + + // --- Memoize response map for performance --- + const responseMap = useMemo(() => { + const map = new Map(); + initialUserResponses.forEach((response: PrismaTypes.AssessmentUserResponse) => { + if (response.userId != null && response.attributeId != null) { + map.set(`${response.userId}-${response.attributeId}`, true); + } + }); + return map; + }, [initialUserResponses]); + + // --- Effect to filter attributes --- + useEffect(() => { + const newFilteredAttributes = initialSortedAttributes.filter((attr: PrismaTypes.AssessmentAttribute) => { + const hasPeriod = attr.attributeId.includes('.'); + if (!showEnvironment && !hasPeriod) return false; + if (!showMaturity && hasPeriod) return false; + return true; + }); + setFilteredAttributes(newFilteredAttributes); + }, [showEnvironment, showMaturity, initialSortedAttributes]); + + // --- Effect to filter users --- + useEffect(() => { + const visibleAttributeIds = new Set(filteredAttributes.map((attr: PrismaTypes.AssessmentAttribute) => attr.attributeId)); + + const newFilteredUsers = initialAssessmentUsers.filter((user: AssessmentUserWithDetails) => { + if (!user || typeof user.userId !== 'number') { + console.warn("Skipping user with missing data:", user); + return true; + } + + let completedVisibleCount = 0; + let hasAnyResponseForVisible = false; + + visibleAttributeIds.forEach(attributeId => { + if (responseMap.has(`${user.userId}-${attributeId}`)) { + hasAnyResponseForVisible = true; + completedVisibleCount++; + } + }); + + const allVisibleCompleted = completedVisibleCount === visibleAttributeIds.size && visibleAttributeIds.size > 0; + const noneVisibleStarted = !hasAnyResponseForVisible; + + if (!showComplete && allVisibleCompleted) return false; + if (!showNotStarted && noneVisibleStarted) return false; + + return true; + }); + setFilteredUsers(newFilteredUsers); + }, [showComplete, showNotStarted, filteredAttributes, initialAssessmentUsers, responseMap]); + + + // --- Render Logic --- + return ( + // Wrap the Accordion in a Card and CardContent for consistent styling + + {/* Add padding top like other cards */} + {/* Remove defaultValue */} + + Progress & Display Options + + {/* Container for options and table */} +
+ {/* --- Filter Controls --- */} +
{/* Removed heading */} +
+ setShowEnvironment(Boolean(checked))} /> + +
+
+ setShowMaturity(Boolean(checked))} /> + +
+
+ setShowComplete(Boolean(checked))} /> + +
+
+ setShowNotStarted(Boolean(checked))} /> + +
+
+ + {/* --- Progress Table Section (Uses Filtered Data) --- */} + +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/ProgressTable.tsx b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/ProgressTable.tsx new file mode 100644 index 00000000..bd781a50 --- /dev/null +++ b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/ProgressTable.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { CheckCircle } from 'lucide-react'; +import type { PrismaClient as PrismaTypes } from '@/lib/db'; + +// Type for user data including nested details, needed by this component +export type AssessmentUserWithDetails = PrismaTypes.AssessmentUser & { + user: { + firstName: string | null; + lastName: string | null; + email: string | null; + } | null; + // Include assessmentUserGroup if needed for display, otherwise omit + assessmentUserGroup: PrismaTypes.AssessmentUserGroup | null; +}; + +interface ProgressTableProps { + assessmentAttributes: PrismaTypes.AssessmentAttribute[]; // Expects potentially filtered attributes + assessmentUsers: AssessmentUserWithDetails[]; // Expects potentially filtered users + allAssessmentUserResponses: PrismaTypes.AssessmentUserResponse[]; // Expects ALL responses for lookup +} + +export function ProgressTable({ + assessmentAttributes, + assessmentUsers, + allAssessmentUserResponses, // Renamed prop for clarity +}: ProgressTableProps) { + + // Create a lookup map from ALL responses passed down + // Use useMemo here for slight optimization if this component re-renders often, + // but since filtering happens in parent, it might not be strictly necessary. + // For simplicity now, create it directly. + const responseMap = new Map(); + allAssessmentUserResponses.forEach((response) => { + if (response.userId != null && response.attributeId != null) { + responseMap.set( + `${response.userId}-${response.attributeId}`, + true + ); + } + }); + + return ( +
+ + + + {/* User column with increased width and nowrap */} + User + {/* Map over the potentially filtered attributes */} + {assessmentAttributes.map((attribute) => ( + {/* Compact header */} + {attribute.attributeId} + + ))} + + + + {/* Handle case where filtered users might be empty */} + {assessmentUsers.length === 0 ? ( + + + No users match the current filters. + + + ) : ( + /* Map over the potentially filtered users */ + assessmentUsers.map((user) => ( + + {/* Compact cell */} + {user.user + ? `${user.user.firstName ?? ''} ${user.user.lastName ?? ''}`.trim() || user.user.email || `User ID: ${user.userId}` + : `User ID: ${user.userId}` + } + + {/* Map over the potentially filtered attributes again for cells */} + {assessmentAttributes.map((attribute) => { + // Check the response map using the user's actual userId + const hasResponse = responseMap.get( + `${user.userId}-${attribute.attributeId}` + ); + return ( + {/* Compact cell */} + {hasResponse ? ( + + ) : null} + + ); + })} + + )) + )} + +
+
+ ); +} \ No newline at end of file diff --git a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/columns.tsx b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/columns.tsx new file mode 100644 index 00000000..030faf68 --- /dev/null +++ b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/columns.tsx @@ -0,0 +1,93 @@ +"use client"; // Required for ColumnDef and potentially interactive headers/cells + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; +import { Badge } from "@/components/ui/badge"; // For potential styling + +// Define the shape of the data object expected by the columns +export type AssessmentUserDashboardData = { + id: number; + prismaUserId: number | null; + clerkUserId: string | null; + name: string; + email: string; + role: string; // Consider using a specific enum/type if available + group: string; + lastCompletedAttributeId: string; // Displaying the ID for now + lastSignInAt: Date | null; + signInMethod: string; +}; + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("name")}
, + enableSorting: true, + }, + { + accessorKey: "email", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("email")}
, + enableSorting: true, + }, + { + accessorKey: "role", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("role")}
, + enableSorting: true, + // Potential future enhancement: Faceted filter for roles + // filterFn: (row, id, value) => { + // return value.includes(row.getValue(id)); + // }, + }, + { + accessorKey: "group", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("group") || "N/A"}
, // Handle empty group name + enableSorting: true, + // Potential future enhancement: Faceted filter for groups + // filterFn: (row, id, value) => { + // return value.includes(row.getValue(id)); + // }, + }, + { + accessorKey: "lastCompletedAttributeId", + header: "Last Completed", // Simple header for now + cell: ({ row }) =>
{row.getValue("lastCompletedAttributeId") || "-"}
, // Display ID or dash + }, + { + accessorKey: "lastSignInAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const lastSignInAt = row.getValue("lastSignInAt") as Date | null; + const clerkUserId = row.original.clerkUserId; // Access original data for clerkUserId check + + if (clerkUserId === null) { + return Not Signed Up; + } + return
{lastSignInAt ? lastSignInAt.toLocaleString() : "-"}
; + }, + enableSorting: true, + }, + { + accessorKey: "signInMethod", + header: "Sign In Method", + cell: ({ row }) => { + const signInMethod = row.getValue("signInMethod") as string; + const clerkUserId = row.original.clerkUserId; + // Display method only if user is signed up + return
{clerkUserId !== null ? signInMethod : "-"}
; + } + }, +]; \ No newline at end of file diff --git a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-pagination.tsx b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-pagination.tsx new file mode 100644 index 00000000..402487d6 --- /dev/null +++ b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-pagination.tsx @@ -0,0 +1,110 @@ +"use client" + +import { Table } from "@tanstack/react-table" +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface DataTablePaginationProps { + table: Table + selectable: boolean +} + +export function DataTablePagination({ + table, selectable +}: DataTablePaginationProps) { + return ( +
+ {selectable && +
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+ } +
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-toolbar.tsx b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-toolbar.tsx new file mode 100644 index 00000000..56ad3156 --- /dev/null +++ b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-toolbar.tsx @@ -0,0 +1,27 @@ +"use client" + +import { Table } from "@tanstack/react-table" + +import { DataTableViewOptions } from "./data-table-view-options" + +interface DataTableToolbarProps { + table: Table +} + +export function DataTableToolbar({ + table, +}: DataTableToolbarProps) { + const hidableColumns = table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && column.getCanHide() + ) + + return ( +
{/* Changed justify-between to justify-end */} + {/* Removed the div containing search, filters, and reset button */} + {hidableColumns.length > 0 && } +
+ ) +} \ No newline at end of file diff --git a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-view-options.tsx b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-view-options.tsx new file mode 100644 index 00000000..a078bf2e --- /dev/null +++ b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table-view-options.tsx @@ -0,0 +1,55 @@ +"use client" + +import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu" +import { Table } from "@tanstack/react-table" +import { SlidersHorizontal } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu" + +interface DataTableViewOptionsProps { + table: Table +} + +export function DataTableViewOptions({ + table, +}: DataTableViewOptionsProps) { + return ( + + + + + + Toggle columns + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && column.getCanHide() + ) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.id} + + ) + })} + + + ) +} \ No newline at end of file diff --git a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table.tsx b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table.tsx new file mode 100644 index 00000000..c8af04a1 --- /dev/null +++ b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/data-table.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" + +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState +} from "@tanstack/react-table" + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { DataTablePagination } from "./data-table-pagination" +import { DataTableToolbar } from "./data-table-toolbar" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + selectable?: boolean + initColumnVisibility?: VisibilityState + urlHeader?: string +} + +export function DataTable({ + columns, + data, + selectable = true, + initColumnVisibility = {}, + urlHeader +}: DataTableProps) { + const router = useRouter() + + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = + React.useState(initColumnVisibility) + const [sorting, setSorting] = React.useState([]) + + const table = useReactTable({ + data, + columns, + autoResetPageIndex: false, + state: { + sorting, + columnVisibility, + rowSelection, + }, + enableRowSelection: selectable, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + return ( +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + { + if (urlHeader && !cell.id.includes("select") && !cell.id.includes("actions")) { + router.push(`${urlHeader}/${row.getValue("id")}`) + } + }}> + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ) +} \ No newline at end of file diff --git a/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/page.tsx b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/page.tsx new file mode 100644 index 00000000..669b1408 --- /dev/null +++ b/web/app/(frontend)/(logged-in)/[assessmentGroupId]/assessments/[assessmentId]/dashboard/page.tsx @@ -0,0 +1,313 @@ +// No "use client" here - this is a Server Component + +import { auth, clerkClient } from "@clerk/nextjs/server"; // Keep server imports here +import { redirect } from "next/navigation"; +import type { User as ClerkUser, EmailAddress } from "@clerk/backend"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +// Remove client-side imports like Checkbox, Label, useState, useEffect, useMemo +import { + isAdmin, + isLeadForAssessment, + isFacForAssessment, +} from "@/app/(frontend)/(logged-in)/utils/permissions"; +import NotFound from "@/app/(frontend)/components/notFound"; +import { db } from "@/lib/db"; +import { DataTable } from "./data-table"; +import { columns, type AssessmentUserDashboardData } from "./columns"; +// Import the type needed for casting, ProgressTable itself is not rendered directly here +import { type AssessmentUserWithDetails } from "./ProgressTable"; +import type { PrismaClient as PrismaTypes } from '@/lib/db'; +import { DashboardClientUI } from './DashboardClientUI'; // Import the new Client Component + +interface FacilitatorDashboardPageProps { + readonly params: { + assessmentGroupId: string; + assessmentId: string; + }; +} + +// Keep the original data fetching logic +async function getData(params: FacilitatorDashboardPageProps['params']) { + const authResult = await auth(); // Needs await + const sessionClaims = authResult.sessionClaims; + + const assessmentId = parseInt(params.assessmentId); // No need for await params + + if (!sessionClaims) { + redirect("/sign-in"); + } + // Permissions check + const metadata = sessionClaims?.metadata; + const isUserAdmin = isAdmin(metadata ? { user: metadata } : null); + const isUserLead = isLeadForAssessment(metadata ? { user: metadata } : null, assessmentId.toString()); + const isUserFac = isFacForAssessment(metadata ? { user: metadata } : null, assessmentId.toString()); + const isAuthorized = isUserAdmin || isUserLead || isUserFac; + + if (!isAuthorized) { + return null; // Return null if not authorized + } + + // Fetch assessment data + const assessment = await db.assessment.findUniqueOrThrow({ + where: { id: assessmentId }, + include: { + assessmentCollection: { + include: { + assessmentTypes: true, + }, + }, + }, + }); + + const assessmentParts = await db.assessmentPart.findMany({ + where: { assessmentId: assessmentId }, + include: { part: true }, + orderBy: { part: { name: 'asc' } }, + }); + + const assessmentUserGroups = await db.assessmentUserGroup.findMany({ + where: { assessmentId: assessmentId }, + orderBy: { name: 'asc' }, + }); + + // Fetch AssessmentUsers with necessary relations for BOTH tables + const assessmentUsers = await db.assessmentUser.findMany({ + where: { assessmentId: assessmentId }, + include: { + user: { select: { firstName: true, id: true, lastName: true, email: true } }, + assessmentUserGroup: true // Needed for AssessmentUserWithDetails type + }, + orderBy: { user: { email: 'asc' } }, + }); + + // Fetch Attributes (no DB sort) + const assessmentAttributes = await db.assessmentAttribute.findMany({ + where: { assessmentId: assessmentId } + }); + + // Fetch ALL Responses + const allAssessmentUserResponses = await db.assessmentUserResponse.findMany({ + where: { assessmentId: assessmentId }, + }); + + // --- Clerk User Data Fetching --- + const userEmails = assessmentUsers + .map((au) => au.user?.email) + .filter((email): email is string => typeof email === 'string' && email !== ''); + + type ClerkUserData = { clerkUserId: string; lastSignInAt: Date | null; signInMethod: string; }; + const clerkUserDataMap = new Map(); + + if (userEmails.length > 0) { + try { + const client = await clerkClient(); // Needs await + const clerkUsers = await client.users.getUserList({ emailAddress: userEmails }); + for (const clerkUser of clerkUsers.data) { + let signInMethod = "Unknown"; + if (clerkUser.externalAccounts && clerkUser.externalAccounts.length > 0) signInMethod = "PARS"; + else if (clerkUser.passwordEnabled) signInMethod = "Email"; + const lastSignInDate = typeof clerkUser.lastSignInAt === 'number' ? new Date(clerkUser.lastSignInAt) : null; + const userDataPayload = { clerkUserId: clerkUser.id, lastSignInAt: lastSignInDate, signInMethod: signInMethod }; + for (const email of clerkUser.emailAddresses) { + if (email.emailAddress && email.verification?.status === 'verified') { + clerkUserDataMap.set(email.emailAddress.toLowerCase(), userDataPayload); + } + } + } + } catch (error) { + console.error("Error fetching Clerk user data by email:", error); + } + } + // --- End Clerk User Data Fetching --- + + // --- Users Table Data Prep --- + const latestResponseMap = new Map() + const userIds = assessmentUsers.map((au) => au.userId).filter((id): id is number => id !== null); + if (userIds.length > 0) { + const responsesForLatest = await db.assessmentUserResponse.findMany({ + where: { assessmentId: assessmentId, userId: { in: userIds } }, + select: { userId: true, attributeId: true, id: true }, + orderBy: { id: 'asc' }, + }); + for (const response of responsesForLatest) { + // Ensure userId is not null before setting in the map + if (response.userId !== null) { + latestResponseMap.set(response.userId, response.attributeId); + } + } + } + + const usersTableData: AssessmentUserDashboardData[] = assessmentUsers.map((assessmentUser) => { + const firstName = assessmentUser.user?.firstName ?? ''; + const lastName = assessmentUser.user?.lastName ?? ''; + const name = `${firstName} ${lastName}`.trim() || ''; + // Ensure userId is not null before getting from the map + const lastCompletedAttributeId = assessmentUser.userId !== null ? latestResponseMap.get(assessmentUser.userId) ?? '' : ''; + const userEmail = assessmentUser.user?.email; + const clerkData = userEmail ? clerkUserDataMap.get(userEmail.toLowerCase()) : undefined; + return { + id: assessmentUser.id, + prismaUserId: assessmentUser.userId, + clerkUserId: clerkData?.clerkUserId ?? null, + name: name, + email: userEmail ?? '', + role: assessmentUser.role, + group: assessmentUser.assessmentUserGroup?.name ?? '', + lastCompletedAttributeId: lastCompletedAttributeId, + lastSignInAt: clerkData?.lastSignInAt ?? null, + signInMethod: clerkData ? (clerkData.signInMethod !== 'Unknown' ? clerkData.signInMethod : 'Email') : '', + }; + }); + // --- End Users Table Data Prep --- + + + // Custom sort function for attribute IDs + const sortAttributeIds = (a: { attributeId: string }, b: { attributeId: string }) => { + const aHasPeriod = a.attributeId.includes('.'); + const bHasPeriod = b.attributeId.includes('.'); + if (aHasPeriod && !bHasPeriod) return 1; + if (!aHasPeriod && bHasPeriod) return -1; + return a.attributeId.localeCompare(b.attributeId, undefined, { numeric: true, sensitivity: 'base' }); + }; + + // Sort the fetched attributes using the custom function + const sortedAssessmentAttributes = [...assessmentAttributes].sort(sortAttributeIds); + + return { + assessment, + assessmentParts, + assessmentUserGroups, + assessmentUsers, // Pass the raw users with details including relations + sortedAssessmentAttributes, + allAssessmentUserResponses, + usersTableData + }; +} + + +export default async function FacilitatorDashboardPage({ params }: FacilitatorDashboardPageProps) { + // Fetch data on the server + const data = await getData(params); + + // If data is null (unauthorized), render NotFound + if (!data) { + return ; + } + + // Destructure data for clarity + const { + assessment, + assessmentParts, + assessmentUserGroups, + assessmentUsers, + sortedAssessmentAttributes, + allAssessmentUserResponses, + usersTableData + } = data; + + // The assessmentUsers fetched includes the 'user' and 'assessmentUserGroup' relations + // We can safely cast it here when passing to the client component + const assessmentUsersForClient = assessmentUsers as AssessmentUserWithDetails[]; + + return ( +
+

Facilitator Dashboard - {assessment.name}

+ + {/* --- Original Static Cards --- */} +
+ + Assessment + +
+

Type: {assessment.assessmentCollection?.assessmentTypes.name ?? ''}

+

+ Status:{" "} + + {assessment.status} + +

+

Completed Date: {assessment.completedDate?.toLocaleDateString() ?? ''}

+
+
+
+ + + Assessment Parts + + {assessmentParts.length > 0 ? ( +
    + {assessmentParts.map((assessmentPart: PrismaTypes.AssessmentPart & { part: PrismaTypes.Part }) => ( +
  • + {assessmentPart.part.name} + + {assessmentPart.status ?? ''} + +
  • + ))} +
+ ) : ( +

No parts found for this assessment.

+ )} +
+
+ + + Assessment User Groups + + {assessmentUserGroups.length > 0 ? ( +
    + {assessmentUserGroups.map((group: PrismaTypes.AssessmentUserGroup) => ( +
  • + {group.name} + + {group.status ?? ''} + +
  • + ))} +
+ ) : ( +

No user groups found for this assessment.

+ )} +
+
+ + {/* --- Original Users Table (in Accordion) --- */} + + + + + Assessment Users (Details) + + {usersTableData.length > 0 ? ( + + ) : ( +

No users found for this assessment.

+ )} +
+
+
+
+
+
+ + {/* --- Client Component for Filters + Progress Table --- */} + + +
+ ); +} \ No newline at end of file