Files
tasq/lib/utils/programmer_stats.dart
T
redz1029 cdfeb954fd feat(programmer): day-sheet model + stats, streak and badges
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>
2026-09-27 21:27:03 +08:00

416 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
}