From 29c36bede78de3d07179e4caf103887acd290031 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Fri, 15 May 2026 13:05:48 +0200 Subject: [PATCH] docs: post-attendance flow v2 implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 small TDD tasks (~120 LOC): display rename, attendance 3-button branch, Site Journal save+absences button, docs. Reuses Round C next_action pattern. HARD STOP after Task 4 — local verification by Konrad before any push (UX change to a daily-use path). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-15-post-attendance-flow-v2-plan.md | 686 ++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100644 docs/plans/2026-05-15-post-attendance-flow-v2-plan.md diff --git a/docs/plans/2026-05-15-post-attendance-flow-v2-plan.md b/docs/plans/2026-05-15-post-attendance-flow-v2-plan.md new file mode 100644 index 0000000..c848102 --- /dev/null +++ b/docs/plans/2026-05-15-post-attendance-flow-v2-plan.md @@ -0,0 +1,686 @@ +# Post-Attendance Flow v2 — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (in-session) to implement this plan task-by-task. **HARD STOP after +> Task 4 — do NOT push to origin or deploy. Hand back to Konrad for +> manual local verification first.** + +**Goal:** Replace the forced post-attendance SiteReport redirect with +three explicit buttons (Log Work → dashboard / + Site Journal / + +Absences), add a "Save Site Journal + Add Absences" path on the +journal page, and rename the user-facing "Site Report" → "Site +Journal" (display-only). + +**Architecture:** Pure flow + display change. Reuses the existing +Round C `next_action` POST mechanism (`core/views.py::attendance_log`, +commit `8c749f3`). Display-only rename is the Path-A pattern (UI text +only; `SiteReport` model/view/URL unchanged — exactly like "New +Loan"→"Loan"). No model, migration, URL, or dependency changes. + +**Tech Stack:** Django 5.2.7, Python 3.13, SQLite (local) / MySQL +(prod). Bootstrap 5 templates. + +**Design doc:** `docs/plans/2026-05-15-post-attendance-flow-v2-design.md` +(commit `110545b`). + +**Branch:** `ai-dev`. Baseline: HEAD `110545b`, 173/173 tests passing. + +**Test command (Windows, Git Bash):** +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +On cmd.exe: `set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2` + +**Pre-reading for the implementer:** +- `docs/plans/2026-05-15-post-attendance-flow-v2-design.md` — full design (4 sections, all approved by Konrad) +- `CLAUDE.md` — "UI-vs-DB naming drift" section (the Path-A display-rename pattern this follows) + "Django template comments {# #} SINGLE-LINE only" gotcha + the grep sanity-check one-liner +- `core/views.py::attendance_log` lines ~744-781 — the Round C `next_action` branch being modified +- `core/views.py::site_report_edit` lines ~867-885 — the POST save branch being modified + +**CRITICAL — template comment gotcha:** Any multi-line `{# ... #}` +renders as literal text on the page (silent, no error). After ANY +template edit run: +``` +grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}' +``` +Every match is a broken multi-line comment — fix to `{% comment %}…{% endcomment %}`. + +--- + +## Task 1 — Vocabulary rename: "Site Report" → "Site Journal" (display-only) + +**Goal:** Every user-facing "Site Report" / "Log Today's Work" string +reads "Site Journal". Zero code-identifier changes. + +**Files:** +- Modify: `core/templates/core/site_report_edit.html` (h1, title, save button) +- Modify: `core/templates/core/site_report_detail.html` (heading, title) +- Modify: `core/templates/core/work_history.html` (clipboard tooltip/title text) +- Modify: `core/views.py::site_report_edit` (success-toast string only) +- Modify: `core/tests.py` (one render-assertion test) + +### Step 1 — Write the failing test + +Append to `core/tests.py` (end of file): + +```python +class SiteJournalRenameTests(TestCase): + """Display-only rename: the SiteReport edit/detail pages must show + 'Site Journal' to users. Model/view/URL identifiers are unchanged + (Path-A pattern, like 'New Loan' displaying as 'Loan').""" + + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user( + username='admin', password='pw', is_staff=True, is_superuser=True, + ) + cls.project = Project.objects.create(name='Solar Farm Alpha') + cls.worker = Worker.objects.create( + name='W', id_number='1', monthly_salary=Decimal('6000'), + ) + cls.log = WorkLog.objects.create( + date=_date(2026, 5, 22), project=cls.project, supervisor=cls.admin, + ) + cls.log.workers.add(cls.worker) + + def setUp(self): + self.client.force_login(self.admin) + + def test_edit_page_shows_site_journal_not_site_report(self): + resp = self.client.get(f'/site-report/{self.log.id}/edit/') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Site Journal') + # The old user-facing strings must be gone from the rendered page + self.assertNotContains(resp, "Log Today's Work") + self.assertNotContains(resp, 'Save Site Report') +``` + +### Step 2 — Run it, confirm it fails + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.SiteJournalRenameTests -v 2 +``` +Expected: FAIL — page still says "Log Today's Work" / "Save Site Report". + +### Step 3 — Apply the rename + +In `core/templates/core/site_report_edit.html`: +- `{% block title %}` — any "Site Report"/"Log Today's Work" → "Site Journal" +- `

` "Log Today's Work" → "Site Journal" +- Save button: "Save Site Report" → "Save Site Journal"; if the + template has `{% if is_creating %}Save Site Report{% else %}Update{% endif %}`, + make it `{% if is_creating %}Save Site Journal{% else %}Update Site Journal{% endif %}` + +In `core/templates/core/site_report_detail.html`: +- Heading + `{% block title %}` "Site Report" → "Site Journal" + +In `core/templates/core/work_history.html`: +- The clipboard-icon link `title=` / tooltip text referencing "site + report" → "site journal". Grep first: `grep -n "site report\|Site Report\|site-report" core/templates/core/work_history.html` + (only change visible TEXT — leave `{% url 'site_report_edit' %}` and + `{% url 'site_report_detail' %}` URL tags untouched). + +In `core/views.py::site_report_edit` (around line 883), the toast: +```python +messages.success( + request, + f"Site journal saved for {work_log.project.name} on {work_log.date:%d %b %Y}.", +) +``` + +**Do NOT touch:** `SiteReport`, `site_report_edit`, `site_report_detail`, +`/site-report/…` URLs, `work_log.site_report`, `SiteReportForm`, +`core/site_report_schema.py`, the `{% url %}` tags. + +### Step 4 — Template comment sanity check + +``` +grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}' +``` +Expected: no output (or only pre-existing known-good — there should be none after the prior fixes). + +### Step 5 — Run tests, confirm pass + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.SiteJournalRenameTests -v 2 +``` +Expected: PASS. + +Full suite: +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +Expected: 174 tests, OK (173 + 1 new). **If any EXISTING test fails +because it asserted "Site Report" text, update that test's assertion +to "Site Journal" — that string contract is intentionally changing.** + +### Step 6 — Commit + +```bash +git add core/templates/core/site_report_edit.html core/templates/core/site_report_detail.html core/templates/core/work_history.html core/views.py core/tests.py +git commit -m "rename(ui): 'Site Report' → 'Site Journal' (display-only, Path-A) + +User-facing text only. SiteReport model / site_report_* views / +/site-report/ URLs unchanged — same pattern as New Loan→Loan. +Frees the word 'Journal' for the parked voice-transcript feature +(which will be 'Voice Notes'). 1 render test." +``` + +--- + +## Task 2 — Attendance form: 3-way next_action branch + new button + +**Goal:** Plain "Log Work" → dashboard. New "Log Work + Site Journal" +→ site journal form. "Log Work + Add Absences" → unchanged. The 3 +buttons have clear primary/secondary visual hierarchy. + +**Files:** +- Modify: `core/views.py::attendance_log` (lines ~754-781 — the Round C branch) +- Modify: `core/templates/core/attendance_log.html` (lines ~174-182 — submit buttons) +- Modify: `core/tests.py` (redirect-behaviour tests) + +### Step 1 — Write the failing tests + +Append to `core/tests.py`: + +```python +class PostAttendanceFlowV2Tests(TestCase): + """Flow v2: 'Log Work' → dashboard (was: forced site-report + redirect). 'log_journal' → site journal form. 'log_absences' + unchanged.""" + + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user( + username='admin', password='pw', is_staff=True, is_superuser=True, + ) + cls.project = Project.objects.create(name='Solar Farm Alpha') + cls.team = Team.objects.create(name='Team A', supervisor=cls.admin) + cls.worker = Worker.objects.create( + name='W', id_number='1', monthly_salary=Decimal('6000'), + ) + cls.team.workers.add(cls.worker) + + def setUp(self): + self.client.force_login(self.admin) + + def _post(self, next_action): + return self.client.post('/attendance/log/', data={ + 'date': '2026-05-22', + 'project': self.project.id, + 'team': self.team.id, + 'workers': [self.worker.id], + 'next_action': next_action, + }) + + def test_log_only_redirects_to_dashboard(self): + """REVERSAL of Phase A.1: plain Log Work now lands on the + dashboard, NOT the site-report form.""" + resp = self._post('log_only') + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp.url, '/') + self.assertNotIn('/site-report/', resp.url) + + def test_missing_next_action_defaults_to_dashboard(self): + """No next_action (e.g. old bookmark / form without the field) + → dashboard, same as log_only.""" + resp = self.client.post('/attendance/log/', data={ + 'date': '2026-05-22', + 'project': self.project.id, + 'team': self.team.id, + 'workers': [self.worker.id], + }) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp.url, '/') + + def test_log_journal_redirects_to_site_report_edit(self): + resp = self._post('log_journal') + self.assertEqual(resp.status_code, 302) + self.assertIn('/site-report/', resp.url) + self.assertTrue(resp.url.endswith('/edit/')) + + def test_log_absences_still_redirects_to_absences_prefilled(self): + """Round C path unchanged.""" + resp = self._post('log_absences') + self.assertEqual(resp.status_code, 302) + self.assertIn('/absences/log/', resp.url) + self.assertIn('date=2026-05-22', resp.url) + self.assertIn(f'team={self.team.id}', resp.url) + + def test_log_journal_survives_conflict_resolution(self): + """The conflict re-render carries next_action through the + form.data.items loop (Round C). log_journal must survive it.""" + # Pre-create a conflicting WorkLog for the same worker+date + clash = WorkLog.objects.create( + date=_date(2026, 5, 22), project=self.project, supervisor=self.admin, + ) + clash.workers.add(self.worker) + # First POST → conflict screen (200, not redirect) + resp = self._post('log_journal') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'name="next_action"') + self.assertContains(resp, 'value="log_journal"') + # Resolve via overwrite, carrying next_action + resp2 = self.client.post('/attendance/log/', data={ + 'date': '2026-05-22', + 'project': self.project.id, + 'team': self.team.id, + 'workers': [self.worker.id], + 'next_action': 'log_journal', + 'conflict_action': 'overwrite', + }) + self.assertEqual(resp2.status_code, 302) + self.assertIn('/site-report/', resp2.url) +``` + +NOTE: `conflict_action='overwrite'` is the value the existing +`attendance_log` conflict handler accepts (verified in the Round C +work). If the test fails on an unknown conflict_action, grep +`core/views.py` + `core/templates/core/attendance_log.html` for +`conflict_action` to find the accepted values; the goal is just to +prove the redirect survives the round-trip. + +### Step 2 — Run, confirm failures + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.PostAttendanceFlowV2Tests -v 2 +``` +Expected: `test_log_only_redirects_to_dashboard` and +`test_missing_next_action_defaults_to_dashboard` FAIL (they currently +redirect to /site-report/…); `test_log_journal_*` FAIL (no such +branch yet). + +### Step 3 — Rewrite the next_action branch in `attendance_log` + +In `core/views.py`, replace the block from `next_action = request.POST.get('next_action', 'log_only')` +down to (and including) the final `return redirect('home')` (~lines +754-781) with: + +```python + # === ROUND C + FLOW v2: pick post-submit destination === + # The attendance form has THREE submit buttons, all named + # `next_action`. Whichever the user clicked lands here: + # - 'log_only' (default/missing) → dashboard (just done) + # - 'log_journal' → Site Journal form + # - 'log_absences' → /absences/log/ prefilled + # Flow v2 (15 May 2026) REVERSED the old default: plain + # "Log Work" no longer force-redirects into the journal — + # that's now an explicit opt-in button. See + # docs/plans/2026-05-15-post-attendance-flow-v2-design.md. + next_action = request.POST.get('next_action', 'log_only') + + if next_action == 'log_absences' and created_log_ids: + # Pre-fill the absence form with the same date / team / + # project just used (LAST log's date for date ranges). + from urllib.parse import urlencode + params = {'date': dates_to_log[-1].isoformat()} + if team: + params['team'] = team.id + if project: + params['project'] = project.id + return redirect(f"{reverse('absence_log')}?{urlencode(params)}") + + if next_action == 'log_journal' and created_log_ids: + # Explicit opt-in: go to the Site Journal form for the + # LAST created log (most recent date in a range). + return redirect('site_report_edit', work_log_id=created_log_ids[-1]) + + # Default ('log_only', missing, unrecognised, or no logs + # created because everything conflicted): straight to the + # dashboard. The green "work log(s) created" toast already + # confirms the save. + return redirect('home') +``` + +### Step 4 — Add the 3rd button + hierarchy in the template + +In `core/templates/core/attendance_log.html`, the submit block +(~lines 174-182) currently: +```html +
+ + +
+``` + +Replace with (primary first, two secondary opt-ins, Konrad's order — +Site Journal between Log Work and Absences): +```html +
+ {% comment %} + Flow v2: primary "Log Work" (just save → dashboard) anchors the + eye. The two "+ ..." buttons are secondary opt-in extras. All + three submit the SAME form; the next_action value routes the + redirect in the view. + {% endcomment %} + + + +
+``` +(Note: secondary buttons drop `btn-lg` so they read visually lighter +than the primary — that's the hierarchy. Keep `btn-lg` only on the +primary "Log Work".) + +### Step 5 — Template comment sanity check + +``` +grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}' +``` +Expected: no output. + +### Step 6 — Run tests, confirm pass + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.PostAttendanceFlowV2Tests -v 2 +``` +Expected: 5/5 PASS. + +Full suite: +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +Expected: ~179 OK. **Any existing test that asserted attendance +submit → /site-report/ by default MUST be updated** — that's the +intentional reversal. Find them: `grep -n "site-report\|site_report_edit" core/tests.py` +and fix assertions in non-flow-v2 tests that relied on the old +forced redirect. + +### Step 7 — Commit + +```bash +git add core/views.py core/templates/core/attendance_log.html core/tests.py +git commit -m "feat(flow): attendance 3-button flow — Log Work → dashboard + +Reverses Phase A.1's forced post-attendance SiteReport redirect. +Plain 'Log Work' now lands on the dashboard. New 'Log Work + Site +Journal' button (next_action=log_journal) opens the journal form +explicitly. 'Log Work + Add Absences' unchanged. Primary/secondary +button hierarchy so 3 buttons don't feel like a wall. 5 tests +incl. conflict-path carry-through." +``` + +--- + +## Task 3 — Site Journal page: "Save + Add Absences" button + +**Goal:** The Site Journal form gets a second submit button that +saves the journal then jumps to the prefilled absence form. + +**Files:** +- Modify: `core/views.py::site_report_edit` (POST success branch, ~line 880-885) +- Modify: `core/templates/core/site_report_edit.html` (action row) +- Modify: `core/tests.py` + +### Step 1 — Write the failing tests + +Append to `core/tests.py` (inside or after `PostAttendanceFlowV2Tests` +— a new class is cleaner): + +```python +class SiteJournalSaveAbsencesTests(TestCase): + """The Site Journal form's 'Save + Add Absences' button saves the + report then redirects to /absences/log/ prefilled from the + WorkLog. Default save (no next_action) → dashboard.""" + + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user( + username='admin', password='pw', is_staff=True, is_superuser=True, + ) + cls.project = Project.objects.create(name='Solar Farm Alpha') + cls.team = Team.objects.create(name='Team A', supervisor=cls.admin) + cls.worker = Worker.objects.create( + name='W', id_number='1', monthly_salary=Decimal('6000'), + ) + cls.team.workers.add(cls.worker) + cls.log = WorkLog.objects.create( + date=_date(2026, 5, 22), project=cls.project, + team=cls.team, supervisor=cls.admin, + ) + cls.log.workers.add(cls.worker) + + def setUp(self): + self.client.force_login(self.admin) + + def test_default_save_redirects_home(self): + resp = self.client.post(f'/site-report/{self.log.id}/edit/', data={ + 'weather': 'sunny', + # no next_action + }) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp.url, '/') + + def test_save_absences_redirects_to_prefilled_absence_form(self): + resp = self.client.post(f'/site-report/{self.log.id}/edit/', data={ + 'weather': 'sunny', + 'next_action': 'save_absences', + }) + self.assertEqual(resp.status_code, 302) + self.assertIn('/absences/log/', resp.url) + self.assertIn('date=2026-05-22', resp.url) + self.assertIn(f'team={self.team.id}', resp.url) + self.assertIn(f'project={self.project.id}', resp.url) + # The report was actually saved + self.log.refresh_from_db() + self.assertTrue(hasattr(self.log, 'site_report')) +``` + +### Step 2 — Run, confirm failure + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.SiteJournalSaveAbsencesTests -v 2 +``` +Expected: `test_save_absences_*` FAILS (no such branch — it currently +always redirects home). + +### Step 3 — Add the next_action branch in `site_report_edit` + +In `core/views.py::site_report_edit`, the POST success block currently +(~lines 878-885): +```python + if instance.pk is None: + instance.created_by = request.user + instance.save() + messages.success( + request, + f"Site journal saved for {work_log.project.name} on {work_log.date:%d %b %Y}.", + ) + return redirect('home') +``` + +Replace the `return redirect('home')` with: +```python + # Flow v2: a second submit button ("Save Site Journal + + # Add Absences") carries next_action=save_absences. Mirror + # the attendance form's absence-prefill exactly — same + # query-string shape from the WorkLog we already have + # select_related'd. + if request.POST.get('next_action') == 'save_absences': + from urllib.parse import urlencode + params = {'date': work_log.date.isoformat()} + if work_log.team_id: + params['team'] = work_log.team_id + if work_log.project_id: + params['project'] = work_log.project_id + return redirect(f"{reverse('absence_log')}?{urlencode(params)}") + return redirect('home') +``` + +(`reverse` is already imported in `core/views.py`. Confirm with +`grep -n "from django.urls import" core/views.py`.) + +### Step 4 — Add the button in the template + +In `core/templates/core/site_report_edit.html`, the action row +(~lines 168-176) currently: +```html +
+ + Skip + + +
+``` +(The button text already says "Site Journal" after Task 1.) + +Replace with: +```html +
+ + Skip + +
+ + +
+
+``` +(The primary save button gets an explicit `value="save_only"` so the +two submit buttons are unambiguous; the view treats anything that +isn't `save_absences` as "go home", so `save_only`/missing both work.) + +### Step 5 — Template comment sanity check + run tests + +``` +grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}' +``` +Expected: no output. + +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.SiteJournalSaveAbsencesTests -v 2 +``` +Expected: 2/2 PASS. + +Full suite: +``` +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 +``` +Expected: ~181 OK. + +### Step 6 — Commit + +```bash +git add core/views.py core/templates/core/site_report_edit.html core/tests.py +git commit -m "feat(flow): Site Journal 'Save + Add Absences' button + +site_report_edit POST now reads next_action — 'save_absences' +saves the journal then redirects to /absences/log/ prefilled with +the WorkLog's date/team/project (mirrors the attendance form's +absence-prefill exactly). Default save still → dashboard. 2 tests." +``` + +--- + +## Task 4 — Docs: CLAUDE.md + parked-work.md + +**Goal:** Record the Site Journal display-rename as a Path-A entry, +and reserve "Voice Notes" for the parked voice feature so it doesn't +collide with "Journal". + +**Files:** +- Modify: `CLAUDE.md` (SiteReport Key-Models line + a Path-A note) +- Modify: `docs/plans/parked-work.md` (Backburner section naming note) + +### Step 1 — CLAUDE.md + +Find the `**SiteReport**` bullet in the "Key Models" section. Append +to it: +> **UI label is "Site Journal"** (display-only rename, 15 May 2026 — +> Path-A pattern: model/view/URL stay `SiteReport`/`site_report_*`/ +> `/site-report/`, only user-facing text says "Site Journal". Same +> rationale as "New Loan"→"Loan".) + +Find the "What's mid-flight" backburner bullet (top of file). Add a +sentence: +> The parked voice-transcript feature must NOT be called "Journal" +> (that name now belongs to the structured site-progress form) — +> use "Voice Notes". + +### Step 2 — parked-work.md + +In the "🧊 Backburner" section, in the Phase A.2 paragraph, add: +> **Naming reservation:** when this is eventually built, it must be +> called **"Voice Notes"** (or similar) — NOT "Journal". As of +> 15 May 2026 "Site Journal" is the user-facing name of the +> structured site-progress form (the `SiteReport` model). Two +> "Journal"s would collide. + +### Step 3 — Commit + +```bash +git add CLAUDE.md docs/plans/parked-work.md +git commit -m "docs: record Site Journal rename (Path-A) + reserve 'Voice Notes' + +CLAUDE.md SiteReport line notes the display-only UI rename. +parked-work.md reserves 'Voice Notes' for the future voice +feature so it never collides with 'Site Journal'." +``` + +--- + +## 🛑 HARD STOP — hand back to Konrad + +**After Task 4: DO NOT PUSH. DO NOT DEPLOY.** + +Report to Konrad: +- All tasks committed locally on `ai-dev` (list the commit SHAs) +- Full suite count (expect ~181 green) +- The exact manual verification steps from the design doc § + "Verification (manual, local — Konrad)": + 1. `/attendance/log/` → **Log Work** → dashboard + toast (NOT journal) + 2. → **Log Work + Site Journal** → Site Journal form (titled "Site Journal") + 3. → **Log Work + Add Absences** → `/absences/log/` prefilled + 4. Site Journal form → **Save Site Journal** → dashboard + 5. → **Save + Add Absences** → `/absences/log/` prefilled + 6. Trigger an attendance conflict with "Log Work + Site Journal" clicked → resolve → still lands on Site Journal form +- Remind Konrad: nothing reaches production until he runs these + locally and explicitly says "push it". + +Only after Konrad's explicit approval: push `ai-dev`, then the +standard deploy (pull on VM → restart; **no migrations, no +collectstatic** — pure template/view change; remember the +restart-AFTER-pull ordering rule from CLAUDE.md). + +--- + +## Notes for the implementer + +- This is ~120 LOC across 4 tiny tasks. Each task is independently + committable and TDD-driven. +- The `next_action` mechanism is proven (Round C, commit `8c749f3`). + You are extending it, not inventing it. +- Display-rename is Path-A: **never** rename code identifiers + (`SiteReport`, `site_report_edit`, the URLs). Only template text + + the one toast string. +- After EVERY template edit, run the `{#`-comment grep. This gotcha + has bitten the project 16+ times. +- If an existing test breaks because it asserted the OLD forced- + redirect or the OLD "Site Report" text — that's expected. Update + the assertion; the contract is intentionally changing. Do NOT + weaken a flow-v2 test to accommodate a stale one. +- No model, migration, URL, or dependency changes anywhere in this + plan. If you find yourself writing a migration, stop — you've gone + off-plan.