From cdfeb954fd785a66f521e7da6287c144c41c6fd6 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 27 Sep 2026 21:27:03 +0800 Subject: [PATCH] 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 --- lib/models/programmer_day_sheet.model.dart | 113 ++++++ lib/utils/programmer_stats.dart | 415 +++++++++++++++++++++ test/programmer_stats_test.dart | 264 +++++++++++++ 3 files changed, 792 insertions(+) create mode 100644 lib/models/programmer_day_sheet.model.dart create mode 100644 lib/utils/programmer_stats.dart create mode 100644 test/programmer_stats_test.dart diff --git a/lib/models/programmer_day_sheet.model.dart b/lib/models/programmer_day_sheet.model.dart new file mode 100644 index 00000000..21009461 --- /dev/null +++ b/lib/models/programmer_day_sheet.model.dart @@ -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 rows; + + const DaySheetSnapshot({required this.totalSeconds, required this.rows}); + + Map toJson() => { + 'v': 1, + 'total_seconds': totalSeconds, + 'rows': rows.map((r) => r.toJson()).toList(), + }; + + factory DaySheetSnapshot.fromJson(Map j) { + final rowsList = (j['rows'] as List? ?? []) + .map((r) => DaySheetRow.fromJson(r as Map)) + .toList(); + return DaySheetSnapshot( + totalSeconds: (j['total_seconds'] as num).toInt(), + rows: rowsList, + ); + } + + factory DaySheetSnapshot.fromRows(List 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 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) + : null, + createdAt: DateTime.parse(m['created_at'] as String), + updatedAt: DateTime.parse(m['updated_at'] as String), + ); + } +} diff --git a/lib/utils/programmer_stats.dart b/lib/utils/programmer_stats.dart new file mode 100644 index 00000000..b6cda7d4 --- /dev/null +++ b/lib/utils/programmer_stats.dart @@ -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 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, + ); +} diff --git a/test/programmer_stats_test.dart b/test/programmer_stats_test.dart new file mode 100644 index 00000000..306a63ee --- /dev/null +++ b/test/programmer_stats_test.dart @@ -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 = { + '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': { + 'v': 1, + 'total_seconds': 3600, + 'rows': [], + }, + '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']))); + }); + }); +}