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:
2026-09-27 21:27:03 +08:00
parent ab82af119b
commit cdfeb954fd
3 changed files with 792 additions and 0 deletions
+113
View File
@@ -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),
);
}
}
+415
View File
@@ -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,
);
}
+264
View File
@@ -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'])));
});
});
}