diff --git a/docs/superpowers/plans/2026-09-27-programmer-oversight.md b/docs/superpowers/plans/2026-09-27-programmer-oversight.md new file mode 100644 index 00000000..219d86ab --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-programmer-oversight.md @@ -0,0 +1,909 @@ +# Programmer Oversight Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add daily Day-Sheet approvals (with remarks ↔ justification loop), a live admin Monitor, a PDF Accomplishment Report, and a gamified Mine dashboard to the Programmer Tasks module. + +**Architecture:** Pure Dart utilities compute per-day worked time (clipped to Manila midnights, credited to the assignee at the time) and build "day rows"; every view (dashboard, monitor, day sheet, report) renders those rows. Day-sheet state lives in Supabase (`programmer_day_sheets` + append-only `programmer_day_sheet_events`), changed only through SECURITY DEFINER RPCs and triggers; a pg_cron job auto-submits past drafts. Approval stores a JSON snapshot so approved days are fixed records. + +**Tech Stack:** Flutter 3.47.5 / Dart 3.13, Riverpod 2.6, supabase_flutter 2.x (Postgres, RLS, realtime, pg_cron, Deno edge functions), `pdf` 3.12 + `printing` 5.14 + `pdfrx` 2.6, flutter_quill 11.6. + +**Spec:** `docs/superpowers/specs/2026-09-27-programmer-oversight-design.md` + +## Global Constraints + +- Run Flutter on the host: `$FLUTTER ` means `flatpak-spawn --host sh -c 'cd /home/redz/tasq && ~/flutter/bin/flutter '`. +- Never run `dart format` on existing files (the repo is not formatted; it causes diff churn). New files may be formatted individually. +- Stage files explicitly (`git add `). The working tree has unrelated modified files (`.claude/`, `.swarm/`, `ruvector.db`, `.mcp.json`) — never `git add -A` / `git add .`. Every commit message ends with `Co-Authored-By: claude-flow `. +- Supabase DDL and edge-function deploys are done by the user. Do not call `mcp__supabase__apply_migration` or `deploy_edge_function`; read-only checks via `mcp__supabase__execute_sql` inside `begin; … rollback;` are fine. +- Time zone is Asia/Manila via `AppTime`. **Day keys** are `DateTime.utc(y, m, d)` of the Manila calendar date, produced only by `manilaDay()` / `parseDayKey()` (Task 1). DB `date` values are `yyyy-MM-dd` strings via `formatDayKey()`. `ReportDateRange` uses local `DateTime(y, m, d)` with an **exclusive** `end`; convert with `DateTime.utc(r.start.year, r.start.month, r.start.day)`. +- "Admin" = `profiles.role == 'admin'` via `isStrictAdminProvider` (Task 10) and SQL `role = 'admin'`. Never use `isAdminProvider` (it includes programmers) for approval or monitor gating. +- Status color only via `AppStatusColors` / `StatusKind` (`success, warning, danger, info, neutral`) / `StatusPill.kind`. Section titles `AppSectionHeader(title, trailing:)`; KPI tiles `AppMetricCard(title:, value:, icon:, trailing:)`; codes/durations `TechChip`; cards `M3Card`. No hardcoded status hex. +- Every new file < 500 lines. +- Day sheets and work logs are online-only. When `isOnlineProvider` is false, day-sheet actions are disabled and a banner reads: `You're offline — day sheet actions need a connection.` +- Tests live in `test/`, call `setUpAll(AppTime.initialize)`, and follow the fake-client pattern of `test/programmer_tasks_controller_test.dart` (controller constructed with `fake as dynamic`). Widget tests wrap in `ProviderScope` + `MaterialApp(theme: AppTheme.light())`. +- Known unrelated failures when running the full suite: `theme_overhaul_test`, `profile_screen_test`, `user_management_screen_test`, and time-of-day-dependent cases in `dashboard_metrics_provider_test`. + +## Review Focus + +1. **Device time zone ≠ Manila** (browser set to UTC): an event at `2026-09-26T16:30Z` belongs to Manila day 2026-09-27, and a day sheet's time never leaks across Manila midnight. → Task 1 test `manilaDay ignores device time zone`. +2. **Legacy plain-text work-log descriptions** (stored before Quill, not Delta JSON): shown verbatim in rows, sheet, and PDF — never blank. → Task 2 test `legacy plain text notes pass through`. +3. **Offline-replayed task with no `started` log but `started_at` set**: its time is still counted from `started_at`. → Task 1 test `falls back to task.startedAt`. +4. **Concurrent review** — two admins act on one sheet, or a bulk approval includes a sheet someone already reviewed: single actions fail with "This sheet was already reviewed."; bulk approves the rest and reports the skipped ones. → Task 14 tests + Task 12 SQL checklist items 5 and 8. +5. **Report period with zero approved days**: the PDF still renders the letterhead, the line `No approved accomplishments in this period.`, and the excluded-days footnote. → Task 21 test `empty period`, Task 22 test. + +--- + +# Part A — Slice 1: Mine dashboard (no schema change) + +### Task 1: Day keys and per-day time split + +**Files:** +- Create: `lib/utils/programmer_daily_time.dart` +- Test: `test/programmer_daily_time_test.dart` + +**Interfaces:** +- Consumes: `ProgrammerTask` (`lib/models/programmer_task.model.dart`), `ProgrammerTaskActivityLog` (`actionType`, `meta`, `createdAt`), `AppTime.fromComponents`. +- Produces: + ```dart + DateTime manilaDay(DateTime instant); // DateTime.utc(y,m,d) of the Manila date + DateTime manilaDayStart(DateTime dayKey); // Manila midnight instant (TZDateTime) + DateTime parseDayKey(String yyyyMmDd); // '2026-09-27' -> DateTime.utc(2026,9,27) + String formatDayKey(DateTime dayKey); // -> '2026-09-27' + typedef WorkedInterval = ({String assigneeId, DateTime start, DateTime end}); + List workedIntervals({required ProgrammerTask task, + required List logs, required DateTime now}); + /// assigneeId -> dayKey -> seconds (adjustments applied, each bucket >= 0) + Map> dailySecondsByAssignee({required ProgrammerTask task, + required List logs, required DateTime now}); + ``` + +- [ ] **Step 1: Write the failing tests.** Build fixtures with a helper `_ev(String action, int d, int h, [int m = 0, Map? meta])` creating a log at Manila `2026-09-d h:m` (via `AppTime.fromComponents`), and a helper `_task({status, creatorId, assigneeId, startedAt})`. + + ```dart + test('manilaDay ignores device time zone', () { + expect(manilaDay(DateTime.parse('2026-09-26T16:30:00Z')), DateTime.utc(2026, 9, 27)); + expect(manilaDay(DateTime.parse('2026-09-26T15:59:00Z')), DateTime.utc(2026, 9, 26)); + }); + test('day key round-trip', () { + expect(formatDayKey(parseDayKey('2026-09-27')), '2026-09-27'); + }); + test('single day with a pause', () { + // started 09:00, paused 11:00, resumed 13:00, completed 15:00 on Sep 27; creator=assignee='a' + expect(dailySecondsByAssignee(task: t, logs: logs, now: now), {'a': {DateTime.utc(2026, 9, 27): 4 * 3600}}); + }); + test('running across Manila midnight splits by day', () { + // started Sep 26 22:00, status in_progress, now = Sep 27 02:00 + expect(result['a'], {DateTime.utc(2026, 9, 26): 7200, DateTime.utc(2026, 9, 27): 7200}); + }); + test('reassignment credits each assignee for their own span', () { + // creator 'a', current assignee 'b'; started 09:00, reassigned {assignee_id:'b'} 10:00, completed 12:00 + expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); + expect(result['b']![DateTime.utc(2026, 9, 27)], 7200); + }); + test('initial assignee comes from the first assigned event', () { + // creator 'boss', assigned {assignee_id:'a'}, started 09:00, completed 10:00 + expect(result.keys, ['a']); + }); + test('adjustment subtracts on the day created and clamps at zero', () { + // started 09:00, adjustment {seconds:1800} 10:00, completed 11:00 -> 5400 + // second case: adjustment {seconds: 99999} -> 0 + }); + test('falls back to task.startedAt when there is no started log', () { + // no 'started' log, task.startedAt = Sep 27 09:00, completed 10:00 -> 3600 + }); + test('a paused in-progress task stops accruing at the pause', () { + // started 09:00, paused 10:00, status in_progress, now 15:00 -> 3600 + }); + ``` + +- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_daily_time_test.dart` — Expected: FAIL (file/functions not defined). + +- [ ] **Step 3: Implement.** Rules the tests don't fully pin: + - Walk logs in **ascending** `createdAt` order (input may be any order). + - Initial assignee = meta `assignee_id` of the first `assigned` event; else, if any `reassigned` event exists, `task.creatorId`; else `task.assigneeId`. + - `started`/`resumed` open a segment (if none open); `paused`/`completed`/`cancelled` close it; `reassigned` closes the open segment for the old assignee and, if it was open, reopens it for `meta['assignee_id']`. + - If no `started` event exists and `task.startedAt != null`, treat `startedAt` as a synthetic `started`. + - An open segment at the end closes at `now` only when `task.status == in_progress`; otherwise at `completedAt ?? cancelledAt ?? now`. + - Split each interval at Manila midnights using `manilaDayStart(nextDayKey)`; adjustments subtract `meta['seconds']` from the bucket of the assignee-at-that-time on `manilaDay(event.createdAt)`; clamp each bucket to ≥ 0. + +- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_daily_time_test.dart` — Expected: all PASS. + +- [ ] **Step 5: Commit.** + ```bash + git add lib/utils/programmer_daily_time.dart test/programmer_daily_time_test.dart + git commit -m "feat(programmer): per-day worked time split with assignee attribution + + Co-Authored-By: claude-flow " + ``` + +--- + +### Task 2: Day rows, Quill plain text, work-log `workDate` + +**Files:** +- Create: `lib/utils/programmer_day_rows.dart` +- Modify: `lib/widgets/quill_field.dart` (add a top-level function), `lib/models/programmer_task_work_log.model.dart` (add `workDate`) +- Test: `test/programmer_day_rows_test.dart` + +**Interfaces:** +- Consumes: Task 1 (`manilaDay`, `parseDayKey`, `dailySecondsByAssignee`). +- Produces: + ```dart + // quill_field.dart + String deltaJsonToPlainText(String? stored); // Delta JSON -> trimmed text; non-JSON -> stored.trim(); null -> '' + // programmer_task_work_log.model.dart + final DateTime workDate; // ctor param `DateTime? workDate` -> `workDate ?? manilaDay(createdAt)`; + // fromMap: map['work_date'] != null ? parseDayKey(...) : manilaDay(createdAt) + // programmer_day_rows.dart + class DaySheetRow { + final String taskId, title, category, kind; // kind: 'assignee' | 'helper' + final String? projectName, helpedName; + final int seconds; + final List notes; + Map toJson(); // keys: task_id,title,category,project_name,kind,helped_name,seconds,notes + factory DaySheetRow.fromJson(Map json); + } + List buildDayRows({required String userId, required DateTime day, + required List tasks, + required Map> logsByTask, + required List workLogs, + required Map projectNames, required Map profileNames, + required DateTime now}); + int totalSeconds(List rows); + ``` + +- [ ] **Step 1: Write the failing tests.** + ```dart + test('assignee row: that day seconds + that day notes only', ...); // seconds from Task 1 split; a note from Sep 26 excluded + test('helper row: minutes*60 and helpedName', () { + // work log by 'h' on task assigned to 'a', minutes 45, Sep 27 + expect(row.kind, 'helper'); expect(row.seconds, 2700); expect(row.helpedName, 'Ana Cruz'); + }); + test('notes-only row when the assignee logged but ran no timer', () { expect(row.seconds, 0); }); + test('legacy plain text notes pass through', () { + expect(deltaJsonToPlainText('fixed bug'), 'fixed bug'); + expect(deltaJsonToPlainText('[{"insert":"Hello\\n"}]'), 'Hello'); + expect(deltaJsonToPlainText(null), ''); + }); + test('rows sort: assignee by seconds desc, then helpers by seconds desc', ...); + test('DaySheetRow json round-trip', () { expect(DaySheetRow.fromJson(r.toJson()).toJson(), r.toJson()); }); + test('workDate falls back to the Manila day of createdAt', () { + expect(ProgrammerTaskWorkLog.fromMap({...base, 'created_at': '2026-09-26T16:30:00Z'}).workDate, DateTime.utc(2026, 9, 27)); + expect(ProgrammerTaskWorkLog.fromMap({...base, 'work_date': '2026-09-25'}).workDate, DateTime.utc(2026, 9, 25)); + }); + ``` + +- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_day_rows_test.dart` — Expected: FAIL. + +- [ ] **Step 3: Implement.** Row rules: an **assignee** row exists for a task when `dailySecondsByAssignee(...)[userId]?[day] > 0` or `userId` authored a `minutes == null` work log on it with `workDate == day`. A **helper** row exists for a task when `userId != task.assigneeId` and `userId` authored work logs with `minutes > 0` and `workDate == day`; `seconds = sum(minutes) * 60`, `helpedName = profileNames[task.assigneeId]`. `notes` = `deltaJsonToPlainText` of that user's work logs on that task that day, oldest first, empty strings dropped. Tasks missing from `tasks` are skipped. + +- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_day_rows_test.dart test/programmer_task_ledger_test.dart` — Expected: PASS (ledger test proves the model change is backward compatible). + +- [ ] **Step 5: Commit** `lib/utils/programmer_day_rows.dart lib/widgets/quill_field.dart lib/models/programmer_task_work_log.model.dart test/programmer_day_rows_test.dart` — message `feat(programmer): day rows builder + work-log workDate`. + +--- + +### Task 3: Day activity data, run states, clock ticker + +**Files:** +- Create: `lib/providers/programmer_day_data_provider.dart` +- Test: `test/programmer_day_data_test.dart` + +**Interfaces:** +- Consumes: Task 1, Task 2 (`ProgrammerTaskWorkLog.fromMap`), `programmerTasksProvider`, `supabaseClientProvider`. +- Produces: + ```dart + enum ProgrammerRunState { running, paused, notStarted, completed, cancelled } + typedef ProgrammerRunInfo = ({ProgrammerRunState state, DateTime? since}); + Map computeRunStates(List tasks, + Map> logsByTask); // pure + class DayActivityData { + final DateTime day; final List tasks; + final Map> logsByTask; + final List workLogs; + } + Future fetchDayActivityData(dynamic client, + {required DateTime day, required List tasks, required DateTime now}); + final clockTickerProvider = StreamProvider(...); // AppTime.now() immediately, then every 30 s + final todayKeyProvider = Provider(...); // manilaDay(ticker value ?? AppTime.now()) + final programmerActivitySignalProvider = StreamProvider(...); // +1 per realtime INSERT on either log table + final programmerDayDataProvider = FutureProvider.family(...); // key = day key + final programmerRunStatesProvider = Provider>(...); // from today's day data + ``` + +- [ ] **Step 1: Write the failing tests.** Define a local `_FakeClient` in the test following the controller-test pattern, supporting `select`, `eq`, `inFilter`, `gte`, `lt`, `order`. + ```dart + test('computeRunStates: queued -> notStarted; latest paused -> paused since pause time; resumed -> running; completed', ...); + test('fetchDayActivityData includes in-progress tasks and tasks completed that day', ...); + test('fetchDayActivityData excludes a task completed two days earlier', ...); + test('fetchDayActivityData includes a task referenced only by a helper work log that day', ...); + ``` + +- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_day_data_test.dart` — Expected: FAIL. + +- [ ] **Step 3: Implement.** Candidate tasks for a day = `status == in_progress`, or `startedAt != null && startedAt < dayEnd && (completedAt ?? cancelledAt ?? now) >= dayStart`. Work logs: `programmer_task_work_logs` with `created_at >= dayStart.toUtc()` and `< dayEnd.toUtc()` (Task 15 switches this to `work_date`); their `task_id`s join the candidates. Activity logs: one `select … inFilter('task_id', ids) order created_at desc`, grouped by task. Run state: `in_progress` whose latest of `started/paused/resumed` is `paused` → `paused` (`since` = that event); otherwise `running` (`since` = latest started/resumed, else `startedAt`). The signal provider subscribes one channel `programmer_activity_signal` with two `onPostgresChanges(event: PostgresChangeEvent.insert, …)` handlers and removes it on dispose. `programmerDayDataProvider` watches `programmerTasksProvider`, `programmerActivitySignalProvider`, `supabaseClientProvider`. + +- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_day_data_test.dart` — Expected: PASS. + +- [ ] **Step 5: Commit** — `feat(programmer): day activity data + run-state provider`. + +--- + +### Task 4: Day-sheet model, stats, streak, badges (pure) + +**Files:** +- Create: `lib/models/programmer_day_sheet.model.dart`, `lib/utils/programmer_stats.dart` +- Test: `test/programmer_stats_test.dart` + +**Interfaces:** +- Consumes: Task 1 (`parseDayKey`, `manilaDay`), Task 2 (`DaySheetRow`). +- Produces: + ```dart + class DaySheetStatus { static const draft = 'draft', pending = 'pending', + disapproved = 'disapproved', approved = 'approved'; + static String label(String s); } // 'Draft' | 'Pending' | 'Disapproved' | 'Approved' + class DaySheetSnapshot { final int totalSeconds; final List rows; + Map toJson(); // {'v':1,'total_seconds':..,'rows':[..]} + factory DaySheetSnapshot.fromJson(Map j); + factory DaySheetSnapshot.fromRows(List rows); } + class ProgrammerDaySheet { final String id, programmerId, status; final DateTime workDate; // day key + final DateTime? submittedAt, reviewedAt; final bool autoSubmitted; final String? reviewedBy; + final int resubmissions; final DaySheetSnapshot? approvedSnapshot; + final DateTime createdAt, updatedAt; + factory ProgrammerDaySheet.fromMap(Map m); + bool get isFirstPassApproved; } // status == approved && resubmissions == 0 + class ProgrammerStats { final Duration todayFocus, helpGivenThisMonth, approvedTotal, helpGivenTotal; + final int completedThisWeek, completedLastWeek, currentStreak, bestStreak, completedTotal, bugFixCompleted; + final double? firstPassRate; final bool hasCleanWeek; } + ProgrammerStats computeProgrammerStats({required String userId, required DateTime now, + required List tasks, required List sheets, + required Set leaveDays, required Duration todayFocus}); + ({int current, int best}) computeStreak({required List sheets, + required Set leaveDays, required DateTime today}); + class BadgeProgress { final String id, title, description; final IconData icon; + final int current, target; final bool earned; } + List computeBadges(ProgrammerStats stats, {required bool includeSheetBadges}); + ``` + +- [ ] **Step 1: Write the failing tests.** (2026-09-27 is a Sunday; Mon 21 … Fri 25, Mon 28.) + ```dart + test('fromMap parses work_date and snapshot', ...); + test('streak counts weekdays back from today, skipping weekends', () { + // today Mon 28 (no sheet); Wed 23, Thu 24, Fri 25 pending/approved + expect(computeStreak(sheets: s, leaveDays: {}, today: DateTime.utc(2026, 9, 28)).current, 3); + }); + test('a disapproved day breaks the streak', () { /* Fri 25 disapproved -> current 0 */ }); + test('approved leave days are neutral', () { /* Thu 24 in leaveDays, no sheet; Wed 23 + Fri 25 approved -> 2 */ }); + test('best streak finds the longest earlier run', () { /* earlier run of 4 -> best 4 */ }); + test('first-pass rate over 30 days', () { + // approved(r=0), approved(r=0), approved(r=1), disapproved -> 0.5 ; none reviewed -> null + }); + test('completedThisWeek uses a Monday-start week', ...); + 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', () { + expect(ids, isNot(containsAll(['deep_work', 'on_a_roll', 'team_player', 'clean_week']))); + }); + ``` + +- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_stats_test.dart` — Expected: FAIL. + +- [ ] **Step 3: Implement.** Pinned rules: + - **Streak:** a day qualifies if its sheet is `pending` or `approved`. Walk back from `today`: a non-qualifying `today` is skipped (not a break); Sat/Sun and `leaveDays` are skipped; a qualifying day counts; any other day stops. **Best** = longest run scanning from the earliest sheet day to today with the same neutral rules. + - **Weeks** start Monday. `completedThisWeek` = tasks with `assigneeId == userId`, `status == completed`, `completedAt` in this week. + - **firstPassRate:** sheets with `workDate` in the last 30 days (today−29 … today); reviewed = approved ∨ disapproved ∨ `resubmissions > 0`; null when none reviewed. + - **Approved sums** from `approvedSnapshot`: `approvedTotal` = Σ totalSeconds; `helpGivenTotal` / `helpGivenThisMonth` = Σ seconds of `kind == 'helper'` rows (month of `workDate`). + - **hasCleanWeek:** some Mon–Fri where every weekday is in `leaveDays` or `isFirstPassApproved`, with ≥ 1 approved. + - **Badges** (tiers ascending; `target` = lowest unreached tier or top tier when all reached; `earned` = current ≥ first tier; tiered earned title = `' · <highest reached tier>'`): + + | id | title | tiers | current | sheet-based | icon | description | + |---|---|---|---|---|---|---| + | first_finish | First Finish | 1 | completedTotal | no | `Icons.flag_outlined` | Complete your first task | + | finisher | Finisher | 10/50/100 | completedTotal | no | `Icons.task_alt` | Complete 10 / 50 / 100 tasks | + | bug_squasher | Bug Squasher | 10 | bugFixCompleted | no | `Icons.bug_report_outlined` | Complete 10 Bug Fix tasks | + | deep_work | Deep Work | 100/500 | approvedTotal.inHours | yes | `Icons.timer_outlined` | Log 100 / 500 approved hours | + | on_a_roll | On a Roll | 5/10/20 | bestStreak | yes | `Icons.local_fire_department_outlined` | Reach a 5 / 10 / 20-day streak | + | team_player | Team Player | 10 | helpGivenTotal.inHours | yes | `Icons.handshake_outlined` | Give 10 hours of help to teammates | + | clean_week | Clean Week | 1 | hasCleanWeek ? 1 : 0 | yes | `Icons.verified_outlined` | A Mon–Fri week with every day approved first time | + +- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_stats_test.dart` — Expected: PASS. + +- [ ] **Step 5: Commit** — `feat(programmer): day-sheet model + stats, streak and badges`. + +--- + +### Task 5: Shared task card with run-state pill; shared complete action + +**Files:** +- Create: `lib/screens/programmer_tasks/widgets/programmer_task_card.dart` +- Modify: `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` (delete `_TaskCard`, use the new card), `lib/screens/programmer_tasks/programmer_task_actions.dart` (receive moved function), `lib/screens/programmer_tasks/programmer_task_detail_screen.dart` (`_complete` → call moved function) +- Test: `test/programmer_task_card_test.dart` + +**Interfaces:** +- Consumes: Task 3 (`programmerRunStatesProvider`, `ProgrammerRunState`). +- Produces: + ```dart + class ProgrammerTaskCard extends ConsumerWidget { + const ProgrammerTaskCard({super.key, required this.task, this.assignee, required this.isMine, this.readOnly = false}); + } + Future<void> completeProgrammerTaskWithConfirm(BuildContext context, WidgetRef ref, ProgrammerTask task); // body moved verbatim from detail `_complete` + ``` + +- [ ] **Step 1: Write the failing test.** Override `programmerRunStatesProvider` with `{'t1': (state: ProgrammerRunState.paused, since: …)}`. + ```dart + testWidgets('paused pill shows for any assignee; Resume only for mine and not read-only', (tester) async { + // isMine: false -> find.text('Paused') findsOneWidget, find.text('Resume') findsNothing + // isMine: true -> find.text('Resume') findsOneWidget + // isMine: true, readOnly: true -> find.text('Resume') findsNothing + }); + ``` +- [ ] **Step 2: Run to verify failure.** `$FLUTTER test test/programmer_task_card_test.dart` — FAIL. +- [ ] **Step 3: Implement** — same layout as the old `_TaskCard`; pill = `StatusPill.kind(StatusKind.warning, label: 'Paused')` when run state is paused, else `programmerTaskStatusKind(task.status)` + `ProgrammerTaskStatus.label`. Start/Resume shown only when `isMine && !readOnly`. +- [ ] **Step 4: Verify.** `$FLUTTER test test/programmer_task_card_test.dart test/programmer_tasks_controller_test.dart` — PASS; `$FLUTTER analyze lib` — no new issues. +- [ ] **Step 5: Commit** — `refactor(programmer): shared task card driven by run states`. + +--- + +### Task 6: ProgrammerDashboard — hero, sections, Mine tab + +**Files:** +- Create: `lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart`, `dashboard/now_working_card.dart`, `dashboard/task_sections.dart` +- Modify: `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` (Mine tab body → `ProgrammerDashboard(userId: me)`) +- Test: `test/programmer_dashboard_sections_test.dart` + +**Interfaces:** +- Consumes: Tasks 1, 3, 5 (`dailySecondsByAssignee`, `programmerDayDataProvider`, `programmerRunStatesProvider`, `todayKeyProvider`, `clockTickerProvider`, `ProgrammerTaskCard`, `completeProgrammerTaskWithConfirm`), `startProgrammerTaskWithPausePrompt`, `programmerTasksControllerProvider.pauseTask`. +- Produces: + ```dart + class ProgrammerDashboard extends ConsumerWidget { const ProgrammerDashboard({super.key, required this.userId, this.readOnly = false}); } + typedef TaskSectionGroups = ({List<ProgrammerTask> running, List<ProgrammerTask> paused, + List<ProgrammerTask> notStarted, List<ProgrammerTask> completed, List<ProgrammerTask> cancelled}); + TaskSectionGroups groupTasksForDashboard(List<ProgrammerTask> tasks, + Map<String, ProgrammerRunInfo> runStates, String userId); // only tasks assigned to userId + class TaskSections extends StatelessWidget { const TaskSections({super.key, required this.groups, required this.profileById, required this.readOnly}); } + class NowWorkingCard extends ConsumerWidget { const NowWorkingCard({super.key, required this.running, required this.nextUp, required this.todayOnTask, required this.totalOnTask, required this.readOnly}); } + ``` + +- [ ] **Step 1: Write the failing tests.** + ```dart + test('groupTasksForDashboard splits by run state and excludes other assignees', ...); + test('completed sorted by completedAt desc, cancelled by cancelledAt desc', ...); + testWidgets('Completed and Cancelled start collapsed', (t) async { + // 12 completed: header 'Completed' visible, first completed title not visible + // tap header -> 10 titles visible + text 'Show all (12)' + }); + testWidgets('empty sections are hidden', ...); // no 'Cancelled' header when none + ``` +- [ ] **Step 2: Run to verify failure.** `$FLUTTER test test/programmer_dashboard_sections_test.dart` — FAIL. +- [ ] **Step 3: Implement.** Section headers `AppSectionHeader('<Name>', trailing: Text('<count>'))` with names `In progress`, `Paused`, `Not started`, `Completed`, `Cancelled`. When all groups are empty show the existing empty view (`No tasks assigned to you`). Hero: running task → title, `TechChip` category, `Today <h m> · Total <h m>` (`formatDurationHm`; today = `dailySecondsByAssignee(...)[userId][today]`, total = sum over all days for `userId`), buttons **Pause** (`pauseTask(taskId:)`) and **Complete** (`completeProgrammerTaskWithConfirm`). Nothing running → text `Nothing running` and, if `nextUp != null`, **Start** via `startProgrammerTaskWithPausePrompt`. `readOnly` hides all buttons. Live values re-render on `clockTickerProvider`. +- [ ] **Step 4: Verify.** `$FLUTTER test test/programmer_dashboard_sections_test.dart` — PASS; `$FLUTTER analyze lib` — clean. +- [ ] **Step 5: Commit** — `feat(programmer): Mine dashboard hero + task sections`. + +--- + +### Task 7: Staff presence helpers (leave / pass slip) + +**Files:** +- Create: `lib/utils/staff_presence.dart` +- Modify: `lib/screens/dashboard/dashboard_screen.dart:371-386` (replace the two inline indexing loops with calls) +- Test: `test/staff_presence_test.dart` + +**Interfaces:** +- Produces: + ```dart + Map<String, LeaveOfAbsence> activeLeaveByUser(List<LeaveOfAbsence> leaves, DateTime now); // status approved && start <= now < end + Map<String, PassSlip> activePassSlipByUser(List<PassSlip> slips); // slip.isActive + Set<DateTime> approvedLeaveDays(List<LeaveOfAbsence> leaves, String userId); // Manila day keys covered + ``` +- [ ] **Step 1: Failing tests** — approved leave spanning `now` included, pending excluded; active pass slip included; `approvedLeaveDays` for a leave Sep 24 08:00 → Sep 25 17:00 == `{utc(2026,9,24), utc(2026,9,25)}`. +- [ ] **Step 2:** `$FLUTTER test test/staff_presence_test.dart` — FAIL. +- [ ] **Step 3: Implement** and swap the dashboard loops (behavior unchanged). +- [ ] **Step 4:** `$FLUTTER test test/staff_presence_test.dart test/dashboard_metrics_provider_test.dart` — PASS (ignore the known time-of-day cases); `$FLUTTER analyze lib` — clean. +- [ ] **Step 5: Commit** — `refactor: extract staff presence helpers`. + +--- + +### Task 8: Stat tiles and badges strip + +**Files:** +- Create: `lib/screens/programmer_tasks/dashboard/stat_tiles.dart`, `dashboard/badges_strip.dart` +- Modify: `dashboard/programmer_dashboard.dart` (insert tiles + badges between hero and sections) +- Test: `test/programmer_stat_tiles_test.dart` + +**Interfaces:** +- Consumes: Task 4 (`computeProgrammerStats`, `computeBadges`), Task 7 (`approvedLeaveDays`), `leavesProvider`, `profilesProvider`. +- Produces: + ```dart + class StatTiles extends StatelessWidget { const StatTiles({super.key, required this.stats, required this.showSheetStats}); } + class BadgesStrip extends StatelessWidget { const BadgesStrip({super.key, required this.badges}); } + ``` +- [ ] **Step 1: Failing tests.** + ```dart + testWidgets('sheet stats hidden when showSheetStats is false', ...); // find.text('STREAK') findsNothing + testWidgets('streak tile shows days and best', ...); // '6 days', 'Best 9' + testWidgets('locked badge shows progress', ...); // '7/10' + ``` +- [ ] **Step 2:** `$FLUTTER test test/programmer_stat_tiles_test.dart` — FAIL. +- [ ] **Step 3: Implement.** Tiles (`AppMetricCard`, `Wrap`), exact copy: + - `TODAY'S FOCUS` — value `formatDurationHm(todayFocus)`, trailing: a 40 px `CircularProgressIndicator(value: min(1, focus / 8h))` with `/ 8h` label (pattern `reports/widgets/conversion_rate_card.dart:43-55`). + - `COMPLETED THIS WEEK` — value count; trailing `↑N vs last week` / `↓N vs last week` / `Same as last week`. + - `STREAK` — icon `Icons.local_fire_department_outlined`, value `N days` (`1 day`), trailing `Best M`. + - `FIRST-PASS APPROVAL` — value `NN%` or `—`, trailing `Last 30 days`. + - `HELP GIVEN` — value `formatDurationHm`, trailing `This month`. + - `showSheetStats` = the dashboard user's role is `programmer`; when false only the first two tiles render and `computeBadges(includeSheetBadges: false)`. + - Badges: horizontal list of chips; earned = `AppStatusColors` success container; locked = neutral + `current/target`; tap → `AlertDialog` with title + description. + - Dashboard feeds `sheets: const <ProgrammerDaySheet>[]` until Task 20 wires `daySheetsForUserProvider`; `todayFocus` = `totalSeconds(buildDayRows(userId, today, …))`. +- [ ] **Step 4:** `$FLUTTER test test/programmer_stat_tiles_test.dart` — PASS; `$FLUTTER analyze lib` — clean. +- [ ] **Step 5: Commit** — `feat(programmer): dashboard stat tiles + badges`. + +**Slice 1 checkpoint:** `$FLUTTER test` (only known failures) and `$FLUTTER build web` succeed. + +--- + +# Part B — Slice 2: Admin Monitor + +### Task 9: Monitor view-model (pure) + +**Files:** +- Create: `lib/screens/programmer_tasks/monitor/monitor_model.dart` +- Test: `test/programmer_monitor_model_test.dart` + +**Interfaces:** +- Consumes: Tasks 2, 3, 7. +- Produces: + ```dart + enum MonitorState { working, paused, idle, onLeave, passSlip } + class HelpEdge { final String helperId, assigneeId, taskId, taskTitle; final int minutes; final DateTime at; } + List<HelpEdge> buildHelpEdges({required List<ProgrammerTaskWorkLog> workLogs, + required Map<String, ProgrammerTask> taskById, required DateTime day}); // author != assignee, minutes > 0, workDate == day; newest first + class ProgrammerMonitorEntry { final Profile programmer; final MonitorState state; + final ProgrammerTask? currentTask; final Duration currentTaskToday, todayTotal; + final int inProgress, paused, notStarted; final String? sheetStatus; + final List<HelpEdge> helped, helpedBy; } + List<ProgrammerMonitorEntry> buildMonitorEntries({required List<Profile> programmers, + required DayActivityData today, required Map<String, ProgrammerRunInfo> runStates, + required Map<String, LeaveOfAbsence> leaves, required Map<String, PassSlip> passSlips, + required Map<String, String> sheetStatusByUser, required Map<String, String> projectNames, + required Map<String, String> profileNames, required DateTime now}); + ``` +- [ ] **Step 1: Failing tests** — leave overrides a running task (`onLeave`); pass slip → `passSlip`; running → `working` with that `currentTask`; only paused → `paused` with the most recent `since`; none → `idle`; sort order working, paused, idle, passSlip, onLeave, then name; `helped`/`helpedBy` edges; `todayTotal` equals `totalSeconds(buildDayRows(...))`. +- [ ] **Step 2:** `$FLUTTER test test/programmer_monitor_model_test.dart` — FAIL. +- [ ] **Step 3: Implement** (counts: `inProgress` = running tasks, `paused`, `notStarted` = queued, all for tasks assigned to that programmer). +- [ ] **Step 4:** same command — PASS. +- [ ] **Step 5: Commit** — `feat(programmer): monitor view-model`. + +--- + +### Task 10: Strict admin, role tabs, Monitor tab, drill-down + +**Files:** +- Modify: `lib/providers/profile_provider.dart` (add provider), `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` (role tabs + `initialTab`), `lib/routing/app_router.dart` (query `tab`, route `programmers/:userId` placed before `:id`) +- Create: `lib/screens/programmer_tasks/monitor/monitor_tab.dart`, `monitor/programmer_monitor_card.dart`, `monitor/help_panel.dart`, `lib/screens/programmer_tasks/programmer_drill_down_screen.dart`, `lib/screens/programmer_tasks/programmer_tasks_tabs.dart` +- Test: `test/programmer_tabs_test.dart` + +**Interfaces:** +- Consumes: Tasks 6, 7, 9. +- Produces: + ```dart + final isStrictAdminProvider = Provider<bool>(...); // currentProfileProvider role == 'admin' + enum ProgrammerTasksTab { monitor, approvals, mine, all, daySheets } + List<ProgrammerTasksTab> programmerTasksTabsFor({required bool isStrictAdmin}); + ProgrammerTasksTab? parseProgrammerTasksTab(String? q); // 'monitor'|'approvals'|'mine'|'all'|'day-sheets' + String programmerTasksTabLabel(ProgrammerTasksTab t); // 'Monitor'|'Approvals'|'Mine'|'All'|'Day Sheets' + class ProgrammerTasksListScreen { const ProgrammerTasksListScreen({super.key, this.initialTab}); final String? initialTab; } + class ProgrammerDrillDownScreen extends ConsumerWidget { const ProgrammerDrillDownScreen({super.key, required this.userId}); } // route /programmer-tasks/programmers/:userId + class ProgrammerMonitorCard extends StatelessWidget { const ProgrammerMonitorCard({super.key, required this.entry, required this.onTap}); } + ``` +- [ ] **Step 1: Failing tests.** + ```dart + test('tabs in this slice', () { + expect(programmerTasksTabsFor(isStrictAdmin: true), [ProgrammerTasksTab.monitor, ProgrammerTasksTab.mine, ProgrammerTasksTab.all]); + expect(programmerTasksTabsFor(isStrictAdmin: false), [ProgrammerTasksTab.mine, ProgrammerTasksTab.all]); + }); + test('parse tab query', () { expect(parseProgrammerTasksTab('day-sheets'), ProgrammerTasksTab.daySheets); expect(parseProgrammerTasksTab('x'), isNull); }); + testWidgets('monitor card pill labels', ...); // onLeave -> 'On leave', passSlip -> 'Pass slip', working -> 'Working' + ``` +- [ ] **Step 2:** `$FLUTTER test test/programmer_tabs_test.dart` — FAIL. +- [ ] **Step 3: Implement.** + - Pills: working → success `Working`; paused → warning `Paused`; idle → neutral `Idle`; onLeave → info `On leave`; passSlip → info `Pass slip`. + - Monitor tab: `AppStatusSummaryRow` with `Working`, `Paused`, `Idle`, `Away` (leave + pass slip) counts; grid columns by width (< 700 → 1, < 1100 → 2, else 3). Card body per spec §7 (current task + `running <h m> today`, `Today <h m>`, counts, help chips `Helped → <name> · <task> · <m>m` and `Helped by ← <name> · <m>m`). Tap → `context.go('/programmer-tasks/programmers/<id>')`. + - Help panel: `AppSectionHeader("Who's helping whom")`, day stepper (← date →, cannot pass today), rows `<helper> → <assignee> · <task> · <m>m · <time>`, from `programmerDayDataProvider(day)`; empty → `No help logged this day.` + - Drill-down: AppBar title = programmer name; body `ProgrammerDashboard(userId, readOnly: true)`. Non-admins see `AppEmptyView(icon: Icons.lock_outline, title: 'Admins only')`. + - The list screen builds its `TabController` from `programmerTasksTabsFor`, selects `parseProgrammerTasksTab(initialTab)` if present in the list; router passes `state.uri.queryParameters['tab']`. +- [ ] **Step 4:** `$FLUTTER test test/programmer_tabs_test.dart` — PASS; `$FLUTTER analyze lib` — clean. +- [ ] **Step 5: Commit** — `feat(programmer): admin monitor tab + drill-down`. + +**Slice 2 checkpoint:** full `$FLUTTER test` + `$FLUTTER build web`; QA harness as admin shows the Monitor with Working/Paused/Idle. + +--- + +# Part C — Slice 3: Day Sheets + +### Task 11: Migration — schema, triggers, RLS + +**Files:** +- Create: `supabase/migrations/20260927150000_add_programmer_day_sheets.sql` (the spec's single migration is split into this file + Task 12's file for reviewability) + +**Interfaces:** +- Produces (DB): `manila_today() returns date`; tables `programmer_day_sheets`, `programmer_day_sheet_events` exactly as spec §5.2 (checks included; events body non-empty for `disapproved`/`justified`); `programmer_task_work_logs.work_date`; `notifications.day_sheet_id`; `ensure_day_sheet(p_programmer uuid, p_date date) returns uuid`; error code `work_date_locked`. + +- [ ] **Step 1: Write the SQL.** Every function `set search_path = public`. Pinned behaviors: + 1. `manila_today()` = `(now() at time zone 'Asia/Manila')::date`, `stable`. + 2. Tables + `unique(programmer_id, work_date)`, index `(status, work_date)`; `updated_at` trigger reusing `set_programmer_tasks_updated_at()`. + 3. `work_date`: add nullable → backfill `(created_at at time zone 'Asia/Manila')::date` → `set default manila_today()` → `set not null`; index `(author_id, work_date)`. + 4. `ensure_day_sheet` (SECURITY DEFINER; `revoke execute … from public, anon, authenticated`): returns null unless the profile's role is `programmer`; inserts `draft` on conflict do nothing; if the row is `approved` and `p_date = manila_today()` → set `pending` and insert event `kind='amended'`, `actor_id null`, `body 'New work was logged after approval.'`. + 5. BEFORE INSERT on work logs (SECURITY DEFINER): `work_date := coalesce(work_date, manila_today())`; future date → `raise exception 'work_date_locked'`; past date allowed only when the author's sheet for that date has status `pending` or `disapproved`, else `raise exception 'work_date_locked'`. + 6. AFTER INSERT on work logs → `ensure_day_sheet(new.author_id, new.work_date)`. + 7. AFTER INSERT on `programmer_task_activity_logs` when `action_type in ('started','paused','resumed','completed','cancelled','adjustment')` → `ensure_day_sheet(<task's assignee_id>, (new.created_at at time zone 'Asia/Manila')::date)` if assignee not null. + 8. RLS on both tables. Sheets SELECT: `programmer_id = auth.uid() or exists (select 1 from profiles p where p.id = auth.uid() and p.role = 'admin')`. Events SELECT: `exists (select 1 from programmer_day_sheets s where s.id = sheet_id)`. No INSERT/UPDATE/DELETE policies. + 9. `alter table notifications add column if not exists day_sheet_id uuid references programmer_day_sheets(id) on delete cascade` + index. + 10. `alter publication supabase_realtime add table programmer_day_sheets, programmer_day_sheet_events;` +- [ ] **Step 2: Self-check.** Re-read against spec §5.2–5.4; confirm every trigger function that writes sheets is SECURITY DEFINER. +- [ ] **Step 3: Commit** — `feat(db): programmer day sheets schema, triggers, RLS`. (Applied together with Task 12.) + +--- + +### Task 12: Migration — RPCs, auto-submit cron, digest queue; apply + SQL checklist + +**Files:** +- Create: `supabase/migrations/20260927150100_programmer_day_sheet_rpcs.sql` + +**Interfaces:** +- Consumes: Task 11. +- Produces (all SECURITY DEFINER, `set search_path = public`, `grant execute … to authenticated` except `auto_submit_day_sheets`): + ```sql + submit_day_sheet(p_date date) returns jsonb + disapprove_day_sheet(p_sheet uuid, p_remarks text, p_flagged_task_ids uuid[] default '{}') returns jsonb + justify_day_sheet(p_sheet uuid, p_body text) returns jsonb + approve_day_sheets(p_items jsonb) returns jsonb -- [{sheet_id, snapshot}] + auto_submit_day_sheets() returns void -- revoked from public, anon, authenticated + ``` + Single-sheet RPCs return `{sheet_id, work_date, programmer_id, programmer_name, notify_user_ids}`; approve returns `{approved: [{sheet_id, programmer_id, work_date}], skipped_ids: [...]}`. + Error codes (`raise exception '<code>'`): `not_authorized`, `already_reviewed`, `already_submitted`, `remarks_required`, `body_required`, `invalid_snapshot`, `future_date`. + +- [ ] **Step 1: Write the SQL.** Pinned behaviors (lock the sheet row `for update` before status checks): + - **submit:** caller role `programmer`; `p_date > manila_today()` → `future_date`; upsert draft row for (caller, p_date); status ≠ `draft` → `already_submitted`; set `pending`, `submitted_at = now()`, `auto_submitted = false`; event `submitted`; notify **every** `role = 'admin'` profile with `type 'day_sheet_submitted'`, `actor_id = caller`, `day_sheet_id`. + - **disapprove:** caller admin else `not_authorized`; `btrim(p_remarks) = ''` → `remarks_required`; status ≠ `pending` → `already_reviewed`; set `disapproved`, `reviewed_by`, `reviewed_at`; event `disapproved` with body + flags; notify the programmer `day_sheet_disapproved`. + - **justify:** caller = owner else `not_authorized`; blank body → `body_required`; status ≠ `disapproved` → `already_reviewed`; set `pending`, `resubmissions + 1`, `submitted_at = now()`; event `justified`; notify `reviewed_by` (if not null) `day_sheet_justified`. + - **approve:** caller admin; each item's `snapshot` must be an object with a `rows` array else `invalid_snapshot`; status in (`pending`,`disapproved`) → set `approved`, `reviewed_by`, `reviewed_at`, `approved_snapshot`, event `approved`, notify programmer `day_sheet_approved`, append to `approved`; otherwise append to `skipped_ids` (no error). + - **auto_submit:** (1) for each `in_progress` task whose latest of started/paused/resumed is not `paused`, with a `programmer` assignee: `ensure_day_sheet(assignee, manila_today() - 1)`; if that sheet is `approved` → `pending` + event `amended` body `'Timer was still running at the end of the day.'`. (2) every `draft` with `work_date < manila_today()` → `pending`, `submitted_at = now()`, `auto_submitted = true`, event `auto_submitted`. (3) if any sheet is `pending`: for each admin insert `notifications(user_id, actor_id null, type 'day_sheet_digest')` and `scheduled_notifications(user_id, notify_type 'day_sheet_digest', scheduled_for ((manila_today() + time '08:00') at time zone 'Asia/Manila'), epoch to_char(manila_today(), 'YYYYMMDD')::int) on conflict do nothing`. + - Replace `chk_at_least_one_ref` on `scheduled_notifications`, keeping its current five references and adding `or notify_type = 'day_sheet_digest'`. + - Cron in a `DO $$ … EXCEPTION WHEN others THEN RAISE NOTICE … $$` block: unschedule `programmer_day_sheet_autosubmit` (ignore errors), then `cron.schedule('programmer_day_sheet_autosubmit', '5 16 * * *', 'SELECT public.auto_submit_day_sheets();')` (16:05 UTC = 00:05 Manila). +- [ ] **Step 2: Commit** — `feat(db): day sheet RPCs, auto-submit cron, digest queue`. +- [ ] **Step 3: Ask the user to apply** `20260927150000_…sql` then `20260927150100_…sql` (dashboard SQL editor or `supabase db push`). Wait for confirmation. +- [ ] **Step 4: Run the SQL checklist** with `mcp__supabase__execute_sql`, each item in `begin; … rollback;`, impersonating via `set local role authenticated; select set_config('request.jwt.claims', json_build_object('sub','<uuid>','role','authenticated')::text, true);`. Programmer = `pao@mail.com`'s profile id; admin = any `role = 'admin'` id. Setup rows for past dates are inserted as `postgres` before switching role. + + | # | Action | Expected | + |---|---|---| + | 1 | programmer `submit_day_sheet(manila_today())` | returns jsonb; sheet `pending` | + | 2 | repeat 1 | error `already_submitted` | + | 3 | programmer `disapprove_day_sheet(id,'x')` | error `not_authorized` | + | 4 | admin disapprove with `' '`, then `'Missing notes'` | `remarks_required`; then `disapproved` + a `day_sheet_disapproved` notification for the programmer | + | 5 | admin disapprove again | `already_reviewed` | + | 6 | programmer inserts a work log with `work_date = manila_today()-1` where no sheet exists; then on a `disapproved` yesterday sheet | `work_date_locked`; then succeeds | + | 7 | programmer justify `''`, then `'Added notes'` | `body_required`; then `pending`, `resubmissions = 1` | + | 8 | admin `approve_day_sheets` with a valid snapshot, then again | id in `approved`; then id in `skipped_ids` | + | 9 | today's sheet approved; programmer inserts a work log today | sheet `pending` + `amended` event | + | 10 | postgres: draft sheet dated yesterday; `select auto_submit_day_sheets()` twice | `pending`, `auto_submitted = true`; one `day_sheet_digest` scheduled row per admin (no duplicate) | + | 11 | a second programmer selects `programmer_day_sheets` | only own rows | + | 12 | `mcp__supabase__get_advisors(type: 'security')` | no new ERROR-level lint on the new tables/functions | + +--- + +### Task 13: Edge function — digest push + +**Files:** +- Modify: `supabase/functions/process_scheduled_notifications/index.ts` (new `case 'day_sheet_digest'` in the `notify_type` switch) + +- [ ] **Step 1: Implement.** Count `programmer_day_sheets` with `status = 'pending'` (`select('id', { count: 'exact', head: true })`). If 0 → mark the row processed and skip sending. Else `title = 'Day sheets awaiting review'`, `body = \`${n} day sheet${n === 1 ? ' is' : 's are'} awaiting your review.\``, `data.navigate_to = '/programmer-tasks?tab=approvals'`. +- [ ] **Step 2: Commit** — `feat(edge): day sheet digest push`. +- [ ] **Step 3: Ask the user to redeploy** `process_scheduled_notifications`. Verify: insert (as postgres, not rolled back, then delete) a `scheduled_notifications` row for an admin with `notify_type 'day_sheet_digest'`, `scheduled_for now()`, `epoch 1`; within ~1 minute `processed = true` and `last_error is null`. + +--- + +### Task 14: Day-sheet event model, providers, controller + +**Files:** +- Create: `lib/models/programmer_day_sheet_event.model.dart`, `lib/providers/programmer_day_sheets_provider.dart` +- Test: `test/programmer_day_sheets_controller_test.dart` + +**Interfaces:** +- Consumes: Task 4 (`ProgrammerDaySheet`, `DaySheetSnapshot`), Task 1 (`formatDayKey`, `parseDayKey`), `NotificationsController.sendPush`. +- Produces: + ```dart + class ProgrammerDaySheetEvent { final String id, sheetId, kind; final String? actorId, body; + final List<String> flaggedTaskIds; final DateTime createdAt; factory ProgrammerDaySheetEvent.fromMap(Map<String, dynamic> m); } + enum DaySheetError { alreadyReviewed, alreadySubmitted, notAuthorized, remarksRequired, bodyRequired, workDateLocked, invalidSnapshot, unknown } + class DaySheetActionException implements Exception { final DaySheetError code; String get message; } + DaySheetError mapDaySheetError(Object error); // substring match on the codes from Tasks 11–12 + typedef PushFn = Future<void> Function({required List<String> userIds, required String title, required String body, Map<String, dynamic>? data}); + class BulkApproveResult { final List<String> approvedIds, skippedIds; } + class DaySheetsController { + DaySheetsController(dynamic client, {required PushFn push}); + Future<String> submit(DateTime dayKey); + Future<void> disapprove({required String sheetId, required String remarks, required List<String> flaggedTaskIds}); + Future<void> justify({required String sheetId, required String body}); + Future<BulkApproveResult> approve(List<({String sheetId, DaySheetSnapshot snapshot})> items); + } + final daySheetsControllerProvider = Provider<DaySheetsController>(...); + final myDaySheetsProvider = StreamProvider<List<ProgrammerDaySheet>>(...); // programmer_id = me, work_date desc + final daySheetsForUserProvider = StreamProvider.family<List<ProgrammerDaySheet>, String>(...); + final allDaySheetsProvider = StreamProvider<List<ProgrammerDaySheet>>(...); // RLS-scoped, work_date desc + final daySheetByIdProvider = StreamProvider.family<ProgrammerDaySheet?, String>(...); + final daySheetEventsProvider = StreamProvider.family<List<ProgrammerDaySheetEvent>, String>(...); // created_at asc + ``` + Error messages (exact): alreadyReviewed `This sheet was already reviewed.` · alreadySubmitted `This day sheet was already submitted.` · notAuthorized `You are not allowed to do that.` · remarksRequired `Remarks are required to disapprove.` · bodyRequired `Please write a justification.` · workDateLocked `That day is locked — you can only add entries to pending or disapproved days.` · invalidSnapshot `Could not approve: the sheet data was invalid.` · unknown `Something went wrong. Please try again.` + Push copy (dates via `AppTime.formatDate`, e.g. `Sep 28, 2026`; `data` always includes `type` and `navigate_to: '/programmer-tasks/day-sheets/<id>'`): + - submitted → `Day sheet submitted` / `<name> submitted their day sheet for <date>` + - disapproved → `Day sheet disapproved` / `Your day sheet for <date> needs changes: <remarks, max 80 chars + '…'>` + - justified → `Day sheet resubmitted` / `<name> justified their day sheet for <date>` + - approved → `Day sheet approved` / `Your day sheet for <date> was approved` + +- [ ] **Step 1: Failing tests** (local fake client with `rpc(String name, {Map<String, dynamic>? params})`; a recording `PushFn`): + ```dart + test('submit calls submit_day_sheet with p_date and pushes admins', () async { + // rpc returns {'sheet_id':'s1','work_date':'2026-09-28','programmer_name':'Paola Ross','notify_user_ids':['adm']} + expect(fake.calls.single, ('submit_day_sheet', {'p_date': '2026-09-28'})); + expect(push.calls.single.title, 'Day sheet submitted'); + expect(push.calls.single.body, 'Paola Ross submitted their day sheet for Sep 28, 2026'); + }); + test('blank remarks fail fast without an rpc call', ...); // throws DaySheetActionException(remarksRequired); fake.calls isEmpty + test('already_reviewed maps to a friendly message', ...); // message 'This sheet was already reviewed.' + test('approve reports approved and skipped ids and pushes per approved sheet', ...); + test('a failing push does not fail the action', ...); + ``` +- [ ] **Step 2:** `$FLUTTER test test/programmer_day_sheets_controller_test.dart` — FAIL. +- [ ] **Step 3: Implement.** Controller trims inputs and validates remarks/body before calling the RPC; wraps RPC errors with `mapDaySheetError`; push errors are caught and `debugPrint`ed. Provider wires `push` to `ref.read(notificationsControllerProvider).sendPush`. +- [ ] **Step 4:** same command — PASS. +- [ ] **Step 5: Commit** — `feat(programmer): day sheet providers + controller`. + +--- + +### Task 15: Dated work logs (corrections); day data by `work_date` + +**Files:** +- Modify: `lib/providers/programmer_task_work_logs_provider.dart`, `lib/screens/programmer_tasks/widgets/work_log_section.dart`, `lib/providers/programmer_day_data_provider.dart` +- Test: `test/programmer_tasks_controller_test.dart` (extend the `addWorkLog` group), `test/programmer_day_data_test.dart` (update fake filtering) + +**Interfaces:** +- Consumes: Task 14 (`DaySheetActionException`, `mapDaySheetError`). +- Produces: + ```dart + Future<void> addWorkLog({required String taskId, required String description, int? minutes, DateTime? workDate}); + // sends 'work_date': formatDayKey(workDate) only when workDate != null; 'work_date_locked' -> DaySheetActionException(workDateLocked) + const WorkLogSection({super.key, required this.task, required this.isAssignee, this.workDate}); + ``` +- [ ] **Step 1: Failing tests** — `addWorkLog(workDate: utc(2026,9,25))` inserts `work_date: '2026-09-25'`; without `workDate` the key is absent; `fetchDayActivityData` now selects work logs with `eq('work_date', '2026-09-27')`. +- [ ] **Step 2:** `$FLUTTER test test/programmer_tasks_controller_test.dart test/programmer_day_data_test.dart` — FAIL. +- [ ] **Step 3: Implement.** `WorkLogSection` with `workDate != null`: header `AppSectionHeader('Correction for <date>')`, and skip the pause / deduct prompts after saving. +- [ ] **Step 4:** same command — PASS. +- [ ] **Step 5: Commit** — `feat(programmer): dated work-log corrections`. + +--- + +### Task 16: `ReportDateFilter.controlled` + +**Files:** +- Modify: `lib/screens/reports/report_date_filter.dart` +- Test: `test/report_date_filter_test.dart` + +**Interfaces:** +- Produces: + ```dart + const ReportDateFilter({super.key}); // unchanged: binds reportDateRangeProvider + const ReportDateFilter.controlled({super.key, required ReportDateRange value, required ValueChanged<ReportDateRange> onChanged}); + ReportDateRange thisMonthRange(DateTime now); // start = DateTime(y,m,1), end = DateTime(y,m+1,1), label 'This Month' + ``` +- [ ] **Step 1: Failing test** — pump `ReportDateFilter.controlled(value: thisMonthRange(now), onChanged: captured.add)`; tap `Change`, tap preset `Yesterday` → `captured.single.label == 'Yesterday'` and `container.read(reportDateRangeProvider)` still has label `Last 30 Days`. +- [ ] **Step 2:** `$FLUTTER test test/report_date_filter_test.dart` — FAIL. +- [ ] **Step 3: Implement.** Both constructors share the build; the dialog's `current`/`onApply` come from `value`/`onChanged` when controlled, otherwise from the provider. +- [ ] **Step 4:** same command — PASS. +- [ ] **Step 5: Commit** — `feat(reports): controlled ReportDateFilter`. + +--- + +### Task 17: Day-sheet detail screen, dialogs, route + +**Files:** +- Create: `lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart`, `day_sheets/day_sheet_dialogs.dart`, `day_sheets/day_sheet_rows_table.dart`, `day_sheets/day_sheet_thread.dart` +- Modify: `lib/routing/app_router.dart` (route `day-sheets/:id` under `/programmer-tasks`, before `:id`) +- Test: `test/day_sheet_dialogs_test.dart` + +**Interfaces:** +- Consumes: Tasks 2, 3, 14, 15. +- Produces: + ```dart + class DaySheetDetailScreen extends ConsumerStatefulWidget { const DaySheetDetailScreen({super.key, required this.sheetId}); } + Future<String?> showDisapproveDialog(BuildContext context, {required List<DaySheetRow> flaggedRows}); + Future<String?> showJustifyDialog(BuildContext context); + class DaySheetRowsTable extends StatelessWidget { const DaySheetRowsTable({super.key, required this.rows, + required this.flaggedTaskIds, this.selectedTaskIds, this.onSelectionChanged, this.onAddCorrection}); } + class DaySheetThread extends StatelessWidget { const DaySheetThread({super.key, required this.events, required this.profileNames}); } + List<DaySheetRow> rowsForSheet(ProgrammerDaySheet sheet, List<DaySheetRow> liveRows); // approved -> snapshot rows, else liveRows + ``` +- [ ] **Step 1: Failing tests.** + ```dart + testWidgets('disapprove dialog requires remarks', (t) async { + // tap 'Disapprove' with empty field -> 'Remarks are required' visible, dialog stays + // enter 'Missing notes' -> dialog returns 'Missing notes' + }); + testWidgets('justify dialog requires a justification', ...); // 'Please write a justification' + test('rowsForSheet uses the snapshot once approved', ...); + ``` +- [ ] **Step 2:** `$FLUTTER test test/day_sheet_dialogs_test.dart` — FAIL. +- [ ] **Step 3: Implement** per spec §6.3: + - Header: date, programmer, `StatusPill.kind` (draft→neutral, pending→info, disapproved→warning, approved→success), total, `Round N` (N = resubmissions + 1) when `resubmissions > 0`. + - Flags = `flaggedTaskIds` of the latest `disapproved` event; shown while status ≠ approved. + - Thread labels: submitted `Submitted` · auto_submitted `Auto-submitted at end of day` · disapproved `Disapproved` · justified `Justified` · approved `Approved` · amended `Reopened — new work after approval`. + - Owner when status is `disapproved` (or `pending` for a past day): each row shows an **Add correction** icon → dialog with `WorkLogSection(task, isAssignee: task.assigneeId == me, workDate: sheet.workDate)`. Owner when `disapproved`: **Justify & resubmit** → `showJustifyDialog` → `controller.justify`. + - Admin when `pending`/`disapproved`: row checkboxes → **Disapprove** → `showDisapproveDialog(flaggedRows)` → `controller.disapprove`; **Approve** → if the sheet day is today and `programmerRunStatesProvider` has a running task assigned to that programmer, confirm `<name> has a timer running. Work logged after approval will reopen this sheet. Approve anyway?` → `controller.approve([(sheetId: sheet.id, snapshot: DaySheetSnapshot.fromRows(rows))])`. + - `DaySheetActionException.message` → SnackBar; offline banner per Global Constraints. +- [ ] **Step 4:** `$FLUTTER test test/day_sheet_dialogs_test.dart` — PASS; `$FLUTTER analyze lib` — clean. +- [ ] **Step 5: Commit** — `feat(programmer): day sheet detail screen`. + +--- + +### Task 18: Day Sheets tab, Approvals tab, tab wiring + +**Files:** +- Create: `lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart`, `day_sheets/approvals_tab.dart`, `day_sheets/day_sheet_snapshot_builder.dart` +- Modify: `lib/screens/programmer_tasks/programmer_tasks_tabs.dart` (`programmerTasksTabsFor`), `programmer_tasks_list_screen.dart` (tab bodies) +- Test: `test/day_sheet_tabs_test.dart`; update `test/programmer_tabs_test.dart` + +**Interfaces:** +- Consumes: Tasks 14, 16, 17. +- Produces: + ```dart + Future<DaySheetSnapshot> buildSnapshotForSheet(WidgetRef ref, ProgrammerDaySheet sheet); + Map<DateTime, List<ProgrammerDaySheet>> groupSheetsByDate(List<ProgrammerDaySheet> sheets); // keys descending + ({int pending, int awaiting, int approved}) approvalCounts(List<ProgrammerDaySheet> sheets); // awaiting = disapproved + ``` + Tabs now: admin `[monitor, approvals, mine, all]`, programmer `[mine, all, daySheets]`. +- [ ] **Step 1: Failing tests** — updated `programmerTasksTabsFor` expectations; `groupSheetsByDate` order; `approvalCounts`; widget: `ApprovalsTab` with overridden `allDaySheetsProvider` (3 pending) → select two cards → button text `Approve selected (2)`. +- [ ] **Step 2:** `$FLUTTER test test/day_sheet_tabs_test.dart test/programmer_tabs_test.dart` — FAIL. +- [ ] **Step 3: Implement** per spec §6.2 / §6.4: + - Day Sheets tab: **Today** card (live `buildDayRows` for me, total, status chip — missing sheet shows `Draft`), **Submit day** enabled when status is draft/missing and rows are non-empty → SnackBar `Day submitted`; **Needs your attention** (my `disapproved` sheets); history with `AppStatusSummaryRow` filters All / Pending / Disapproved / Approved; tapping → `/programmer-tasks/day-sheets/<id>`. + - Approvals tab: summary chips `Pending`, `Awaiting justification`, `Approved` (Pending selected by default); programmer dropdown (`All programmers` + names); `ReportDateFilter.controlled` (default `thisMonthRange`); cards grouped by date with name, `TechChip(formatDurationHm(total))`, `<n> tasks`, badge `Auto` if `autoSubmitted` else `Early`, `Round N`; checkboxes on pending cards; **Approve selected (n)** → `buildSnapshotForSheet` each → `approve` → SnackBar `Approved <a>` plus `, <s> already reviewed` when skipped > 0. +- [ ] **Step 4:** same command — PASS; `$FLUTTER analyze lib` — clean. +- [ ] **Step 5: Commit** — `feat(programmer): day sheets + approvals tabs`. + +--- + +### Task 19: Notifications for day sheets + +**Files:** +- Modify: `lib/models/notification_item.model.dart` (add `final String? daySheetId;` ← `day_sheet_id`), Brick generated files (regenerated), `lib/screens/notifications/notifications_screen.dart`, `lib/services/notification_bridge.dart` +- Test: `test/notification_titles_test.dart` + +**Interfaces:** +- Produces (moved out of the screen's State so they are testable; existing cases unchanged): + ```dart + String notificationTitle(String type, String actorName); + IconData notificationIcon(String type); + ``` + New cases: `day_sheet_submitted` → `'$actorName submitted a day sheet'` / `Icons.assignment_turned_in_outlined`; `day_sheet_disapproved` → `'Day sheet disapproved'` / `Icons.report_outlined`; `day_sheet_justified` → `'$actorName justified a day sheet'` / `Icons.reply_outlined`; `day_sheet_approved` → `'Day sheet approved'` / `Icons.verified_outlined`; `day_sheet_digest` → `'Day sheets awaiting review'` / `Icons.inbox_outlined`. Banner labels: `Day sheet submitted`, `Day sheet disapproved`, `Day sheet justified`, `Day sheet approved`, `Day sheets awaiting review`. Tap: `daySheetId != null` → `/programmer-tasks/day-sheets/<id>`; `day_sheet_digest` → `/programmer-tasks?tab=approvals`. +- [ ] **Step 1: Failing tests** for the five titles/icons and one existing case (`'assignment'` → `'X assigned you'`) to guard the move. +- [ ] **Step 2:** `$FLUTTER test test/notification_titles_test.dart` — FAIL. +- [ ] **Step 3: Implement**, then regenerate Brick: `$FLUTTER pub run build_runner build --delete-conflicting-outputs`; commit the regenerated adapter, `schema.g.dart`, and new `lib/brick/db/*.migration.dart`. +- [ ] **Step 4:** `$FLUTTER test test/notification_titles_test.dart test/notification_bridge_test.dart` — PASS; `$FLUTTER analyze lib` — clean. +- [ ] **Step 5: Commit** — `feat(notifications): day sheet notification types`. + +--- + +### Task 20: Wire sheet data into dashboard, monitor, drill-down + +**Files:** +- Modify: `dashboard/programmer_dashboard.dart` (`sheets` ← `daySheetsForUserProvider(userId)`), `monitor/monitor_tab.dart` (`sheetStatusByUser` from `allDaySheetsProvider` for today + summary chip `Sheets pending`), `programmer_drill_down_screen.dart` (`AppSectionHeader('Recent day sheets')` + last 10 sheets → detail route) +- Test: `test/programmer_drill_down_test.dart` + +- [ ] **Step 1: Failing test** — drill-down with overridden `daySheetsForUserProvider('u1')` (2 sheets) and admin profile shows `Recent day sheets` and both dates. +- [ ] **Step 2:** `$FLUTTER test test/programmer_drill_down_test.dart` — FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** same command — PASS; `$FLUTTER analyze lib` — clean. +- [ ] **Step 5: Commit** — `feat(programmer): sheet-driven stats, monitor status, drill-down history`. + +**Slice 3 checkpoint:** full `$FLUTTER test`, `$FLUTTER build web`; QA harness: programmer submits early → admin disapproves with a flag → programmer adds a correction + justifies → admin approves; notifications render and route. + +--- + +# Part D — Slice 4: Accomplishment Report + +### Task 21: Report data builder (pure) + +**Files:** +- Create: `lib/screens/programmer_tasks/report/accomplishment_report_data.dart` +- Test: `test/accomplishment_report_data_test.dart` + +**Interfaces:** +- Consumes: Tasks 2, 4. +- Produces: + ```dart + class ReportDay { final DateTime day; final String? statusTag; final List<DaySheetRow> rows; } // statusTag null when approved; 'Pending' | 'Disapproved' + class AccomplishmentReportData { + final String programmerName, positionLabel; // positionLabel: 'Programmer' | 'Administrator' + final DateTime periodStart, periodEndInclusive; // day keys + final List<ReportDay> days; // ascending + final int excludedPending, excludedDisapproved; + final int daysReported, totalSeconds, tasksCompleted, helpSeconds; + final Map<String, int> secondsByCategory; + final String? approverName; } + AccomplishmentReportData buildAccomplishmentReportData({required Profile programmer, + required ReportDateRange range, required bool includeUnapproved, + required List<ProgrammerDaySheet> sheets, required Map<DateTime, List<DaySheetRow>> liveRowsByDay, + required List<ProgrammerTask> tasks, required Map<String, String> profileNames}); + ``` +- [ ] **Step 1: Failing tests.** + ```dart + test('approved-only uses snapshots and counts excluded days', ...); // 2 approved, 1 pending, 1 disapproved -> days 2, excludedPending 1, excludedDisapproved 1 + test('include unapproved tags days and uses live rows', ...); // statusTag 'Pending' / 'Disapproved'; draft counts as 'Pending' + test('period end is inclusive', ...); // range Sep 1 -> Oct 1 (exclusive) -> periodEndInclusive utc(2026,9,30) + test('approver name only when one reviewer approved every included day', ...); + test('tasksCompleted counts tasks completed in range for the programmer', ...); + test('empty period', () { expect(data.days, isEmpty); expect(data.daysReported, 0); }); + ``` +- [ ] **Step 2:** `$FLUTTER test test/accomplishment_report_data_test.dart` — FAIL. +- [ ] **Step 3: Implement.** Only sheets with `workDate` in `[start, end)` and days with ≥ 1 row are included. `helpSeconds` / `secondsByCategory` sum over included rows (helper rows count toward help and their category). +- [ ] **Step 4:** same command — PASS. +- [ ] **Step 5: Commit** — `feat(report): accomplishment report data`. + +--- + +### Task 22: Shared PDF helpers + report PDF + +**Files:** +- Create: `lib/utils/pdf/pdf_letterhead.dart`, `lib/widgets/pdf_preview_dialog.dart`, `lib/screens/programmer_tasks/report/accomplishment_report_pdf.dart` +- Test: `test/accomplishment_report_pdf_test.dart` + +**Interfaces:** +- Consumes: Task 21. +- Produces: + ```dart + class PdfBrand { final pw.Font regular, bold; final pw.ImageProvider logo; + static Future<PdfBrand> load(); // assets/fonts/Roboto-*.ttf + assets/crmc_logo.png + pw.Widget letterhead(); } // same four lines + 60x60 logo as dtr_pdf.dart `_header` + Future<void> showPdfPreviewDialog(BuildContext context, {required String title, + required String filename, required Future<Uint8List> Function() buildBytes}); // modeled on _DtrPdfDialog: pdfrx PdfViewer.data, Print (Printing.layoutPdf), Download (Printing.sharePdf) + Future<Uint8List> buildAccomplishmentReportPdf(AccomplishmentReportData data); + ``` +- [ ] **Step 1: Failing tests** (`TestWidgetsFlutterBinding.ensureInitialized()`): a populated report builds non-empty bytes starting with `%PDF`; an empty report builds too (Review Focus 5). +- [ ] **Step 2:** `$FLUTTER test test/accomplishment_report_pdf_test.dart` — FAIL. +- [ ] **Step 3: Implement** layout per spec §9. Exact copy: title `ACCOMPLISHMENT REPORT`; info labels `Name:`, `Position:`, `Period:` (`MMMM d, yyyy – MMMM d, yyyy`, inclusive end); table headers `DATE`, `TASK / PROJECT`, `ACCOMPLISHMENT`, `TIME`; notes joined with `\n`, `(no notes)` when empty; helper rows `Helped <name> — <task>`; status tag in brackets after the date (`[Pending]`); empty → `No approved accomplishments in this period.`; footnote `<n> day(s) in this period are not included (<p> pending, <d> disapproved).` (only when approved-only and n > 0); summary labels `Days reported`, `Total time`, `Tasks completed`, `Help given`, `Time by category`; signatures `Prepared by:` (programmer name + position) and `Approved by:` (approver name or a blank line). A4 `MultiPage`, margin 32, footer `Page x of y`. +- [ ] **Step 4:** same command — PASS. +- [ ] **Step 5: Commit** — `feat(report): accomplishment report PDF + shared PDF helpers`. + +--- + +### Task 23: Generate dialog + entry points + +**Files:** +- Create: `lib/screens/programmer_tasks/report/accomplishment_report_dialog.dart` +- Modify: `day_sheets/day_sheets_tab.dart` (app-bar action), `day_sheets/approvals_tab.dart` (action), `programmer_drill_down_screen.dart` (button) +- Test: `test/accomplishment_report_dialog_test.dart` + +**Interfaces:** +- Consumes: Tasks 16, 21, 22, `fetchDayActivityData`, `buildDayRows`. +- Produces: + ```dart + Future<void> showAccomplishmentReportDialog(BuildContext context, WidgetRef ref, {String? programmerId}); + Future<AccomplishmentReportData> loadAccomplishmentReport(WidgetRef ref, + {required Profile programmer, required ReportDateRange range, required bool includeUnapproved}); + ``` +- [ ] **Step 1: Failing tests** — as programmer: no programmer picker, title `Accomplishment Report`, toggle `Include unapproved days (marked)` off by default; as admin with no `programmerId`: picker lists only `role == 'programmer'` profiles and **Generate** is disabled until one is chosen. +- [ ] **Step 2:** `$FLUTTER test test/accomplishment_report_dialog_test.dart` — FAIL. +- [ ] **Step 3: Implement.** Loader selects sheets for the programmer with `work_date` in range; for unapproved days (only when the toggle is on) builds live rows via `fetchDayActivityData` + `buildDayRows`; then `showPdfPreviewDialog(title: 'Accomplishment Report – <name>', filename: 'Accomplishment Report - <name>.pdf')`. Entry labels: `Accomplishment Report` (Day Sheets / Approvals app bars), `Generate Accomplishment Report` (drill-down). +- [ ] **Step 4:** same command — PASS; `$FLUTTER analyze lib` — clean. +- [ ] **Step 5: Commit** — `feat(report): accomplishment report dialog + entry points`. + +--- + +### Task 24: Final verification + +- [ ] **Step 1:** `$FLUTTER analyze lib` — no issues. +- [ ] **Step 2:** `$FLUTTER test` — only the known unrelated failures. +- [ ] **Step 3:** `$FLUTTER build web` — succeeds. +- [ ] **Step 4: QA harness** (`~/.cache/tasq-qa/`, fresh `--session` per rebuild; programmer `pao@mail.com`, then an admin; light and dark): log work → Mine dashboard hero/tiles/sections → submit early → admin Monitor shows Working/Paused/On leave and help edges → admin flags + disapproves → programmer adds a correction + justifies → admin approves (single and bulk) → both generate the PDF (approved-only and marked) → notifications render and route. Save screenshots to `~/.cache/tasq-qa/shots/`. +- [ ] **Step 5:** Report results to the user; fix regressions via superpowers:systematic-debugging before claiming completion.