Files
tasq/lib/utils/programmer_stats.dart
T
redz1029 2946945ce3 fix(programmer-tasks): tab switching + help-given live data
Tab switching: _buildTabs was recreating TabController on every build
because `_tabs == tabs` compares List by reference identity, not content.
programmerTasksTabsFor() always returns a new list, so the guard was
always false, resetting the controller to initialIndex 0 on every tap.
Fixed by using listEquals from flutter/foundation.

Help given: computeProgrammerStats accumulated helpGivenThisMonth only
from approvedSnapshot rows, which only exist after admin approval.
Programmers who helped others but had pending sheets saw 0m indefinitely.
Fixed by adding helpGivenThisMonthProvider (FutureProvider.family) that
queries programmer_task_work_logs directly for the current Manila month,
and passing it as liveHelpThisMonthSec to computeProgrammerStats.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 22:25:25 +08:00

422 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.
///
/// [liveHelpThisMonthSec] — when provided (from a live work-log query), this
/// value is used directly for [ProgrammerStats.helpGivenThisMonth] instead of
/// the approved-snapshot sum, so the stat reflects pending work too.
ProgrammerStats computeProgrammerStats({
required String userId,
required DateTime now,
required List<ProgrammerTask> tasks,
required List<ProgrammerDaySheet> sheets,
required Set<DateTime> leaveDays,
required Duration todayFocus,
int? liveHelpThisMonthSec,
}) {
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: liveHelpThisMonthSec ?? 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,
);
}