diff --git a/lib/utils/programmer_daily_time.dart b/lib/utils/programmer_daily_time.dart new file mode 100644 index 00000000..83cc2b49 --- /dev/null +++ b/lib/utils/programmer_daily_time.dart @@ -0,0 +1,221 @@ +import 'package:timezone/timezone.dart' as tz; +import 'package:tasq/models/programmer_task.model.dart'; +import 'package:tasq/models/programmer_task_activity_log.model.dart'; +import 'app_time.dart'; + +/// Get the Manila calendar date of an instant as DateTime.utc(y, m, d) +DateTime manilaDay(DateTime instant) { + final utc = instant.isUtc ? instant : instant.toUtc(); + final manila = tz.TZDateTime.from(utc, tz.local); + return DateTime.utc(manila.year, manila.month, manila.day); +} + +/// Get the Manila midnight of a day key as a TZDateTime +DateTime manilaDayStart(DateTime dayKey) { + return AppTime.fromComponents( + year: dayKey.year, + month: dayKey.month, + day: dayKey.day, + hour: 0, + minute: 0, + ); +} + +/// Parse a day key string (e.g., '2026-09-27') to DateTime.utc +DateTime parseDayKey(String yyyyMmDd) { + final parts = yyyyMmDd.split('-'); + return DateTime.utc(int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2])); +} + +/// Format a day key to string (e.g., DateTime.utc(2026, 9, 27) -> '2026-09-27') +String formatDayKey(DateTime dayKey) { + final y = dayKey.year.toString(); + final m = dayKey.month.toString().padLeft(2, '0'); + final d = dayKey.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; +} + +/// Represents a worked interval for an assignee +typedef WorkedInterval = ({String assigneeId, DateTime start, DateTime end}); + +/// Calculate worked intervals from task and activity logs +List workedIntervals({ + required ProgrammerTask task, + required List logs, + required DateTime now, +}) { + final sorted = List.from(logs) + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + final intervals = []; + DateTime? segmentStart; + String currentAssignee = _initialAssignee(task, sorted); + + // Check if there's a synthetic started event needed + if (!sorted.any((l) => l.actionType == 'started') && task.startedAt != null) { + // Inject synthetic started + sorted.insert( + 0, + ProgrammerTaskActivityLog( + id: 'synthetic-start', + taskId: task.id, + actionType: 'started', + createdAt: task.startedAt!, + ), + ); + } + + for (final log in sorted) { + switch (log.actionType) { + case 'started': + case 'resumed': + if (segmentStart == null) { + segmentStart = log.createdAt; + } + break; + + case 'paused': + case 'completed': + case 'cancelled': + if (segmentStart != null) { + intervals.add(( + assigneeId: currentAssignee, + start: segmentStart, + end: log.createdAt, + )); + segmentStart = null; + } + break; + + case 'reassigned': + if (segmentStart != null) { + // Close segment for old assignee + intervals.add(( + assigneeId: currentAssignee, + start: segmentStart, + end: log.createdAt, + )); + } + // Update assignee and reopen segment + if (log.meta?['assignee_id'] != null) { + currentAssignee = log.meta!['assignee_id'] as String; + segmentStart = log.createdAt; + } + break; + } + } + + // Close any remaining open segment + if (segmentStart != null) { + final endTime = task.status == ProgrammerTaskStatus.inProgress + ? now + : (task.completedAt ?? task.cancelledAt ?? now); + intervals.add(( + assigneeId: currentAssignee, + start: segmentStart, + end: endTime, + )); + } + + return intervals; +} + +/// Calculate daily seconds by assignee +Map> dailySecondsByAssignee({ + required ProgrammerTask task, + required List logs, + required DateTime now, +}) { + final intervals = workedIntervals(task: task, logs: logs, now: now); + final result = >{}; + + // Process intervals and split at Manila midnights + for (final interval in intervals) { + var current = interval.start; + final end = interval.end; + + while (current.isBefore(end)) { + final currentDay = manilaDay(current); + final dayStart = manilaDayStart(currentDay); + final nextDayStart = dayStart.add(Duration(days: 1)); + final segmentEnd = end.isBefore(nextDayStart) ? end : nextDayStart; + + final seconds = segmentEnd.difference(current).inSeconds; + + if (!result.containsKey(interval.assigneeId)) { + result[interval.assigneeId] = {}; + } + + result[interval.assigneeId]![currentDay] = + (result[interval.assigneeId]![currentDay] ?? 0) + seconds; + + current = segmentEnd; + } + } + + // Apply adjustments + for (final log in logs) { + if (log.actionType == 'adjustment' && log.meta?['seconds'] != null) { + final adjustSeconds = (log.meta!['seconds'] as num).toInt(); + final adjustDay = manilaDay(log.createdAt); + + // Find the assignee at this time + final assigneeAtTime = _assigneeAtTime(task, logs, log.createdAt); + if (assigneeAtTime != null) { + if (!result.containsKey(assigneeAtTime)) { + result[assigneeAtTime] = {}; + } + final current = + result[assigneeAtTime]![adjustDay] ?? 0; + result[assigneeAtTime]![adjustDay] = + (current - adjustSeconds).clamp(0, 999999999); + } + } + } + + return result; +} + +/// Find the initial assignee based on task and logs +String _initialAssignee( + ProgrammerTask task, + List logs, +) { + // First: check for 'assigned' event + for (final log in logs) { + if (log.actionType == 'assigned' && log.meta?['assignee_id'] != null) { + return log.meta!['assignee_id'] as String; + } + } + + // Second: if any 'reassigned' event exists, use task.creatorId + if (logs.any((l) => l.actionType == 'reassigned') && task.creatorId != null) { + return task.creatorId!; + } + + // Third: use task.assigneeId + return task.assigneeId ?? 'unknown'; +} + +/// Find the assignee at a specific time +String? _assigneeAtTime( + ProgrammerTask task, + List logs, + DateTime time, +) { + final sorted = List.from(logs) + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + var currentAssignee = _initialAssignee(task, sorted); + + for (final log in sorted) { + if (log.createdAt.isAfter(time)) break; + + if (log.actionType == 'reassigned' && + log.meta?['assignee_id'] != null) { + currentAssignee = log.meta!['assignee_id'] as String; + } + } + + return currentAssignee; +} diff --git a/test/programmer_daily_time_test.dart b/test/programmer_daily_time_test.dart new file mode 100644 index 00000000..db4925f7 --- /dev/null +++ b/test/programmer_daily_time_test.dart @@ -0,0 +1,238 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/programmer_task.model.dart'; +import 'package:tasq/models/programmer_task_activity_log.model.dart'; +import 'package:tasq/utils/app_time.dart'; +import 'package:tasq/utils/programmer_daily_time.dart'; + +void main() { + setUpAll(AppTime.initialize); + + /// Helper to create a ProgrammerTaskActivityLog at Manila time d h:m + ProgrammerTaskActivityLog _ev( + String action, + int d, + int h, [ + int m = 0, + Map? meta, + ]) { + return ProgrammerTaskActivityLog( + id: 'ev-$d-$h-$m', + taskId: 'task-1', + actorId: 'actor', + actionType: action, + meta: meta, + createdAt: AppTime.fromComponents( + year: 2026, + month: 9, + day: d, + hour: h, + minute: m, + ), + ); + } + + /// Helper to create a ProgrammerTask + ProgrammerTask _task({ + String status = ProgrammerTaskStatus.inProgress, + String? creatorId = 'a', + String? assigneeId = 'a', + DateTime? startedAt, + DateTime? completedAt, + DateTime? cancelledAt, + }) { + return ProgrammerTask( + id: 'task-1', + title: 'Test Task', + category: ProgrammerTaskCategory.softwareDevelopment, + status: status, + creatorId: creatorId, + assigneeId: assigneeId, + createdAt: AppTime.fromComponents(year: 2026, month: 9, day: 27), + startedAt: startedAt, + completedAt: completedAt, + cancelledAt: cancelledAt, + updatedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27), + ); + } + + test('manilaDay ignores device time zone', () { + // Sep 26 4:30 PM UTC = Sep 27 12:30 AM Manila time + expect( + manilaDay(DateTime.parse('2026-09-26T16:30:00Z')), + DateTime.utc(2026, 9, 27), + ); + // Sep 26 3:59 PM UTC = Sep 26 11:59 PM Manila time + expect( + manilaDay(DateTime.parse('2026-09-26T15:59:00Z')), + DateTime.utc(2026, 9, 26), + ); + }); + + test('day key round-trip', () { + expect(formatDayKey(parseDayKey('2026-09-27')), '2026-09-27'); + expect(formatDayKey(parseDayKey('2025-01-05')), '2025-01-05'); + }); + + test('single day with a pause', () { + final task = _task( + status: ProgrammerTaskStatus.completed, + creatorId: 'a', + assigneeId: 'a', + startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9), + completedAt: + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 15), + ); + final logs = [ + _ev('started', 27, 9), + _ev('paused', 27, 11), + _ev('resumed', 27, 13), + _ev('completed', 27, 15), + ]; + final now = + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20); + + final result = dailySecondsByAssignee(task: task, logs: logs, now: now); + expect(result['a'], {DateTime.utc(2026, 9, 27): 4 * 3600}); + }); + + test('running across Manila midnight splits by day', () { + final task = _task( + status: ProgrammerTaskStatus.inProgress, + creatorId: 'a', + assigneeId: 'a', + startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 26, hour: 22), + ); + final logs = [ + _ev('started', 26, 22), + ]; + final now = + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 2); + + final result = dailySecondsByAssignee(task: task, logs: logs, now: now); + expect(result['a']![DateTime.utc(2026, 9, 26)], 2 * 3600); + expect(result['a']![DateTime.utc(2026, 9, 27)], 2 * 3600); + }); + + test('reassignment credits each assignee for their own span', () { + final task = _task( + status: ProgrammerTaskStatus.completed, + creatorId: 'a', + assigneeId: 'b', + startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9), + completedAt: + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 12), + ); + final logs = [ + _ev('started', 27, 9), + _ev('reassigned', 27, 10, 0, {'assignee_id': 'b'}), + _ev('completed', 27, 12), + ]; + final now = + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20); + + final result = dailySecondsByAssignee(task: task, logs: logs, now: now); + expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); + expect(result['b']![DateTime.utc(2026, 9, 27)], 2 * 3600); + }); + + test('initial assignee comes from the first assigned event', () { + final task = _task( + status: ProgrammerTaskStatus.completed, + creatorId: 'boss', + assigneeId: 'a', + startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9), + completedAt: + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 10), + ); + final logs = [ + _ev('assigned', 27, 8, 0, {'assignee_id': 'a'}), + _ev('started', 27, 9), + _ev('completed', 27, 10), + ]; + final now = + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20); + + final result = dailySecondsByAssignee(task: task, logs: logs, now: now); + expect(result.keys.toList(), ['a']); + expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); + }); + + test('adjustment subtracts on the day created and clamps at zero', () { + // Case 1: adjustment {seconds:1800} at 10:00 + final task1 = _task( + status: ProgrammerTaskStatus.completed, + creatorId: 'a', + assigneeId: 'a', + startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9), + completedAt: + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 11), + ); + final logs1 = [ + _ev('started', 27, 9), + _ev('adjustment', 27, 10, 0, {'seconds': 1800}), + _ev('completed', 27, 11), + ]; + final now = + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20); + + final result1 = + dailySecondsByAssignee(task: task1, logs: logs1, now: now); + expect(result1['a']![DateTime.utc(2026, 9, 27)], 5400); // 7200 - 1800 + + // Case 2: adjustment {seconds: 99999} -> clamps at 0 + final task2 = _task( + status: ProgrammerTaskStatus.completed, + creatorId: 'a', + assigneeId: 'a', + startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9), + completedAt: + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 11), + ); + final logs2 = [ + _ev('started', 27, 9), + _ev('adjustment', 27, 10, 0, {'seconds': 99999}), + _ev('completed', 27, 11), + ]; + + final result2 = + dailySecondsByAssignee(task: task2, logs: logs2, now: now); + expect(result2['a']![DateTime.utc(2026, 9, 27)], 0); // clamped + }); + + test('falls back to task.startedAt when there is no started log', () { + final task = _task( + status: ProgrammerTaskStatus.completed, + creatorId: 'a', + assigneeId: 'a', + startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9), + completedAt: + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 10), + ); + final logs = [ + _ev('completed', 27, 10), + ]; + final now = + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20); + + final result = dailySecondsByAssignee(task: task, logs: logs, now: now); + expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); + }); + + test('a paused in-progress task stops accruing at the pause', () { + final task = _task( + status: ProgrammerTaskStatus.inProgress, + creatorId: 'a', + assigneeId: 'a', + startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9), + ); + final logs = [ + _ev('started', 27, 9), + _ev('paused', 27, 10), + ]; + final now = + AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 15); + + final result = dailySecondsByAssignee(task: task, logs: logs, now: now); + expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); // not 6 hours + }); +}