diff --git a/lib/screens/programmer_tasks/monitor/monitor_model.dart b/lib/screens/programmer_tasks/monitor/monitor_model.dart new file mode 100644 index 00000000..4e2714a9 --- /dev/null +++ b/lib/screens/programmer_tasks/monitor/monitor_model.dart @@ -0,0 +1,394 @@ +import 'package:tasq/models/leave_of_absence.model.dart'; +import 'package:tasq/models/pass_slip.model.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/models/programmer_task.model.dart'; +import 'package:tasq/models/programmer_task_work_log.model.dart'; +import 'package:tasq/providers/programmer_day_data_provider.dart'; +import 'package:tasq/utils/programmer_day_rows.dart'; +import 'package:tasq/utils/staff_presence.dart'; + +enum MonitorState { working, paused, idle, onLeave, passSlip } + +class HelpEdge { + final String helperId; + final String assigneeId; + final String taskId; + final String taskTitle; + final int minutes; + final DateTime at; + + HelpEdge({ + required this.helperId, + required this.assigneeId, + required this.taskId, + required this.taskTitle, + required this.minutes, + required this.at, + }); +} + +/// Build help edges from work logs for a given day. +/// +/// Returns edges where: +/// - author != assignee (author is helping) +/// - minutes > 0 +/// - workDate == day +/// +/// Sorted by createdAt newest first. +List buildHelpEdges({ + required List workLogs, + required Map taskById, + required DateTime day, +}) { + final edges = []; + + for (final log in workLogs) { + final task = taskById[log.taskId]; + if (task == null) continue; + + // Filter: author != assignee AND minutes > 0 AND workDate == day + if (log.authorId == task.assigneeId) continue; + if (log.minutes == null || log.minutes! <= 0) continue; + if (log.workDate != day) continue; + + edges.add(HelpEdge( + helperId: log.authorId, + assigneeId: task.assigneeId ?? '', + taskId: log.taskId, + taskTitle: task.title, + minutes: log.minutes!, + at: log.createdAt, + )); + } + + // Sort by createdAt newest first + edges.sort((a, b) => b.at.compareTo(a.at)); + + return edges; +} + +class ProgrammerMonitorEntry { + final Profile programmer; + final MonitorState state; + final ProgrammerTask? currentTask; + final Duration currentTaskToday; + final Duration todayTotal; + final int inProgress; + final int paused; + final int notStarted; + final String? sheetStatus; + final List helped; + final List helpedBy; + + ProgrammerMonitorEntry({ + required this.programmer, + required this.state, + required this.currentTask, + required this.currentTaskToday, + required this.todayTotal, + required this.inProgress, + required this.paused, + required this.notStarted, + this.sheetStatus, + required this.helped, + required this.helpedBy, + }); +} + +/// Build monitor entries for all programmers for a given day. +/// +/// Sort order: working -> paused -> idle -> passSlip -> onLeave, +/// then alphabetical by programmer.fullName within each state group. +List buildMonitorEntries({ + required List programmers, + required DayActivityData today, + required Map runStates, + required List leaves, + required List passSlips, + required Map sheetStatusByUser, + required Map projectNames, + required Map profileNames, + required DateTime now, +}) { + final taskById = {for (final t in today.tasks) t.id: t}; + final activeLeaves = activeLeaveByUser(leaves, now); + final activePassSlips = activePassSlipByUser(passSlips); + + final entries = []; + + for (final programmer in programmers) { + final programmerId = programmer.id; + + // Step 1: Determine state + // Priority: onLeave > passSlip > working/paused/idle + MonitorState state; + if (activeLeaves.containsKey(programmerId)) { + state = MonitorState.onLeave; + } else if (activePassSlips.containsKey(programmerId)) { + state = MonitorState.passSlip; + } else { + // Determine from task states + state = _determineState(programmerId, today.tasks, runStates); + } + + // Step 2: Find current task (oldest running task) + ProgrammerTask? currentTask; + if (state == MonitorState.working) { + currentTask = _findCurrentTask(programmerId, today.tasks, runStates); + } + + // Step 3: Count tasks by state + final (inProgress, paused, notStarted) = + _countTasksByState(programmerId, today.tasks, runStates); + + // Step 4: Calculate durations + final allTasksRows = buildDayRows( + userId: programmerId, + day: today.day, + tasks: today.tasks, + logsByTask: today.logsByTask, + workLogs: today.workLogs, + projectNames: projectNames, + profileNames: profileNames, + now: now, + ); + final todayTotal = Duration(seconds: totalSeconds(allTasksRows)); + + // For current task, filter to only that task's work logs + final currentTaskToday = _calculateCurrentTaskDuration( + programmerId, + currentTask, + today, + projectNames, + profileNames, + now, + ); + + // Step 5: Build helped and helpedBy edges + final helped = _buildHelpedEdges(programmerId, today.workLogs, taskById, today.day); + final helpedBy = _buildHelpedByEdges(programmerId, today.workLogs, taskById, today.day); + + entries.add(ProgrammerMonitorEntry( + programmer: programmer, + state: state, + currentTask: currentTask, + currentTaskToday: currentTaskToday, + todayTotal: todayTotal, + inProgress: inProgress, + paused: paused, + notStarted: notStarted, + sheetStatus: sheetStatusByUser[programmerId], + helped: helped, + helpedBy: helpedBy, + )); + } + + // Sort by state then by name + entries.sort((a, b) { + final stateOrder = [ + MonitorState.working, + MonitorState.paused, + MonitorState.idle, + MonitorState.passSlip, + MonitorState.onLeave, + ]; + final aStateIdx = stateOrder.indexOf(a.state); + final bStateIdx = stateOrder.indexOf(b.state); + + if (aStateIdx != bStateIdx) { + return aStateIdx.compareTo(bStateIdx); + } + + return a.programmer.fullName.compareTo(b.programmer.fullName); + }); + + return entries; +} + +/// Determine state from task states +MonitorState _determineState( + String programmerId, + List tasks, + Map runStates, +) { + final programmerTasks = tasks.where((t) => t.assigneeId == programmerId).toList(); + + var hasRunning = false; + var hasPaused = false; + + for (final task in programmerTasks) { + final runInfo = runStates[task.id]; + if (runInfo == null) continue; + + if (runInfo.state == ProgrammerRunState.running) { + hasRunning = true; + } else if (runInfo.state == ProgrammerRunState.paused) { + hasPaused = true; + } + } + + if (hasRunning) return MonitorState.working; + if (hasPaused) return MonitorState.paused; + return MonitorState.idle; +} + +/// Find the current task (oldest running task by since) +ProgrammerTask? _findCurrentTask( + String programmerId, + List tasks, + Map runStates, +) { + final runningTasks = tasks + .where((t) => + t.assigneeId == programmerId && + runStates[t.id]?.state == ProgrammerRunState.running) + .toList(); + + if (runningTasks.isEmpty) return null; + + // Sort by since (earliest first) + runningTasks.sort((a, b) { + final aSince = runStates[a.id]?.since ?? DateTime(0); + final bSince = runStates[b.id]?.since ?? DateTime(0); + return aSince.compareTo(bSince); + }); + + return runningTasks.first; +} + +/// Count tasks by state +(int inProgress, int paused, int notStarted) _countTasksByState( + String programmerId, + List tasks, + Map runStates, +) { + var inProgress = 0; + var paused = 0; + var notStarted = 0; + + final programmerTasks = tasks.where((t) => t.assigneeId == programmerId).toList(); + + for (final task in programmerTasks) { + final runInfo = runStates[task.id]; + if (runInfo == null) continue; + + if (runInfo.state == ProgrammerRunState.running || + runInfo.state == ProgrammerRunState.paused) { + // This is still "in progress" (running or paused) + // We count these separately + if (runInfo.state == ProgrammerRunState.running) { + inProgress++; + } else { + paused++; + } + } else if (runInfo.state == ProgrammerRunState.notStarted) { + notStarted++; + } + } + + return (inProgress, paused, notStarted); +} + +/// Calculate duration for current task +Duration _calculateCurrentTaskDuration( + String programmerId, + ProgrammerTask? currentTask, + DayActivityData today, + Map projectNames, + Map profileNames, + DateTime now, +) { + if (currentTask == null) return Duration.zero; + + // Filter work logs to only this task + final currentTaskLogs = + today.workLogs.where((w) => w.taskId == currentTask.id).toList(); + + // Filter activity logs to only this task + final currentTaskActivityLogs = { + currentTask.id: today.logsByTask[currentTask.id] ?? [] + }; + + final rows = buildDayRows( + userId: programmerId, + day: today.day, + tasks: [currentTask], + logsByTask: currentTaskActivityLogs, + workLogs: currentTaskLogs, + projectNames: projectNames, + profileNames: profileNames, + now: now, + ); + + return Duration(seconds: totalSeconds(rows)); +} + +/// Build helped edges (where programmer helped others) +List _buildHelpedEdges( + String programmerId, + List workLogs, + Map taskById, + DateTime day, +) { + final edges = []; + + for (final log in workLogs) { + // Filter: author == programmer AND author != assignee AND minutes > 0 AND workDate == day + if (log.authorId != programmerId) continue; + + final task = taskById[log.taskId]; + if (task == null) continue; + + if (log.authorId == task.assigneeId) continue; + if (log.minutes == null || log.minutes! <= 0) continue; + if (log.workDate != day) continue; + + edges.add(HelpEdge( + helperId: log.authorId, + assigneeId: task.assigneeId ?? '', + taskId: log.taskId, + taskTitle: task.title, + minutes: log.minutes!, + at: log.createdAt, + )); + } + + // Sort by createdAt newest first + edges.sort((a, b) => b.at.compareTo(a.at)); + return edges; +} + +/// Build helpedBy edges (where others helped programmer) +List _buildHelpedByEdges( + String programmerId, + List workLogs, + Map taskById, + DateTime day, +) { + final edges = []; + + for (final log in workLogs) { + final task = taskById[log.taskId]; + if (task == null) continue; + + // Filter: task assigned to programmer AND author != programmer AND minutes > 0 AND workDate == day + if (task.assigneeId != programmerId) continue; + + if (log.authorId == programmerId) continue; + if (log.minutes == null || log.minutes! <= 0) continue; + if (log.workDate != day) continue; + + edges.add(HelpEdge( + helperId: log.authorId, + assigneeId: programmerId, + taskId: log.taskId, + taskTitle: task.title, + minutes: log.minutes!, + at: log.createdAt, + )); + } + + // Sort by createdAt newest first + edges.sort((a, b) => b.at.compareTo(a.at)); + return edges; +} diff --git a/test/programmer_monitor_model_test.dart b/test/programmer_monitor_model_test.dart new file mode 100644 index 00000000..da96177b --- /dev/null +++ b/test/programmer_monitor_model_test.dart @@ -0,0 +1,779 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/leave_of_absence.model.dart'; +import 'package:tasq/models/pass_slip.model.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/models/programmer_task.model.dart'; +import 'package:tasq/models/programmer_task_activity_log.model.dart'; +import 'package:tasq/models/programmer_task_work_log.model.dart'; +import 'package:tasq/providers/programmer_day_data_provider.dart'; +import 'package:tasq/screens/programmer_tasks/monitor/monitor_model.dart'; +import 'package:tasq/utils/app_time.dart'; + +void main() { + setUpAll(AppTime.initialize); + + group('HelpEdge', () { + test('creates with all fields', () { + final edge = HelpEdge( + helperId: 'h1', + assigneeId: 'a1', + taskId: 't1', + taskTitle: 'Fix bug', + minutes: 30, + at: DateTime.utc(2026, 9, 27), + ); + expect(edge.helperId, 'h1'); + expect(edge.assigneeId, 'a1'); + expect(edge.taskId, 't1'); + expect(edge.taskTitle, 'Fix bug'); + expect(edge.minutes, 30); + expect(edge.at, DateTime.utc(2026, 9, 27)); + }); + }); + + group('buildHelpEdges', () { + test('no edges when no work logs', () { + final edges = buildHelpEdges( + workLogs: [], + taskById: {}, + day: DateTime.utc(2026, 9, 27), + ); + expect(edges, isEmpty); + }); + + test('skips when author == assignee (not helping)', () { + final task = ProgrammerTask( + id: 't1', + title: 'Task', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'a1', + ); + final log = ProgrammerTaskWorkLog( + id: 'l1', + taskId: 't1', + authorId: 'a1', // Same as assignee + description: 'work', + minutes: 30, + createdAt: DateTime.utc(2026, 9, 27, 10, 0), + workDate: DateTime.utc(2026, 9, 27), + ); + final edges = buildHelpEdges( + workLogs: [log], + taskById: {'t1': task}, + day: DateTime.utc(2026, 9, 27), + ); + expect(edges, isEmpty); + }); + + test('skips when minutes is null', () { + final task = ProgrammerTask( + id: 't1', + title: 'Task', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'a1', + ); + final log = ProgrammerTaskWorkLog( + id: 'l1', + taskId: 't1', + authorId: 'h1', + description: 'work', + minutes: null, + createdAt: DateTime.utc(2026, 9, 27, 10, 0), + workDate: DateTime.utc(2026, 9, 27), + ); + final edges = buildHelpEdges( + workLogs: [log], + taskById: {'t1': task}, + day: DateTime.utc(2026, 9, 27), + ); + expect(edges, isEmpty); + }); + + test('skips when minutes is 0', () { + final task = ProgrammerTask( + id: 't1', + title: 'Task', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'a1', + ); + final log = ProgrammerTaskWorkLog( + id: 'l1', + taskId: 't1', + authorId: 'h1', + description: 'work', + minutes: 0, + createdAt: DateTime.utc(2026, 9, 27, 10, 0), + workDate: DateTime.utc(2026, 9, 27), + ); + final edges = buildHelpEdges( + workLogs: [log], + taskById: {'t1': task}, + day: DateTime.utc(2026, 9, 27), + ); + expect(edges, isEmpty); + }); + + test('skips when workDate != day', () { + final task = ProgrammerTask( + id: 't1', + title: 'Task', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'a1', + ); + final log = ProgrammerTaskWorkLog( + id: 'l1', + taskId: 't1', + authorId: 'h1', + description: 'work', + minutes: 30, + createdAt: DateTime.utc(2026, 9, 26, 10, 0), + workDate: DateTime.utc(2026, 9, 26), + ); + final edges = buildHelpEdges( + workLogs: [log], + taskById: {'t1': task}, + day: DateTime.utc(2026, 9, 27), + ); + expect(edges, isEmpty); + }); + + test('creates edge when all conditions met', () { + final task = ProgrammerTask( + id: 't1', + title: 'Fix bug', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'a1', + ); + final log = ProgrammerTaskWorkLog( + id: 'l1', + taskId: 't1', + authorId: 'h1', + description: 'work', + minutes: 30, + createdAt: DateTime.utc(2026, 9, 27, 10, 0), + workDate: DateTime.utc(2026, 9, 27), + ); + final edges = buildHelpEdges( + workLogs: [log], + taskById: {'t1': task}, + day: DateTime.utc(2026, 9, 27), + ); + expect(edges, hasLength(1)); + expect(edges[0].helperId, 'h1'); + expect(edges[0].assigneeId, 'a1'); + expect(edges[0].taskId, 't1'); + expect(edges[0].taskTitle, 'Fix bug'); + expect(edges[0].minutes, 30); + }); + + test('sorts by createdAt newest first', () { + final task = ProgrammerTask( + id: 't1', + title: 'Fix bug', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'a1', + ); + final log1 = ProgrammerTaskWorkLog( + id: 'l1', + taskId: 't1', + authorId: 'h1', + description: 'work', + minutes: 30, + createdAt: DateTime.utc(2026, 9, 27, 10, 0), + workDate: DateTime.utc(2026, 9, 27), + ); + final log2 = ProgrammerTaskWorkLog( + id: 'l2', + taskId: 't1', + authorId: 'h2', + description: 'work', + minutes: 20, + createdAt: DateTime.utc(2026, 9, 27, 11, 0), + workDate: DateTime.utc(2026, 9, 27), + ); + final edges = buildHelpEdges( + workLogs: [log1, log2], + taskById: {'t1': task}, + day: DateTime.utc(2026, 9, 27), + ); + expect(edges, hasLength(2)); + expect(edges[0].helperId, 'h2'); // Newest first + expect(edges[1].helperId, 'h1'); + }); + }); + + group('buildMonitorEntries', () { + final now = DateTime.utc(2026, 9, 27, 12, 0); + final today = DateTime.utc(2026, 9, 27); + + test('programmer on leave -> onLeave state', () { + final programmer = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Alice', + ); + final leave = LeaveOfAbsence( + id: 'l1', + userId: 'p1', + leaveType: 'Sick Leave', + justification: 'ill', + startTime: DateTime.utc(2026, 9, 27, 8, 0), + endTime: DateTime.utc(2026, 9, 27, 17, 0), + status: 'approved', + filedBy: 'p1', + createdAt: DateTime.utc(2026, 9, 27), + ); + final data = DayActivityData( + day: today, + tasks: [], + logsByTask: {}, + workLogs: [], + ); + final entries = buildMonitorEntries( + programmers: [programmer], + today: data, + runStates: {}, + leaves: [leave], + passSlips: [], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + expect(entries, hasLength(1)); + expect(entries[0].state, MonitorState.onLeave); + }); + + test('programmer with active pass slip -> passSlip state', () { + final programmer = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Alice', + ); + final slip = PassSlip( + id: 's1', + userId: 'p1', + dutyScheduleId: 'd1', + reason: 'out', + status: 'approved', + requestedAt: DateTime.utc(2026, 9, 27), + approvedBy: 'admin', + approvedAt: DateTime.utc(2026, 9, 27), + slipStart: DateTime.utc(2026, 9, 27, 13, 0), + slipEnd: null, + ); + final data = DayActivityData( + day: today, + tasks: [], + logsByTask: {}, + workLogs: [], + ); + final entries = buildMonitorEntries( + programmers: [programmer], + today: data, + runStates: {}, + leaves: [], + passSlips: [slip], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + expect(entries, hasLength(1)); + expect(entries[0].state, MonitorState.passSlip); + }); + + test('programmer with running task -> working state with currentTask', () { + final programmer = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Alice', + ); + final task = ProgrammerTask( + id: 't1', + title: 'Fix bug', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + final data = DayActivityData( + day: today, + tasks: [task], + logsByTask: {}, + workLogs: [], + ); + final runStates = { + 't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)), + }; + final entries = buildMonitorEntries( + programmers: [programmer], + today: data, + runStates: runStates, + leaves: [], + passSlips: [], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + expect(entries, hasLength(1)); + expect(entries[0].state, MonitorState.working); + expect(entries[0].currentTask, task); + }); + + test('programmer with only paused task -> paused state', () { + final programmer = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Alice', + ); + final task = ProgrammerTask( + id: 't1', + title: 'Fix bug', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + final data = DayActivityData( + day: today, + tasks: [task], + logsByTask: {}, + workLogs: [], + ); + final runStates = { + 't1': (state: ProgrammerRunState.paused, since: DateTime.utc(2026, 9, 27, 10, 0)), + }; + final entries = buildMonitorEntries( + programmers: [programmer], + today: data, + runStates: runStates, + leaves: [], + passSlips: [], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + expect(entries, hasLength(1)); + expect(entries[0].state, MonitorState.paused); + }); + + test('programmer with no running tasks -> idle state', () { + final programmer = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Alice', + ); + final data = DayActivityData( + day: today, + tasks: [], + logsByTask: {}, + workLogs: [], + ); + final entries = buildMonitorEntries( + programmers: [programmer], + today: data, + runStates: {}, + leaves: [], + passSlips: [], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + expect(entries, hasLength(1)); + expect(entries[0].state, MonitorState.idle); + expect(entries[0].currentTask, isNull); + }); + + test('leave overrides running task (onLeave state)', () { + final programmer = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Alice', + ); + final task = ProgrammerTask( + id: 't1', + title: 'Fix bug', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + final leave = LeaveOfAbsence( + id: 'l1', + userId: 'p1', + leaveType: 'Sick Leave', + justification: 'ill', + startTime: DateTime.utc(2026, 9, 27, 8, 0), + endTime: DateTime.utc(2026, 9, 27, 17, 0), + status: 'approved', + filedBy: 'p1', + createdAt: DateTime.utc(2026, 9, 27), + ); + final data = DayActivityData( + day: today, + tasks: [task], + logsByTask: {}, + workLogs: [], + ); + final runStates = { + 't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)), + }; + final entries = buildMonitorEntries( + programmers: [programmer], + today: data, + runStates: runStates, + leaves: [leave], + passSlips: [], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + expect(entries, hasLength(1)); + expect(entries[0].state, MonitorState.onLeave); + }); + + test('sort order: working, paused, idle, passSlip, onLeave, then name', () { + final p1 = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Zoe', // Should be last after sorting by state + ); + final p2 = Profile( + id: 'p2', + role: 'programmer', + fullName: 'Alice', + ); + final p3 = Profile( + id: 'p3', + role: 'programmer', + fullName: 'Bob', + ); + final p4 = Profile( + id: 'p4', + role: 'programmer', + fullName: 'Charlie', + ); + final p5 = Profile( + id: 'p5', + role: 'programmer', + fullName: 'Diana', + ); + + // p1: working + // p2: paused + // p3: idle + // p4: passSlip + // p5: onLeave + final task1 = ProgrammerTask( + id: 't1', + title: 'Task', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + final task2 = ProgrammerTask( + id: 't2', + title: 'Task', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p2', + ); + + final slip = PassSlip( + id: 's1', + userId: 'p4', + dutyScheduleId: 'd1', + reason: 'out', + status: 'approved', + requestedAt: DateTime.utc(2026, 9, 27), + approvedBy: 'admin', + approvedAt: DateTime.utc(2026, 9, 27), + slipStart: DateTime.utc(2026, 9, 27, 13, 0), + slipEnd: null, + ); + + final leave = LeaveOfAbsence( + id: 'l1', + userId: 'p5', + leaveType: 'Sick Leave', + justification: 'ill', + startTime: DateTime.utc(2026, 9, 27, 8, 0), + endTime: DateTime.utc(2026, 9, 27, 17, 0), + status: 'approved', + filedBy: 'p5', + createdAt: DateTime.utc(2026, 9, 27), + ); + + final data = DayActivityData( + day: today, + tasks: [task1, task2], + logsByTask: {}, + workLogs: [], + ); + + final runStates = { + 't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)), + 't2': (state: ProgrammerRunState.paused, since: DateTime.utc(2026, 9, 27, 11, 0)), + }; + + final entries = buildMonitorEntries( + programmers: [p1, p2, p3, p4, p5], + today: data, + runStates: runStates, + leaves: [leave], + passSlips: [slip], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + + expect(entries, hasLength(5)); + expect(entries[0].programmer.id, 'p1'); // working + expect(entries[1].programmer.id, 'p2'); // paused + expect(entries[2].programmer.id, 'p3'); // idle + expect(entries[3].programmer.id, 'p4'); // passSlip + expect(entries[4].programmer.id, 'p5'); // onLeave + }); + + test('within same state, sort alphabetically by fullName', () { + final p1 = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Zoe', + ); + final p2 = Profile( + id: 'p2', + role: 'programmer', + fullName: 'Alice', + ); + final p3 = Profile( + id: 'p3', + role: 'programmer', + fullName: 'Bob', + ); + + // All idle + final data = DayActivityData( + day: today, + tasks: [], + logsByTask: {}, + workLogs: [], + ); + + final entries = buildMonitorEntries( + programmers: [p1, p2, p3], + today: data, + runStates: {}, + leaves: [], + passSlips: [], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + + expect(entries, hasLength(3)); + expect(entries[0].programmer.fullName, 'Alice'); + expect(entries[1].programmer.fullName, 'Bob'); + expect(entries[2].programmer.fullName, 'Zoe'); + }); + + test('counts inProgress and paused and notStarted tasks', () { + final programmer = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Alice', + ); + + // 2 running, 1 paused, 1 queued + final t1 = ProgrammerTask( + id: 't1', + title: 'Task 1', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + final t2 = ProgrammerTask( + id: 't2', + title: 'Task 2', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + final t3 = ProgrammerTask( + id: 't3', + title: 'Task 3', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + final t4 = ProgrammerTask( + id: 't4', + title: 'Task 4', + category: 'Bug Fix', + status: 'queued', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + + final data = DayActivityData( + day: today, + tasks: [t1, t2, t3, t4], + logsByTask: {}, + workLogs: [], + ); + + final runStates = { + 't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)), + 't2': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)), + 't3': (state: ProgrammerRunState.paused, since: DateTime.utc(2026, 9, 27, 11, 0)), + 't4': (state: ProgrammerRunState.notStarted, since: null), + }; + + final entries = buildMonitorEntries( + programmers: [programmer], + today: data, + runStates: runStates, + leaves: [], + passSlips: [], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + + expect(entries[0].inProgress, 2); + expect(entries[0].paused, 1); + expect(entries[0].notStarted, 1); + }); + + test('currentTask is oldest running task', () { + final programmer = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Alice', + ); + + final t1 = ProgrammerTask( + id: 't1', + title: 'Task 1', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + final t2 = ProgrammerTask( + id: 't2', + title: 'Task 2', + category: 'Bug Fix', + status: 'in_progress', + createdAt: DateTime.utc(2026, 9, 27), + updatedAt: DateTime.utc(2026, 9, 27), + assigneeId: 'p1', + ); + + final data = DayActivityData( + day: today, + tasks: [t1, t2], + logsByTask: {}, + workLogs: [], + ); + + final runStates = { + 't1': ( + state: ProgrammerRunState.running, + since: DateTime.utc(2026, 9, 27, 10, 0), + ), // Earlier + 't2': ( + state: ProgrammerRunState.running, + since: DateTime.utc(2026, 9, 27, 11, 0), + ), // Later + }; + + final entries = buildMonitorEntries( + programmers: [programmer], + today: data, + runStates: runStates, + leaves: [], + passSlips: [], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + + expect(entries[0].currentTask?.id, 't1'); // Oldest running + }); + + test('todayTotal equals totalSeconds for all programmer tasks', () { + // This test verifies that todayTotal matches the expected value + // For now, we just verify the structure is correct + final programmer = Profile( + id: 'p1', + role: 'programmer', + fullName: 'Alice', + ); + + final data = DayActivityData( + day: today, + tasks: [], + logsByTask: {}, + workLogs: [], + ); + + final entries = buildMonitorEntries( + programmers: [programmer], + today: data, + runStates: {}, + leaves: [], + passSlips: [], + sheetStatusByUser: {}, + projectNames: {}, + profileNames: {}, + now: now, + ); + + expect(entries[0].todayTotal, isA()); + expect(entries[0].currentTaskToday, isA()); + }); + }); +}