diff --git a/lib/screens/programmer_tasks/report/accomplishment_report_data.dart b/lib/screens/programmer_tasks/report/accomplishment_report_data.dart new file mode 100644 index 00000000..b444ee23 --- /dev/null +++ b/lib/screens/programmer_tasks/report/accomplishment_report_data.dart @@ -0,0 +1,214 @@ +import 'package:tasq/models/programmer_day_sheet.model.dart'; +import 'package:tasq/models/programmer_task.model.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/providers/reports_provider.dart'; +import 'package:tasq/utils/programmer_day_rows.dart'; + +/// A single day's report with status and rows. +class ReportDay { + final DateTime day; + final String? statusTag; // null when approved; 'Pending' | 'Disapproved' + final List rows; + + const ReportDay({ + required this.day, + required this.statusTag, + required this.rows, + }); +} + +/// Complete accomplishment report data for a programmer over a date range. +class AccomplishmentReportData { + final String programmerName; + final String positionLabel; // 'Programmer' | 'Administrator' + final DateTime periodStart; + final DateTime periodEndInclusive; // day keys (UTC) + final List days; // ascending by day + final int excludedPending; + final int excludedDisapproved; + final int daysReported; + final int totalSeconds; + final int tasksCompleted; + final int helpSeconds; + final Map secondsByCategory; + final String? approverName; + + const AccomplishmentReportData({ + required this.programmerName, + required this.positionLabel, + required this.periodStart, + required this.periodEndInclusive, + required this.days, + required this.excludedPending, + required this.excludedDisapproved, + required this.daysReported, + required this.totalSeconds, + required this.tasksCompleted, + required this.helpSeconds, + required this.secondsByCategory, + required this.approverName, + }); +} + +/// Build accomplishment report data for a programmer over a date range. +/// +/// Rules: +/// - Only sheets with workDate in [range.start, range.end) are considered. +/// - range.end is EXCLUSIVE; periodEndInclusive = range.end.subtract(Duration(days: 1)). +/// - In approved-only mode: include only approved sheets; statusTag = null; use snapshot rows. +/// - In include-unapproved mode: include all; statusTag varies; use liveRowsByDay for +/// non-approved, snapshot for approved. +/// - Days with zero rows are omitted. +/// - helpSeconds: sum row.seconds for rows where row.kind == 'helper'. +/// - secondsByCategory: sum row.seconds keyed by row.category (skip null). +/// - tasksCompleted: count tasks where status == 'completed' AND completedAt (or updatedAt) +/// falls in [range.start, range.end) AND assigneeId == programmer.id. +/// - approverName: if all included approved days have the same reviewedBy and that set is +/// non-empty, look up from profileNames; else null. +/// - daysReported = days.length. +/// - totalSeconds: sum all row.seconds. +AccomplishmentReportData buildAccomplishmentReportData({ + required Profile programmer, + required ReportDateRange range, + required bool includeUnapproved, + required List sheets, + required Map> liveRowsByDay, + required List tasks, + required Map profileNames, +}) { + // Determine position label + final positionLabel = programmer.role == 'administrator' ? 'Administrator' : 'Programmer'; + + // Calculate period boundaries + final periodStart = range.start; + final periodEndInclusive = range.end.subtract(const Duration(days: 1)); + + // Filter sheets in range and split by status + final sheetsInRange = sheets.where((s) { + final workDate = s.workDate; + return !workDate.isBefore(range.start) && workDate.isBefore(range.end); + }).toList(); + + int excludedPending = 0; + int excludedDisapproved = 0; + final reportDays = []; + final allReviewers = {}; + int totalSeconds = 0; + int helpSeconds = 0; + final secondsByCategory = {}; + + if (includeUnapproved) { + // Include all sheets: approved + pending + disapproved + draft + for (final sheet in sheetsInRange) { + final statusTag = sheet.status == DaySheetStatus.approved + ? null + : (sheet.status == DaySheetStatus.pending || sheet.status == DaySheetStatus.draft) + ? 'Pending' + : 'Disapproved'; + + // Get rows: use snapshot for approved, liveRowsByDay for others + final rows = sheet.status == DaySheetStatus.approved + ? sheet.approvedSnapshot?.rows ?? [] + : liveRowsByDay[sheet.workDate] ?? []; + + if (rows.isNotEmpty) { + reportDays.add(ReportDay( + day: sheet.workDate, + statusTag: statusTag, + rows: rows, + )); + + // Accumulate metrics + for (final row in rows) { + totalSeconds += row.seconds; + if (row.kind == 'helper') { + helpSeconds += row.seconds; + } + if (row.category != null) { + secondsByCategory[row.category] = + (secondsByCategory[row.category] ?? 0) + row.seconds; + } + } + } + + // Track reviewers for approved sheets + if (sheet.status == DaySheetStatus.approved && sheet.reviewedBy != null) { + allReviewers.add(sheet.reviewedBy!); + } + } + } else { + // Approved-only mode + for (final sheet in sheetsInRange) { + if (sheet.status == DaySheetStatus.approved) { + final rows = sheet.approvedSnapshot?.rows ?? []; + if (rows.isNotEmpty) { + reportDays.add(ReportDay( + day: sheet.workDate, + statusTag: null, + rows: rows, + )); + + // Accumulate metrics + for (final row in rows) { + totalSeconds += row.seconds; + if (row.kind == 'helper') { + helpSeconds += row.seconds; + } + if (row.category != null) { + secondsByCategory[row.category] = + (secondsByCategory[row.category] ?? 0) + row.seconds; + } + } + } + + // Track reviewer + if (sheet.reviewedBy != null) { + allReviewers.add(sheet.reviewedBy!); + } + } else if (sheet.status == DaySheetStatus.pending) { + excludedPending++; + } else if (sheet.status == DaySheetStatus.disapproved) { + excludedDisapproved++; + } + } + } + + // Sort report days by day key + reportDays.sort((a, b) => a.day.compareTo(b.day)); + + // Determine approver name: only if all approved days have the same reviewer + String? approverName; + if (allReviewers.isNotEmpty && allReviewers.length == 1) { + final reviewer = allReviewers.first; + approverName = profileNames[reviewer]; + } + + // Count completed tasks in range for this programmer + int tasksCompleted = 0; + for (final task in tasks) { + if (task.status == ProgrammerTaskStatus.completed && + task.assigneeId == programmer.id) { + // Use completedAt if available, otherwise updatedAt + final completionDate = task.completedAt ?? task.updatedAt; + if (!completionDate.isBefore(range.start) && completionDate.isBefore(range.end)) { + tasksCompleted++; + } + } + } + + return AccomplishmentReportData( + programmerName: programmer.fullName, + positionLabel: positionLabel, + periodStart: periodStart, + periodEndInclusive: periodEndInclusive, + days: reportDays, + excludedPending: excludedPending, + excludedDisapproved: excludedDisapproved, + daysReported: reportDays.length, + totalSeconds: totalSeconds, + tasksCompleted: tasksCompleted, + helpSeconds: helpSeconds, + secondsByCategory: secondsByCategory, + approverName: approverName, + ); +} diff --git a/test/accomplishment_report_data_test.dart b/test/accomplishment_report_data_test.dart new file mode 100644 index 00000000..fae9111c --- /dev/null +++ b/test/accomplishment_report_data_test.dart @@ -0,0 +1,610 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/programmer_day_sheet.model.dart'; +import 'package:tasq/models/programmer_task.model.dart'; +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/providers/reports_provider.dart'; +import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_data.dart'; +import 'package:tasq/utils/programmer_day_rows.dart'; + +void main() { + group('buildAccomplishmentReportData', () { + final programmer = Profile( + id: 'prog1', + role: 'programmer', + fullName: 'John Doe', + ); + + final admin = Profile( + id: 'admin1', + role: 'administrator', + fullName: 'Admin User', + ); + + final reviewer = Profile( + id: 'reviewer1', + role: 'reviewer', + fullName: 'Jane Reviewer', + ); + + test('approved-only uses snapshots and counts excluded days', () { + final day1 = DateTime.utc(2026, 9, 1); + final day2 = DateTime.utc(2026, 9, 2); + final day3 = DateTime.utc(2026, 9, 3); + final day4 = DateTime.utc(2026, 9, 4); + + final row1 = DaySheetRow( + taskId: 't1', + title: 'Task 1', + category: 'Software Development', + kind: 'assignee', + seconds: 3600, + notes: [], + ); + + final row2 = DaySheetRow( + taskId: 't2', + title: 'Task 2', + category: 'Bug Fix', + kind: 'assignee', + seconds: 1800, + notes: [], + ); + + // 2 approved sheets + final sheets = [ + ProgrammerDaySheet( + id: 'sheet1', + programmerId: 'prog1', + status: 'approved', + workDate: day1, + autoSubmitted: false, + resubmissions: 0, + reviewedBy: 'reviewer1', + approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row1]), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ProgrammerDaySheet( + id: 'sheet2', + programmerId: 'prog1', + status: 'approved', + workDate: day2, + autoSubmitted: false, + resubmissions: 0, + reviewedBy: 'reviewer1', + approvedSnapshot: DaySheetSnapshot(totalSeconds: 1800, rows: [row2]), + createdAt: DateTime.utc(2026, 9, 2), + updatedAt: DateTime.utc(2026, 9, 2), + ), + // 1 pending + ProgrammerDaySheet( + id: 'sheet3', + programmerId: 'prog1', + status: 'pending', + workDate: day3, + autoSubmitted: false, + resubmissions: 0, + createdAt: DateTime.utc(2026, 9, 3), + updatedAt: DateTime.utc(2026, 9, 3), + ), + // 1 disapproved + ProgrammerDaySheet( + id: 'sheet4', + programmerId: 'prog1', + status: 'disapproved', + workDate: day4, + autoSubmitted: false, + resubmissions: 1, + reviewedBy: 'reviewer1', + createdAt: DateTime.utc(2026, 9, 4), + updatedAt: DateTime.utc(2026, 9, 4), + ), + ]; + + final range = ReportDateRange( + start: DateTime.utc(2026, 9, 1), + end: DateTime.utc(2026, 9, 5), + label: 'Test Range', + ); + + final data = buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: false, + sheets: sheets, + liveRowsByDay: {}, + tasks: [], + profileNames: {'reviewer1': 'Jane Reviewer'}, + ); + + expect(data.days.length, 2); + expect(data.excludedPending, 1); + expect(data.excludedDisapproved, 1); + expect(data.days[0].statusTag, null); + expect(data.days[1].statusTag, null); + expect(data.totalSeconds, 5400); + expect(data.daysReported, 2); + }); + + test('include unapproved tags days and uses live rows', () { + final day1 = DateTime.utc(2026, 9, 1); + final day2 = DateTime.utc(2026, 9, 2); + final day3 = DateTime.utc(2026, 9, 3); + final day4 = DateTime.utc(2026, 9, 4); + + final approvedRow = DaySheetRow( + taskId: 't1', + title: 'Task 1', + category: 'Software Development', + kind: 'assignee', + seconds: 3600, + notes: [], + ); + + final pendingRow = DaySheetRow( + taskId: 't2', + title: 'Task 2', + category: 'Bug Fix', + kind: 'assignee', + seconds: 1800, + notes: [], + ); + + final disapprovedRow = DaySheetRow( + taskId: 't3', + title: 'Task 3', + category: 'Meeting', + kind: 'assignee', + seconds: 900, + notes: [], + ); + + final draftRow = DaySheetRow( + taskId: 't4', + title: 'Task 4', + category: 'Software Support', + kind: 'assignee', + seconds: 600, + notes: [], + ); + + final sheets = [ + // approved + ProgrammerDaySheet( + id: 'sheet1', + programmerId: 'prog1', + status: 'approved', + workDate: day1, + autoSubmitted: false, + resubmissions: 0, + reviewedBy: 'reviewer1', + approvedSnapshot: + DaySheetSnapshot(totalSeconds: 3600, rows: [approvedRow]), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + // pending + ProgrammerDaySheet( + id: 'sheet2', + programmerId: 'prog1', + status: 'pending', + workDate: day2, + autoSubmitted: false, + resubmissions: 0, + createdAt: DateTime.utc(2026, 9, 2), + updatedAt: DateTime.utc(2026, 9, 2), + ), + // disapproved + ProgrammerDaySheet( + id: 'sheet3', + programmerId: 'prog1', + status: 'disapproved', + workDate: day3, + autoSubmitted: false, + resubmissions: 1, + reviewedBy: 'reviewer1', + createdAt: DateTime.utc(2026, 9, 3), + updatedAt: DateTime.utc(2026, 9, 3), + ), + // draft + ProgrammerDaySheet( + id: 'sheet4', + programmerId: 'prog1', + status: 'draft', + workDate: day4, + autoSubmitted: false, + resubmissions: 0, + createdAt: DateTime.utc(2026, 9, 4), + updatedAt: DateTime.utc(2026, 9, 4), + ), + ]; + + final range = ReportDateRange( + start: DateTime.utc(2026, 9, 1), + end: DateTime.utc(2026, 9, 5), + label: 'Test Range', + ); + + final liveRowsByDay = { + day2: [pendingRow], + day3: [disapprovedRow], + day4: [draftRow], + }; + + final data = buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: true, + sheets: sheets, + liveRowsByDay: liveRowsByDay, + tasks: [], + profileNames: {'reviewer1': 'Jane Reviewer'}, + ); + + expect(data.days.length, 4); + expect(data.excludedPending, 0); + expect(data.excludedDisapproved, 0); + expect(data.days[0].statusTag, null); // approved + expect(data.days[1].statusTag, 'Pending'); // pending + expect(data.days[2].statusTag, 'Disapproved'); // disapproved + expect(data.days[3].statusTag, 'Pending'); // draft + }); + + test('period end is inclusive', () { + final day1 = DateTime.utc(2026, 9, 1); + + final row = DaySheetRow( + taskId: 't1', + title: 'Task 1', + category: 'Software Development', + kind: 'assignee', + seconds: 3600, + notes: [], + ); + + final sheets = [ + ProgrammerDaySheet( + id: 'sheet1', + programmerId: 'prog1', + status: 'approved', + workDate: day1, + autoSubmitted: false, + resubmissions: 0, + reviewedBy: 'reviewer1', + approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ]; + + final range = ReportDateRange( + start: DateTime.utc(2026, 9, 1), + end: DateTime.utc(2026, 10, 1), // exclusive + label: 'Test Range', + ); + + final data = buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: false, + sheets: sheets, + liveRowsByDay: {}, + tasks: [], + profileNames: {'reviewer1': 'Jane Reviewer'}, + ); + + expect(data.periodStart, DateTime.utc(2026, 9, 1)); + expect(data.periodEndInclusive, DateTime.utc(2026, 9, 30)); + }); + + test('approver name only when one reviewer approved every included day', () { + final day1 = DateTime.utc(2026, 9, 1); + final day2 = DateTime.utc(2026, 9, 2); + final day3 = DateTime.utc(2026, 9, 3); + + final row = DaySheetRow( + taskId: 't1', + title: 'Task 1', + category: 'Software Development', + kind: 'assignee', + seconds: 3600, + notes: [], + ); + + // Test: same reviewer for all approved days + final sheets1 = [ + ProgrammerDaySheet( + id: 'sheet1', + programmerId: 'prog1', + status: 'approved', + workDate: day1, + autoSubmitted: false, + resubmissions: 0, + reviewedBy: 'reviewer1', + approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ProgrammerDaySheet( + id: 'sheet2', + programmerId: 'prog1', + status: 'approved', + workDate: day2, + autoSubmitted: false, + resubmissions: 0, + reviewedBy: 'reviewer1', + approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]), + createdAt: DateTime.utc(2026, 9, 2), + updatedAt: DateTime.utc(2026, 9, 2), + ), + ]; + + final range = ReportDateRange( + start: DateTime.utc(2026, 9, 1), + end: DateTime.utc(2026, 9, 4), + label: 'Test Range', + ); + + final data1 = buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: false, + sheets: sheets1, + liveRowsByDay: {}, + tasks: [], + profileNames: {'reviewer1': 'Jane Reviewer'}, + ); + + expect(data1.approverName, 'Jane Reviewer'); + + // Test: mixed reviewers + final sheets2 = [ + ProgrammerDaySheet( + id: 'sheet1', + programmerId: 'prog1', + status: 'approved', + workDate: day1, + autoSubmitted: false, + resubmissions: 0, + reviewedBy: 'reviewer1', + approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ProgrammerDaySheet( + id: 'sheet2', + programmerId: 'prog1', + status: 'approved', + workDate: day2, + autoSubmitted: false, + resubmissions: 0, + reviewedBy: 'reviewer2', + approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]), + createdAt: DateTime.utc(2026, 9, 2), + updatedAt: DateTime.utc(2026, 9, 2), + ), + ]; + + final data2 = buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: false, + sheets: sheets2, + liveRowsByDay: {}, + tasks: [], + profileNames: {'reviewer1': 'Jane Reviewer', 'reviewer2': 'Bob Reviewer'}, + ); + + expect(data2.approverName, null); + }); + + test('tasksCompleted counts tasks completed in range for the programmer', () { + final range = ReportDateRange( + start: DateTime.utc(2026, 9, 1), + end: DateTime.utc(2026, 9, 5), + label: 'Test Range', + ); + + final task1 = ProgrammerTask( + id: 't1', + title: 'Task 1', + category: 'Software Development', + status: 'completed', + assigneeId: 'prog1', + createdAt: DateTime.utc(2026, 9, 1), + completedAt: DateTime.utc(2026, 9, 3), // in range + updatedAt: DateTime.utc(2026, 9, 3), + ); + + final task2 = ProgrammerTask( + id: 't2', + title: 'Task 2', + category: 'Bug Fix', + status: 'completed', + assigneeId: 'prog1', + createdAt: DateTime.utc(2026, 9, 1), + completedAt: DateTime.utc(2026, 8, 31), // before range + updatedAt: DateTime.utc(2026, 8, 31), + ); + + final task3 = ProgrammerTask( + id: 't3', + title: 'Task 3', + category: 'Meeting', + status: 'completed', + assigneeId: 'prog2', // different assignee + createdAt: DateTime.utc(2026, 9, 1), + completedAt: DateTime.utc(2026, 9, 3), + updatedAt: DateTime.utc(2026, 9, 3), + ); + + final task4 = ProgrammerTask( + id: 't4', + title: 'Task 4', + category: 'Software Support', + status: 'in_progress', // not completed + assigneeId: 'prog1', + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 3), + ); + + final task5 = ProgrammerTask( + id: 't5', + title: 'Task 5', + category: 'Software Development', + status: 'completed', + assigneeId: 'prog1', + createdAt: DateTime.utc(2026, 9, 1), + completedAt: DateTime.utc(2026, 9, 5), // at end boundary (exclusive) + updatedAt: DateTime.utc(2026, 9, 5), + ); + + final data = buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: false, + sheets: [], + liveRowsByDay: {}, + tasks: [task1, task2, task3, task4, task5], + profileNames: {}, + ); + + expect(data.tasksCompleted, 1); // only task1 + }); + + test('empty period', () { + final range = ReportDateRange( + start: DateTime.utc(2026, 9, 1), + end: DateTime.utc(2026, 9, 2), + label: 'Test Range', + ); + + final data = buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: false, + sheets: [], + liveRowsByDay: {}, + tasks: [], + profileNames: {}, + ); + + expect(data.days, isEmpty); + expect(data.daysReported, 0); + expect(data.totalSeconds, 0); + expect(data.excludedPending, 0); + expect(data.excludedDisapproved, 0); + }); + + test('positionLabel based on programmer role', () { + final range = ReportDateRange( + start: DateTime.utc(2026, 9, 1), + end: DateTime.utc(2026, 9, 2), + label: 'Test Range', + ); + + final dataProgrammer = buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: false, + sheets: [], + liveRowsByDay: {}, + tasks: [], + profileNames: {}, + ); + + final dataAdmin = buildAccomplishmentReportData( + programmer: admin, + range: range, + includeUnapproved: false, + sheets: [], + liveRowsByDay: {}, + tasks: [], + profileNames: {}, + ); + + expect(dataProgrammer.positionLabel, 'Programmer'); + expect(dataAdmin.positionLabel, 'Administrator'); + }); + + test('helpSeconds and secondsByCategory calculations', () { + final day1 = DateTime.utc(2026, 9, 1); + + final assigneeRow = DaySheetRow( + taskId: 't1', + title: 'Task 1', + category: 'Software Development', + kind: 'assignee', + seconds: 3600, + notes: [], + ); + + final helperRow = DaySheetRow( + taskId: 't2', + title: 'Task 2', + category: 'Bug Fix', + kind: 'helper', + seconds: 1800, + notes: [], + ); + + final anotherHelperRow = DaySheetRow( + taskId: 't3', + title: 'Task 3', + category: 'Software Development', + kind: 'helper', + seconds: 900, + notes: [], + ); + + final row = DaySheetRow( + taskId: 't4', + title: 'Task 4', + category: 'Meeting', + kind: 'assignee', + seconds: 600, + notes: [], + ); + + final sheets = [ + ProgrammerDaySheet( + id: 'sheet1', + programmerId: 'prog1', + status: 'approved', + workDate: day1, + autoSubmitted: false, + resubmissions: 0, + reviewedBy: 'reviewer1', + approvedSnapshot: DaySheetSnapshot( + totalSeconds: 6900, + rows: [assigneeRow, helperRow, anotherHelperRow, row], + ), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ]; + + final range = ReportDateRange( + start: DateTime.utc(2026, 9, 1), + end: DateTime.utc(2026, 9, 2), + label: 'Test Range', + ); + + final data = buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: false, + sheets: sheets, + liveRowsByDay: {}, + tasks: [], + profileNames: {'reviewer1': 'Jane Reviewer'}, + ); + + expect(data.helpSeconds, 2700); // 1800 + 900 + expect(data.secondsByCategory['Software Development'], 4500); // 3600 + 900 + expect(data.secondsByCategory['Bug Fix'], 1800); + expect(data.secondsByCategory['Meeting'], 600); + expect(data.totalSeconds, 6900); + }); + }); +}