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 tasks, required List sheets, required Set 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 sheets, required Set leaveDays, required DateTime today, }) { final qualifyingDays = {}; 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 qualifyingDays, Set 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 qualifyingDays, Set 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 sheets, Set leaveDays, ) { if (sheets.isEmpty) return false; final firstPassDays = {}; for (final sheet in sheets) { if (sheet.isFirstPassApproved) firstPassDays.add(sheet.workDate); } // Collect unique Monday starts from all sheet work dates final weeks = {}; 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 computeBadges( ProgrammerStats stats, { required bool includeSheetBadges, }) { final badges = []; 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 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, ); }