cdfeb954fd
Add ProgrammerDaySheet model with DaySheetStatus/DaySheetSnapshot, computeProgrammerStats with streak, first-pass rate, approved totals, hasCleanWeek, and tiered badge progress via computeBadges. 10 tests pass. Co-Authored-By: claude-flow <ruv@ruv.net>
416 lines
11 KiB
Dart
416 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||
import 'package:tasq/models/programmer_task.model.dart';
|
||
|
||
/// Aggregated statistics for a programmer's dashboard.
|
||
class ProgrammerStats {
|
||
final Duration todayFocus;
|
||
final Duration helpGivenThisMonth;
|
||
final Duration approvedTotal;
|
||
final Duration helpGivenTotal;
|
||
final int completedThisWeek;
|
||
final int completedLastWeek;
|
||
final int currentStreak;
|
||
final int bestStreak;
|
||
final int completedTotal;
|
||
final int bugFixCompleted;
|
||
final double? firstPassRate;
|
||
final bool hasCleanWeek;
|
||
|
||
const ProgrammerStats({
|
||
required this.todayFocus,
|
||
required this.helpGivenThisMonth,
|
||
required this.approvedTotal,
|
||
required this.helpGivenTotal,
|
||
required this.completedThisWeek,
|
||
required this.completedLastWeek,
|
||
required this.currentStreak,
|
||
required this.bestStreak,
|
||
required this.completedTotal,
|
||
required this.bugFixCompleted,
|
||
this.firstPassRate,
|
||
required this.hasCleanWeek,
|
||
});
|
||
}
|
||
|
||
DateTime _dateOnly(DateTime dt) => DateTime.utc(dt.year, dt.month, dt.day);
|
||
|
||
DateTime _weekStart(DateTime dt) {
|
||
final d = _dateOnly(dt);
|
||
return d.subtract(Duration(days: d.weekday - 1));
|
||
}
|
||
|
||
/// Compute all programmer statistics from raw data.
|
||
ProgrammerStats computeProgrammerStats({
|
||
required String userId,
|
||
required DateTime now,
|
||
required List<ProgrammerTask> tasks,
|
||
required List<ProgrammerDaySheet> sheets,
|
||
required Set<DateTime> leaveDays,
|
||
required Duration todayFocus,
|
||
}) {
|
||
final today = _dateOnly(now);
|
||
final thisWeekStart = _weekStart(today);
|
||
final thisWeekEnd = thisWeekStart.add(const Duration(days: 6)); // Sunday
|
||
final lastWeekStart = thisWeekStart.subtract(const Duration(days: 7));
|
||
final lastWeekEnd = thisWeekStart.subtract(const Duration(days: 1));
|
||
|
||
int completedThisWeek = 0;
|
||
int completedLastWeek = 0;
|
||
int completedTotal = 0;
|
||
int bugFixCompleted = 0;
|
||
|
||
for (final task in tasks) {
|
||
if (task.assigneeId != userId) continue;
|
||
if (task.status != ProgrammerTaskStatus.completed) continue;
|
||
completedTotal++;
|
||
if (task.category == ProgrammerTaskCategory.bugFix) bugFixCompleted++;
|
||
if (task.completedAt != null) {
|
||
final completedDay = _dateOnly(task.completedAt!);
|
||
if (!completedDay.isBefore(thisWeekStart) &&
|
||
!completedDay.isAfter(thisWeekEnd)) {
|
||
completedThisWeek++;
|
||
} else if (!completedDay.isBefore(lastWeekStart) &&
|
||
!completedDay.isAfter(lastWeekEnd)) {
|
||
completedLastWeek++;
|
||
}
|
||
}
|
||
}
|
||
|
||
final streakResult = computeStreak(
|
||
sheets: sheets,
|
||
leaveDays: leaveDays,
|
||
today: today,
|
||
);
|
||
|
||
// firstPassRate: last 30 days (today-29 through today inclusive)
|
||
final thirtyDaysAgo = today.subtract(const Duration(days: 29));
|
||
int reviewedCount = 0;
|
||
int firstPassApprovedCount = 0;
|
||
for (final sheet in sheets) {
|
||
final wd = sheet.workDate;
|
||
if (wd.isBefore(thirtyDaysAgo) || wd.isAfter(today)) continue;
|
||
final isReviewed = sheet.status == DaySheetStatus.approved ||
|
||
sheet.status == DaySheetStatus.disapproved ||
|
||
sheet.resubmissions > 0;
|
||
if (!isReviewed) continue;
|
||
reviewedCount++;
|
||
if (sheet.isFirstPassApproved) firstPassApprovedCount++;
|
||
}
|
||
final double? firstPassRate =
|
||
reviewedCount == 0 ? null : firstPassApprovedCount / reviewedCount;
|
||
|
||
// Approved sums from approvedSnapshot
|
||
int approvedTotalSec = 0;
|
||
int helpGivenTotalSec = 0;
|
||
int helpGivenThisMonthSec = 0;
|
||
for (final sheet in sheets) {
|
||
final snap = sheet.approvedSnapshot;
|
||
if (snap == null) continue;
|
||
approvedTotalSec += snap.totalSeconds;
|
||
for (final row in snap.rows) {
|
||
if (row.kind == 'helper') {
|
||
helpGivenTotalSec += row.seconds;
|
||
if (sheet.workDate.year == now.year &&
|
||
sheet.workDate.month == now.month) {
|
||
helpGivenThisMonthSec += row.seconds;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return ProgrammerStats(
|
||
todayFocus: todayFocus,
|
||
helpGivenThisMonth: Duration(seconds: helpGivenThisMonthSec),
|
||
approvedTotal: Duration(seconds: approvedTotalSec),
|
||
helpGivenTotal: Duration(seconds: helpGivenTotalSec),
|
||
completedThisWeek: completedThisWeek,
|
||
completedLastWeek: completedLastWeek,
|
||
currentStreak: streakResult.current,
|
||
bestStreak: streakResult.best,
|
||
completedTotal: completedTotal,
|
||
bugFixCompleted: bugFixCompleted,
|
||
firstPassRate: firstPassRate,
|
||
hasCleanWeek: _computeHasCleanWeek(sheets, leaveDays),
|
||
);
|
||
}
|
||
|
||
/// Compute current and best approval streaks.
|
||
///
|
||
/// A day qualifies if its sheet status is `pending` or `approved`.
|
||
/// Weekends and [leaveDays] are neutral (skipped, not breaks).
|
||
/// A non-qualifying `today` is also skipped (not a break).
|
||
/// Any other non-neutral weekday without a qualifying sheet breaks the current streak.
|
||
({int current, int best}) computeStreak({
|
||
required List<ProgrammerDaySheet> sheets,
|
||
required Set<DateTime> leaveDays,
|
||
required DateTime today,
|
||
}) {
|
||
final qualifyingDays = <DateTime>{};
|
||
for (final sheet in sheets) {
|
||
if (sheet.status == DaySheetStatus.pending ||
|
||
sheet.status == DaySheetStatus.approved) {
|
||
qualifyingDays.add(sheet.workDate);
|
||
}
|
||
}
|
||
|
||
return (
|
||
current: _currentStreak(qualifyingDays, leaveDays, today),
|
||
best: _bestStreak(qualifyingDays, leaveDays, today),
|
||
);
|
||
}
|
||
|
||
int _currentStreak(
|
||
Set<DateTime> qualifyingDays,
|
||
Set<DateTime> leaveDays,
|
||
DateTime today,
|
||
) {
|
||
int streak = 0;
|
||
bool isFirstDay = true;
|
||
DateTime day = today;
|
||
|
||
for (int i = 0; i < 1000; i++) {
|
||
final weekday = day.weekday; // 1=Mon..7=Sun
|
||
|
||
if (weekday == 6 || weekday == 7) {
|
||
day = day.subtract(const Duration(days: 1));
|
||
continue;
|
||
}
|
||
|
||
if (leaveDays.contains(day)) {
|
||
day = day.subtract(const Duration(days: 1));
|
||
isFirstDay = false;
|
||
continue;
|
||
}
|
||
|
||
if (qualifyingDays.contains(day)) {
|
||
streak++;
|
||
day = day.subtract(const Duration(days: 1));
|
||
isFirstDay = false;
|
||
continue;
|
||
}
|
||
|
||
if (isFirstDay) {
|
||
// today with no qualifying sheet is skipped, not a break
|
||
day = day.subtract(const Duration(days: 1));
|
||
isFirstDay = false;
|
||
continue;
|
||
}
|
||
|
||
break; // non-neutral, non-qualifying weekday that is not today
|
||
}
|
||
|
||
return streak;
|
||
}
|
||
|
||
int _bestStreak(
|
||
Set<DateTime> qualifyingDays,
|
||
Set<DateTime> leaveDays,
|
||
DateTime today,
|
||
) {
|
||
if (qualifyingDays.isEmpty) return 0;
|
||
|
||
final earliest =
|
||
qualifyingDays.reduce((a, b) => a.isBefore(b) ? a : b);
|
||
int best = 0;
|
||
int run = 0;
|
||
DateTime day = earliest;
|
||
|
||
while (!day.isAfter(today)) {
|
||
final weekday = day.weekday;
|
||
|
||
if (weekday == 6 || weekday == 7 || leaveDays.contains(day)) {
|
||
day = day.add(const Duration(days: 1));
|
||
continue;
|
||
}
|
||
|
||
if (qualifyingDays.contains(day)) {
|
||
run++;
|
||
if (run > best) best = run;
|
||
} else {
|
||
run = 0;
|
||
}
|
||
|
||
day = day.add(const Duration(days: 1));
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
bool _computeHasCleanWeek(
|
||
List<ProgrammerDaySheet> sheets,
|
||
Set<DateTime> leaveDays,
|
||
) {
|
||
if (sheets.isEmpty) return false;
|
||
|
||
final firstPassDays = <DateTime>{};
|
||
for (final sheet in sheets) {
|
||
if (sheet.isFirstPassApproved) firstPassDays.add(sheet.workDate);
|
||
}
|
||
|
||
// Collect unique Monday starts from all sheet work dates
|
||
final weeks = <DateTime>{};
|
||
for (final sheet in sheets) {
|
||
final wd = sheet.workDate;
|
||
final monday = wd.subtract(Duration(days: wd.weekday - 1));
|
||
weeks.add(monday);
|
||
}
|
||
|
||
for (final monday in weeks) {
|
||
bool hasApproved = false;
|
||
bool allClean = true;
|
||
|
||
for (int i = 0; i < 5; i++) {
|
||
final day = monday.add(Duration(days: i));
|
||
if (leaveDays.contains(day)) continue;
|
||
if (firstPassDays.contains(day)) {
|
||
hasApproved = true;
|
||
continue;
|
||
}
|
||
allClean = false;
|
||
break;
|
||
}
|
||
|
||
if (allClean && hasApproved) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// Progress toward a badge milestone.
|
||
class BadgeProgress {
|
||
final String id;
|
||
final String title;
|
||
final String description;
|
||
final IconData icon;
|
||
final int current;
|
||
final int target;
|
||
final bool earned;
|
||
|
||
const BadgeProgress({
|
||
required this.id,
|
||
required this.title,
|
||
required this.description,
|
||
required this.icon,
|
||
required this.current,
|
||
required this.target,
|
||
required this.earned,
|
||
});
|
||
}
|
||
|
||
/// Compute badge progress list from [stats].
|
||
///
|
||
/// When [includeSheetBadges] is false, sheet-based badges
|
||
/// (deep_work, on_a_roll, team_player, clean_week) are omitted.
|
||
List<BadgeProgress> computeBadges(
|
||
ProgrammerStats stats, {
|
||
required bool includeSheetBadges,
|
||
}) {
|
||
final badges = <BadgeProgress>[];
|
||
|
||
badges.add(_makeBadge(
|
||
id: 'first_finish',
|
||
title: 'First Finish',
|
||
description: 'Complete your first task',
|
||
icon: Icons.flag_outlined,
|
||
tiers: const [1],
|
||
current: stats.completedTotal,
|
||
));
|
||
|
||
badges.add(_makeBadge(
|
||
id: 'finisher',
|
||
title: 'Finisher',
|
||
description: 'Complete 10 / 50 / 100 tasks',
|
||
icon: Icons.task_alt,
|
||
tiers: const [10, 50, 100],
|
||
current: stats.completedTotal,
|
||
));
|
||
|
||
badges.add(_makeBadge(
|
||
id: 'bug_squasher',
|
||
title: 'Bug Squasher',
|
||
description: 'Complete 10 Bug Fix tasks',
|
||
icon: Icons.bug_report_outlined,
|
||
tiers: const [10],
|
||
current: stats.bugFixCompleted,
|
||
));
|
||
|
||
if (includeSheetBadges) {
|
||
badges.add(_makeBadge(
|
||
id: 'deep_work',
|
||
title: 'Deep Work',
|
||
description: 'Log 100 / 500 approved hours',
|
||
icon: Icons.timer_outlined,
|
||
tiers: const [100, 500],
|
||
current: stats.approvedTotal.inHours,
|
||
));
|
||
|
||
badges.add(_makeBadge(
|
||
id: 'on_a_roll',
|
||
title: 'On a Roll',
|
||
description: 'Reach a 5 / 10 / 20-day streak',
|
||
icon: Icons.local_fire_department_outlined,
|
||
tiers: const [5, 10, 20],
|
||
current: stats.bestStreak,
|
||
));
|
||
|
||
badges.add(_makeBadge(
|
||
id: 'team_player',
|
||
title: 'Team Player',
|
||
description: 'Give 10 hours of help to teammates',
|
||
icon: Icons.handshake_outlined,
|
||
tiers: const [10],
|
||
current: stats.helpGivenTotal.inHours,
|
||
));
|
||
|
||
badges.add(_makeBadge(
|
||
id: 'clean_week',
|
||
title: 'Clean Week',
|
||
description: 'A Mon–Fri week with every day approved first time',
|
||
icon: Icons.verified_outlined,
|
||
tiers: const [1],
|
||
current: stats.hasCleanWeek ? 1 : 0,
|
||
));
|
||
}
|
||
|
||
return badges;
|
||
}
|
||
|
||
BadgeProgress _makeBadge({
|
||
required String id,
|
||
required String title,
|
||
required String description,
|
||
required IconData icon,
|
||
required List<int> tiers,
|
||
required int current,
|
||
}) {
|
||
final earned = current >= tiers.first;
|
||
|
||
// Highest tier reached
|
||
int highestReached = 0;
|
||
for (final tier in tiers) {
|
||
if (current >= tier) highestReached = tier;
|
||
}
|
||
|
||
// Next unreached tier, or top tier when all reached
|
||
int target = tiers.last;
|
||
for (final tier in tiers) {
|
||
if (current < tier) {
|
||
target = tier;
|
||
break;
|
||
}
|
||
}
|
||
|
||
final displayTitle = earned ? '$title · $highestReached' : title;
|
||
|
||
return BadgeProgress(
|
||
id: id,
|
||
title: displayTitle,
|
||
description: description,
|
||
icon: icon,
|
||
current: current,
|
||
target: target,
|
||
earned: earned,
|
||
);
|
||
}
|