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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user