feat(report): accomplishment report data builder
Implement AccomplishmentReportData and buildAccomplishmentReportData() for generating programmer accomplishment reports. Supports both approved-only and include-unapproved modes, with proper row filtering, day aggregation, metrics calculation (totalSeconds, tasksCompleted, helpSeconds, secondsByCategory), and approver tracking. All 8 tests passing. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -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<DaySheetRow> 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<ReportDay> days; // ascending by day
|
||||||
|
final int excludedPending;
|
||||||
|
final int excludedDisapproved;
|
||||||
|
final int daysReported;
|
||||||
|
final int totalSeconds;
|
||||||
|
final int tasksCompleted;
|
||||||
|
final int helpSeconds;
|
||||||
|
final Map<String, int> 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<ProgrammerDaySheet> sheets,
|
||||||
|
required Map<DateTime, List<DaySheetRow>> liveRowsByDay,
|
||||||
|
required List<ProgrammerTask> tasks,
|
||||||
|
required Map<String, String> 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 = <ReportDay>[];
|
||||||
|
final allReviewers = <String>{};
|
||||||
|
int totalSeconds = 0;
|
||||||
|
int helpSeconds = 0;
|
||||||
|
final secondsByCategory = <String, int>{};
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user