Files
tasq/docs/superpowers/plans/2026-09-27-programmer-oversight.md
2026-09-27 18:22:05 +08:00

910 lines
70 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <args>` means `flatpak-spawn --host sh -c 'cd /home/redz/tasq && ~/flutter/bin/flutter <args>'`.
- 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 <paths>`). 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 <ruv@ruv.net>`.
- 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<WorkedInterval> workedIntervals({required ProgrammerTask task,
required List<ProgrammerTaskActivityLog> logs, required DateTime now});
/// assigneeId -> dayKey -> seconds (adjustments applied, each bucket >= 0)
Map<String, Map<DateTime, int>> dailySecondsByAssignee({required ProgrammerTask task,
required List<ProgrammerTaskActivityLog> 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<String, dynamic>? 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 <ruv@ruv.net>"
```
---
### 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<String> notes;
Map<String, dynamic> toJson(); // keys: task_id,title,category,project_name,kind,helped_name,seconds,notes
factory DaySheetRow.fromJson(Map<String, dynamic> json);
}
List<DaySheetRow> buildDayRows({required String userId, required DateTime day,
required List<ProgrammerTask> tasks,
required Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
required List<ProgrammerTaskWorkLog> workLogs,
required Map<String, String> projectNames, required Map<String, String> profileNames,
required DateTime now});
int totalSeconds(List<DaySheetRow> 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<String, ProgrammerRunInfo> computeRunStates(List<ProgrammerTask> tasks,
Map<String, List<ProgrammerTaskActivityLog>> logsByTask); // pure
class DayActivityData {
final DateTime day; final List<ProgrammerTask> tasks;
final Map<String, List<ProgrammerTaskActivityLog>> logsByTask;
final List<ProgrammerTaskWorkLog> workLogs;
}
Future<DayActivityData> fetchDayActivityData(dynamic client,
{required DateTime day, required List<ProgrammerTask> tasks, required DateTime now});
final clockTickerProvider = StreamProvider<DateTime>(...); // AppTime.now() immediately, then every 30 s
final todayKeyProvider = Provider<DateTime>(...); // manilaDay(ticker value ?? AppTime.now())
final programmerActivitySignalProvider = StreamProvider<int>(...); // +1 per realtime INSERT on either log table
final programmerDayDataProvider = FutureProvider.family<DayActivityData, DateTime>(...); // key = day key
final programmerRunStatesProvider = Provider<Map<String, ProgrammerRunInfo>>(...); // 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<DaySheetRow> rows;
Map<String, dynamic> toJson(); // {'v':1,'total_seconds':..,'rows':[..]}
factory DaySheetSnapshot.fromJson(Map<String, dynamic> j);
factory DaySheetSnapshot.fromRows(List<DaySheetRow> 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<String, dynamic> 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<ProgrammerTask> tasks, required List<ProgrammerDaySheet> sheets,
required Set<DateTime> leaveDays, required Duration todayFocus});
({int current, int best}) computeStreak({required List<ProgrammerDaySheet> sheets,
required Set<DateTime> leaveDays, required DateTime today});
class BadgeProgress { final String id, title, description; final IconData icon;
final int current, target; final bool earned; }
List<BadgeProgress> 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 = `'<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.