2946945ce3
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>
422 lines
11 KiB
Dart
422 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.
|
||
///
|
||
/// [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,
|
||
);
|
||
}
|