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>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import 'package:tasq/utils/programmer_daily_time.dart';
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
|
||||
/// Status constants for a programmer day sheet.
|
||||
class DaySheetStatus {
|
||||
static const String draft = 'draft';
|
||||
static const String pending = 'pending';
|
||||
static const String disapproved = 'disapproved';
|
||||
static const String approved = 'approved';
|
||||
|
||||
static String label(String s) {
|
||||
switch (s) {
|
||||
case draft:
|
||||
return 'Draft';
|
||||
case pending:
|
||||
return 'Pending';
|
||||
case disapproved:
|
||||
return 'Disapproved';
|
||||
case approved:
|
||||
return 'Approved';
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable snapshot of a day sheet at approval time.
|
||||
class DaySheetSnapshot {
|
||||
final int totalSeconds;
|
||||
final List<DaySheetRow> rows;
|
||||
|
||||
const DaySheetSnapshot({required this.totalSeconds, required this.rows});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'v': 1,
|
||||
'total_seconds': totalSeconds,
|
||||
'rows': rows.map((r) => r.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory DaySheetSnapshot.fromJson(Map<String, dynamic> j) {
|
||||
final rowsList = (j['rows'] as List? ?? [])
|
||||
.map((r) => DaySheetRow.fromJson(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
return DaySheetSnapshot(
|
||||
totalSeconds: (j['total_seconds'] as num).toInt(),
|
||||
rows: rowsList,
|
||||
);
|
||||
}
|
||||
|
||||
factory DaySheetSnapshot.fromRows(List<DaySheetRow> rows) {
|
||||
final total = rows.fold(0, (sum, r) => sum + r.seconds);
|
||||
return DaySheetSnapshot(totalSeconds: total, rows: rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// A programmer's daily time-sheet submission for one work day.
|
||||
class ProgrammerDaySheet {
|
||||
final String id;
|
||||
final String programmerId;
|
||||
final String status;
|
||||
final DateTime workDate;
|
||||
final DateTime? submittedAt;
|
||||
final DateTime? reviewedAt;
|
||||
final bool autoSubmitted;
|
||||
final String? reviewedBy;
|
||||
final int resubmissions;
|
||||
final DaySheetSnapshot? approvedSnapshot;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const ProgrammerDaySheet({
|
||||
required this.id,
|
||||
required this.programmerId,
|
||||
required this.status,
|
||||
required this.workDate,
|
||||
this.submittedAt,
|
||||
this.reviewedAt,
|
||||
this.autoSubmitted = false,
|
||||
this.reviewedBy,
|
||||
this.resubmissions = 0,
|
||||
this.approvedSnapshot,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// True when approved on the first submission (no resubmissions needed).
|
||||
bool get isFirstPassApproved =>
|
||||
status == DaySheetStatus.approved && resubmissions == 0;
|
||||
|
||||
factory ProgrammerDaySheet.fromMap(Map<String, dynamic> m) {
|
||||
return ProgrammerDaySheet(
|
||||
id: m['id'] as String,
|
||||
programmerId: m['programmer_id'] as String,
|
||||
status: m['status'] as String? ?? DaySheetStatus.draft,
|
||||
workDate: parseDayKey(m['work_date'] as String),
|
||||
submittedAt: m['submitted_at'] != null
|
||||
? DateTime.parse(m['submitted_at'] as String)
|
||||
: null,
|
||||
reviewedAt: m['reviewed_at'] != null
|
||||
? DateTime.parse(m['reviewed_at'] as String)
|
||||
: null,
|
||||
autoSubmitted: m['auto_submitted'] as bool? ?? false,
|
||||
reviewedBy: m['reviewed_by'] as String?,
|
||||
resubmissions: (m['resubmissions'] as num?)?.toInt() ?? 0,
|
||||
approvedSnapshot: m['approved_snapshot'] != null
|
||||
? DaySheetSnapshot.fromJson(
|
||||
m['approved_snapshot'] as Map<String, dynamic>)
|
||||
: null,
|
||||
createdAt: DateTime.parse(m['created_at'] as String),
|
||||
updatedAt: DateTime.parse(m['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:tasq/utils/programmer_stats.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
ProgrammerDaySheet makeSheet({
|
||||
String id = 'id',
|
||||
String programmerId = 'user1',
|
||||
String status = 'pending',
|
||||
required DateTime workDate,
|
||||
int resubmissions = 0,
|
||||
DaySheetSnapshot? approvedSnapshot,
|
||||
}) {
|
||||
return ProgrammerDaySheet(
|
||||
id: id,
|
||||
programmerId: programmerId,
|
||||
status: status,
|
||||
workDate: workDate,
|
||||
resubmissions: resubmissions,
|
||||
approvedSnapshot: approvedSnapshot,
|
||||
createdAt: DateTime.utc(2026, 1, 1),
|
||||
updatedAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
ProgrammerStats statsWith({
|
||||
int completedTotal = 0,
|
||||
int bugFixCompleted = 0,
|
||||
Duration approvedTotal = Duration.zero,
|
||||
Duration helpGivenTotal = Duration.zero,
|
||||
Duration helpGivenThisMonth = Duration.zero,
|
||||
Duration todayFocus = Duration.zero,
|
||||
int completedThisWeek = 0,
|
||||
int completedLastWeek = 0,
|
||||
int currentStreak = 0,
|
||||
int bestStreak = 0,
|
||||
double? firstPassRate,
|
||||
bool hasCleanWeek = false,
|
||||
}) {
|
||||
return ProgrammerStats(
|
||||
todayFocus: todayFocus,
|
||||
helpGivenThisMonth: helpGivenThisMonth,
|
||||
approvedTotal: approvedTotal,
|
||||
helpGivenTotal: helpGivenTotal,
|
||||
completedThisWeek: completedThisWeek,
|
||||
completedLastWeek: completedLastWeek,
|
||||
currentStreak: currentStreak,
|
||||
bestStreak: bestStreak,
|
||||
completedTotal: completedTotal,
|
||||
bugFixCompleted: bugFixCompleted,
|
||||
firstPassRate: firstPassRate,
|
||||
hasCleanWeek: hasCleanWeek,
|
||||
);
|
||||
}
|
||||
|
||||
test('fromMap parses work_date and snapshot', () {
|
||||
final ts = DateTime.utc(2026, 9, 28).toIso8601String();
|
||||
final m = <String, dynamic>{
|
||||
'id': 'ds1',
|
||||
'programmer_id': 'user1',
|
||||
'status': 'approved',
|
||||
'work_date': '2026-09-23',
|
||||
'submitted_at': null,
|
||||
'reviewed_at': null,
|
||||
'auto_submitted': false,
|
||||
'reviewed_by': null,
|
||||
'resubmissions': 0,
|
||||
'approved_snapshot': <String, dynamic>{
|
||||
'v': 1,
|
||||
'total_seconds': 3600,
|
||||
'rows': <dynamic>[],
|
||||
},
|
||||
'created_at': ts,
|
||||
'updated_at': ts,
|
||||
};
|
||||
final sheet = ProgrammerDaySheet.fromMap(m);
|
||||
expect(sheet.workDate, DateTime.utc(2026, 9, 23));
|
||||
expect(sheet.approvedSnapshot?.totalSeconds, 3600);
|
||||
expect(sheet.isFirstPassApproved, isTrue);
|
||||
});
|
||||
|
||||
group('computeStreak', () {
|
||||
test('streak counts weekdays back from today, skipping weekends', () {
|
||||
// today Mon Sep 28 (no sheet);
|
||||
// sheets: Wed Sep 23 pending, Thu Sep 24 pending, Fri Sep 25 approved
|
||||
final sheets = [
|
||||
makeSheet(id: 'a', workDate: DateTime.utc(2026, 9, 23), status: 'pending'),
|
||||
makeSheet(id: 'b', workDate: DateTime.utc(2026, 9, 24), status: 'pending'),
|
||||
makeSheet(id: 'c', workDate: DateTime.utc(2026, 9, 25), status: 'approved'),
|
||||
];
|
||||
final result = computeStreak(
|
||||
sheets: sheets,
|
||||
leaveDays: {},
|
||||
today: DateTime.utc(2026, 9, 28),
|
||||
);
|
||||
expect(result.current, 3);
|
||||
});
|
||||
|
||||
test('a disapproved day breaks the streak', () {
|
||||
// Fri Sep 25 disapproved -> streak breaks immediately after skipping today
|
||||
final sheets = [
|
||||
makeSheet(id: 'a', workDate: DateTime.utc(2026, 9, 25), status: 'disapproved'),
|
||||
];
|
||||
final result = computeStreak(
|
||||
sheets: sheets,
|
||||
leaveDays: {},
|
||||
today: DateTime.utc(2026, 9, 28),
|
||||
);
|
||||
expect(result.current, 0);
|
||||
});
|
||||
|
||||
test('approved leave days are neutral', () {
|
||||
// Thu Sep 24 in leaveDays (no sheet); Wed Sep 23 approved, Fri Sep 25 approved;
|
||||
// today Mon Sep 28 (no sheet) -> current 2
|
||||
final sheets = [
|
||||
makeSheet(id: 'a', workDate: DateTime.utc(2026, 9, 23), status: 'approved'),
|
||||
makeSheet(id: 'b', workDate: DateTime.utc(2026, 9, 25), status: 'approved'),
|
||||
];
|
||||
final result = computeStreak(
|
||||
sheets: sheets,
|
||||
leaveDays: {DateTime.utc(2026, 9, 24)},
|
||||
today: DateTime.utc(2026, 9, 28),
|
||||
);
|
||||
expect(result.current, 2);
|
||||
});
|
||||
|
||||
test('best streak finds the longest earlier run', () {
|
||||
// Earlier run of 4: Mon Sep 7, Tue Sep 8, Wed Sep 9, Thu Sep 10 all approved
|
||||
// Then gap (Fri Sep 11 missing), later Fri Sep 25 approved (run=1)
|
||||
// today Mon Sep 28 (no sheet) -> current=1, best=4
|
||||
final sheets = [
|
||||
makeSheet(id: 'a', workDate: DateTime.utc(2026, 9, 7), status: 'approved'),
|
||||
makeSheet(id: 'b', workDate: DateTime.utc(2026, 9, 8), status: 'approved'),
|
||||
makeSheet(id: 'c', workDate: DateTime.utc(2026, 9, 9), status: 'approved'),
|
||||
makeSheet(id: 'd', workDate: DateTime.utc(2026, 9, 10), status: 'approved'),
|
||||
makeSheet(id: 'e', workDate: DateTime.utc(2026, 9, 25), status: 'approved'),
|
||||
];
|
||||
final result = computeStreak(
|
||||
sheets: sheets,
|
||||
leaveDays: {},
|
||||
today: DateTime.utc(2026, 9, 28),
|
||||
);
|
||||
expect(result.best, 4);
|
||||
});
|
||||
});
|
||||
|
||||
group('firstPassRate', () {
|
||||
test('first-pass rate over 30 days', () {
|
||||
// 2 approved(r=0), 1 approved(r=1), 1 disapproved -> 2 first-pass / 4 reviewed = 0.5
|
||||
final now = DateTime.utc(2026, 9, 28);
|
||||
final base = DateTime.utc(2026, 9, 20);
|
||||
final sheets = [
|
||||
makeSheet(id: 'a', workDate: base, status: 'approved', resubmissions: 0),
|
||||
makeSheet(
|
||||
id: 'b',
|
||||
workDate: base.add(const Duration(days: 1)),
|
||||
status: 'approved',
|
||||
resubmissions: 0),
|
||||
makeSheet(
|
||||
id: 'c',
|
||||
workDate: base.add(const Duration(days: 2)),
|
||||
status: 'approved',
|
||||
resubmissions: 1),
|
||||
makeSheet(
|
||||
id: 'd',
|
||||
workDate: base.add(const Duration(days: 3)),
|
||||
status: 'disapproved',
|
||||
resubmissions: 0),
|
||||
];
|
||||
final stats = computeProgrammerStats(
|
||||
userId: 'user1',
|
||||
now: now,
|
||||
tasks: [],
|
||||
sheets: sheets,
|
||||
leaveDays: {},
|
||||
todayFocus: Duration.zero,
|
||||
);
|
||||
expect(stats.firstPassRate, closeTo(0.5, 0.001));
|
||||
});
|
||||
|
||||
test('no reviewed sheets gives null first-pass rate', () {
|
||||
final stats = computeProgrammerStats(
|
||||
userId: 'user1',
|
||||
now: DateTime.utc(2026, 9, 28),
|
||||
tasks: [],
|
||||
sheets: [
|
||||
makeSheet(
|
||||
id: 'a',
|
||||
workDate: DateTime.utc(2026, 9, 20),
|
||||
status: 'draft',
|
||||
resubmissions: 0),
|
||||
],
|
||||
leaveDays: {},
|
||||
todayFocus: Duration.zero,
|
||||
);
|
||||
expect(stats.firstPassRate, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('completedThisWeek uses a Monday-start week', () {
|
||||
// now = Mon Sep 28, 2026
|
||||
// week Mon Sep 28 - Sun Oct 4; last week Mon Sep 21 - Sun Sep 27
|
||||
final now = DateTime.utc(2026, 9, 28);
|
||||
|
||||
ProgrammerTask makeTask(String id, DateTime completedAt) {
|
||||
return ProgrammerTask(
|
||||
id: id,
|
||||
title: 'Task $id',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
assigneeId: 'user1',
|
||||
createdAt: DateTime.utc(2026, 1, 1),
|
||||
updatedAt: DateTime.utc(2026, 1, 1),
|
||||
completedAt: completedAt,
|
||||
);
|
||||
}
|
||||
|
||||
final tasks = [
|
||||
makeTask('t1', DateTime.utc(2026, 9, 28)), // this week (Mon)
|
||||
makeTask('t2', DateTime.utc(2026, 9, 29)), // this week (Tue)
|
||||
makeTask('t3', DateTime.utc(2026, 9, 27)), // last week (Sun Sep 27)
|
||||
makeTask('t4', DateTime.utc(2026, 9, 21)), // last week (Mon Sep 21)
|
||||
];
|
||||
|
||||
final stats = computeProgrammerStats(
|
||||
userId: 'user1',
|
||||
now: now,
|
||||
tasks: tasks,
|
||||
sheets: [],
|
||||
leaveDays: {},
|
||||
todayFocus: Duration.zero,
|
||||
);
|
||||
|
||||
expect(stats.completedThisWeek, 2);
|
||||
expect(stats.completedLastWeek, 2);
|
||||
});
|
||||
|
||||
group('computeBadges', () {
|
||||
test('tiered badge shows highest earned tier and next target', () {
|
||||
final b =
|
||||
computeBadges(statsWith(completedTotal: 12), includeSheetBadges: false)
|
||||
.firstWhere((b) => b.id == 'finisher');
|
||||
expect(b.earned, isTrue);
|
||||
expect(b.title, 'Finisher · 10');
|
||||
expect(b.target, 50);
|
||||
});
|
||||
|
||||
test('includeSheetBadges false hides sheet-based badges', () {
|
||||
final ids =
|
||||
computeBadges(statsWith(), includeSheetBadges: false)
|
||||
.map((b) => b.id)
|
||||
.toSet();
|
||||
expect(
|
||||
ids,
|
||||
isNot(containsAll(
|
||||
['deep_work', 'on_a_roll', 'team_player', 'clean_week'])));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user