38686-vm/docs/plans/2026-05-15-post-attendance-flow-v2-plan.md
Konrad du Plessis 29c36bede7 docs: post-attendance flow v2 implementation plan
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) <noreply@anthropic.com>
2026-05-15 13:05:48 +02:00

27 KiB

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):

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"
  • <h1> "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:

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

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:

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:

            # === 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:

<div class="d-grid gap-2 mt-5">
    <button type="submit" name="next_action" value="log_only" class="btn btn-lg btn-accent">
        <i class="fas fa-save me-2"></i>Log Work
    </button>
    <button type="submit" name="next_action" value="log_absences" class="btn btn-lg btn-outline-secondary"
            title="...">
        <i class="fas fa-user-clock me-2"></i>Log Work + Add Absences
    </button>
</div>

Replace with (primary first, two secondary opt-ins, Konrad's order — Site Journal between Log Work and Absences):

<div class="d-grid gap-2 mt-5">
    {% 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 %}
    <button type="submit" name="next_action" value="log_only" class="btn btn-lg btn-accent">
        <i class="fas fa-save me-2"></i>Log Work
    </button>
    <button type="submit" name="next_action" value="log_journal" class="btn btn-outline-secondary"
            title="Save this work log, then open the Site Journal form to record weather / progress for the same day.">
        <i class="fas fa-clipboard-check me-2"></i>Log Work + Site Journal
    </button>
    <button type="submit" name="next_action" value="log_absences" class="btn btn-outline-secondary"
            title="Save this work log, then jump to the absence form pre-filled with the same date / team / project.">
        <i class="fas fa-user-clock me-2"></i>Log Work + Add Absences
    </button>
</div>

(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

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):

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):

            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:

            # 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:

<div class="d-flex justify-content-between gap-2">
    <a href="{% url 'home' %}" class="btn btn-outline-secondary">
        <i class="fas fa-times me-1"></i> Skip
    </a>
    <button type="submit" class="btn btn-accent">
        <i class="fas fa-check me-1"></i>
        {% if is_creating %}Save Site Journal{% else %}Update Site Journal{% endif %}
    </button>
</div>

(The button text already says "Site Journal" after Task 1.)

Replace with:

<div class="d-flex flex-wrap justify-content-between gap-2">
    <a href="{% url 'home' %}" class="btn btn-outline-secondary">
        <i class="fas fa-times me-1"></i> Skip
    </a>
    <div class="d-flex flex-wrap gap-2">
        <button type="submit" name="next_action" value="save_absences" class="btn btn-outline-secondary"
                title="Save the site journal, then jump to the absence form pre-filled with the same date / team / project.">
            <i class="fas fa-user-clock me-1"></i> Save + Add Absences
        </button>
        <button type="submit" name="next_action" value="save_only" class="btn btn-accent">
            <i class="fas fa-check me-1"></i>
            {% if is_creating %}Save Site Journal{% else %}Update Site Journal{% endif %}
        </button>
    </div>
</div>

(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

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

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.