fix(templates): convert 8 broken multi-line {# #} comments + clarify cryptic sublines

CLAUDE.md gotcha #1 strikes again — the dashboard audit pass added
7 multi-line {# ... #} comment blocks across index.html, report.html,
and pdf/report_pdf.html. All rendered as literal text on the live
pages (Konrad screenshotted them). Also caught an old one in
admin/base_site.html that was technically broken syntax but
non-rendering (outside any block). All 8 converted to
{% comment %}{% endcomment %}.

CLAUDE.md updated:
- Bumped the bit-us count (4 → confirmed 4 + 5 + 7 across three
  features). Added a grep-one-liner sanity check that finds broken
  multi-line {# blocks across all templates so future passes can
  spot-check before committing.

Cryptic hero-card sublines on /report/ clarified (Konrad asked
what they mean):
- "as of 08:13" → "Live total at 08:13 today · for <scope>" with
  hover tooltip explaining the snapshot semantics.
- "Company Avg / Working Day" / "/ Month" labels renamed to
  "Avg Labour Cost / Working Day" / "/ Month". Sublines simplified
  to "Lifetime average across all crews" / "Daily figure × 30.44
  days". Both gain hover tooltips that explain the math and the
  "current pay rates" basis.

Pure template + docs change. 173/173 tests still passing
(no test changes — these are cosmetic fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-05-15 08:23:20 +02:00
parent 652168fe88
commit 1d224bc01b
5 changed files with 56 additions and 30 deletions

View File

@ -28,7 +28,7 @@ answers — see `docs/plans/parked-work.md`.
- Add plain English comments explaining what complex logic does - Add plain English comments explaining what complex logic does
- The project owner is not a programmer — comments should be understandable by a non-technical person - The project owner is not a programmer — comments should be understandable by a non-technical person
- When creating or editing code, maintain the existing comment structure - When creating or editing code, maintain the existing comment structure
- **Django template comments `{# ... #}` are SINGLE-LINE only.** Multi-line blocks need `{% comment %}...{% endcomment %}`. A `{#` on line N with no closing `#}` on the same line renders the whole block as literal text onto the page (and silently — no error). This bit us 4× during the Adjustments feature. Also: the literal tokens `{#` and `#}` cannot appear inside a `{% comment %}` block — they'll be parsed as a nested comment marker. Rephrase meta-notes about comment syntax OUTSIDE the block. - **Django template comments `{# ... #}` are SINGLE-LINE only.** Multi-line blocks need `{% comment %}...{% endcomment %}`. A `{#` on line N with no closing `#}` on the same line renders the whole block as literal text onto the page (and silently — no error). This bit us 4× during the Adjustments feature, 5× during the Absences feature, and 7× during the 15 May dashboard-audit pass. **Sanity check after any template edit:** `grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}'` — every match is a multi-line broken comment. Also: the literal tokens `{#` and `#}` cannot appear inside a `{% comment %}` block — they'll be parsed as a nested comment marker. Rephrase meta-notes about comment syntax OUTSIDE the block.
- **Duplicate `id=""` attributes cause silent bugs.** `document.getElementById()` returns only the FIRST match in DOM order, so adding a second element with an existing id silently steals the handler from the original. Grep the template before assigning any new id (caught `adjSelectAll` collision in Task 6 — header checkbox stole the Add-Adjustment modal's Select-All handler). - **Duplicate `id=""` attributes cause silent bugs.** `document.getElementById()` returns only the FIRST match in DOM order, so adding a second element with an existing id silently steals the handler from the original. Grep the template before assigning any new id (caught `adjSelectAll` collision in Task 6 — header checkbox stole the Add-Adjustment modal's Select-All handler).
- **Bootstrap dropdowns inside `.card` elements get clipped by sibling cards.** A `.dropdown-menu` with `z-index: 1050` rendered inside a filter `.card` will STILL appear behind a sibling table `.card` that follows in document order. Bootstrap's `transform: translate(...)` Popper positioning creates a new stacking context — the z-index is measured INSIDE the parent card, not globally. The fix: lift the wrapping element (e.g. the filter `<form class="card">`) with `style="position: relative; z-index: 10;"` so the entire card sits above its siblings. The dropdown's local z-index then resolves correctly. Bit us on the Absences filter dropdown (May 2026). - **Bootstrap dropdowns inside `.card` elements get clipped by sibling cards.** A `.dropdown-menu` with `z-index: 1050` rendered inside a filter `.card` will STILL appear behind a sibling table `.card` that follows in document order. Bootstrap's `transform: translate(...)` Popper positioning creates a new stacking context — the z-index is measured INSIDE the parent card, not globally. The fix: lift the wrapping element (e.g. the filter `<form class="card">`) with `style="position: relative; z-index: 10;"` so the entire card sits above its siblings. The dropdown's local z-index then resolves correctly. Bit us on the Absences filter dropdown (May 2026).
- **JS reading from `data-worker-id` was unreliable; read from `<input name="workers">[value]` directly.** Round A's first absence-form team filter rendered `data-worker-id="{{ worker.choice_value }}"` on the row `<div>` and read it via `row.dataset.workerId`. On production this hid ALL workers when a team was selected — likely a stale-template / template-render mismatch. The proven pattern (used by `attendance_log.html` for years) is to read `row.querySelector('input[name="workers"]').value`. The form widget's `<input value="<pk>">` is the source of truth; data attributes are an unnecessary indirection. - **JS reading from `data-worker-id` was unreliable; read from `<input name="workers">[value]` directly.** Round A's first absence-form team filter rendered `data-worker-id="{{ worker.choice_value }}"` on the row `<div>` and read it via `row.dataset.workerId`. On production this hid ALL workers when a team was selected — likely a stale-template / template-render mismatch. The proven pattern (used by `attendance_log.html` for years) is to read `row.querySelector('input[name="workers"]').value`. The form widget's `<input value="<pk>">` is the source of truth; data attributes are an unnecessary indirection.

View File

@ -1,11 +1,13 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{# =========================================================== {% comment %}
===========================================================
Minimal override of the default admin/base_site.html. Minimal override of the default admin/base_site.html.
The sole purpose right now is to inject a small <style> block The sole purpose right now is to inject a small <style> block
into every Django admin page. Add more admin CSS tweaks here into every Django admin page. Add more admin CSS tweaks here
as needed — keeps them in one place and isolated from the as needed — keeps them in one place and isolated from the
main app's custom.css. main app's custom.css.
=========================================================== #} ===========================================================
{% endcomment %}
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} {% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}

View File

@ -148,8 +148,10 @@
<div style="flex: 1;"> <div style="flex: 1;">
<div class="stat-label">Outstanding by Project</div> <div class="stat-label">Outstanding by Project</div>
{% if outstanding_by_project_list %} {% if outstanding_by_project_list %}
{# Sorted by amount desc. Keyed by project_id so two projects {% comment %}
sharing a name don't get merged. #} Sorted by amount desc. Keyed by project_id so two projects
sharing a name don't get merged.
{% endcomment %}
<div style="font-size: 0.85rem; margin-top: 0.35rem;"> <div style="font-size: 0.85rem; margin-top: 0.35rem;">
{% for proj in outstanding_by_project_list %} {% for proj in outstanding_by_project_list %}
<div class="d-flex justify-content-between" style="color: var(--text-primary);"> <div class="d-flex justify-content-between" style="color: var(--text-primary);">

View File

@ -744,8 +744,10 @@
<td class="name">{{ w.name }}</td> <td class="name">{{ w.name }}</td>
<td class="r">{{ w.days }}</td> <td class="r">{{ w.days }}</td>
<td class="total">R&nbsp;{{ w.total_paid|money }}</td> <td class="total">R&nbsp;{{ w.total_paid|money }}</td>
{# adj_values now contains {'amount', 'is_deductive'} per column. {% comment %}
Render "-R …" for deductive types. #} adj_values now contains an amount + is_deductive flag per column.
Render with a leading minus for deductive types.
{% endcomment %}
{% for val in w.adj_values %} {% for val in w.adj_values %}
{% if val.amount %} {% if val.amount %}
<td class="r">{% if val.is_deductive %}&minus;R&nbsp;{% else %}R&nbsp;{% endif %}{{ val.amount|money_abs }}</td> <td class="r">{% if val.is_deductive %}&minus;R&nbsp;{% else %}R&nbsp;{% endif %}{{ val.amount|money_abs }}</td>

View File

@ -199,9 +199,11 @@
</div> </div>
{# === HERO KPI BAND === #} {# === HERO KPI BAND === #}
{# Sub-labels intentionally call out current-pay-rate basis (Finding 2) {% comment %}
Sub-labels intentionally call out current-pay-rate basis (Finding 2)
and the active filter scope (Finding 5) so the KPIs aren't read as and the active filter scope (Finding 5) so the KPIs aren't read as
apples-to-apples when filters are on. #} apples-to-apples when filters are on.
{% endcomment %}
<div class="row g-3 mb-4 hero-kpi-row"> <div class="row g-3 mb-4 hero-kpi-row">
<div class="col-lg-3 col-md-6"> <div class="col-lg-3 col-md-6">
<div class="stat-card stat-card--danger stat-card--hero h-100"> <div class="stat-card stat-card--danger stat-card--hero h-100">
@ -216,26 +218,36 @@
<div class="stat-card stat-card--warning stat-card--hero h-100"> <div class="stat-card stat-card--warning stat-card--hero h-100">
<div class="stat-label">Outstanding Now</div> <div class="stat-label">Outstanding Now</div>
<div class="stat-value">R {{ current_outstanding.total|money }}</div> <div class="stat-value">R {{ current_outstanding.total|money }}</div>
<div class="stat-subline"> <div class="stat-subline"
as of {{ current_as_of|date:"H:i" }} data-bs-toggle="tooltip"
title="Live total: unpaid wages plus any pending payroll adjustments. Refresh the page to see the latest.">
Live total at {{ current_as_of|date:"H:i" }} today
{% if project_name != 'All Projects' or team_name != 'All Teams' %} {% if project_name != 'All Projects' or team_name != 'All Teams' %}
&middot; scoped to {{ project_name }}{% if team_name != 'All Teams' %} / {{ team_name }}{% endif %} &middot; for {{ project_name }}{% if team_name != 'All Teams' %} / {{ team_name }}{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-3 col-md-6"> <div class="col-lg-3 col-md-6">
<div class="stat-card stat-card--info stat-card--hero h-100"> <div class="stat-card stat-card--info stat-card--hero h-100">
<div class="stat-label">Company Avg / Working Day</div> <div class="stat-label">Avg Labour Cost / Working Day</div>
<div class="stat-value">R {{ company_avg_daily|money }}</div> <div class="stat-value">R {{ company_avg_daily|money }}</div>
<div class="stat-subline">lifetime &middot; all crews &middot; at current pay rates</div> <div class="stat-subline"
data-bs-toggle="tooltip"
title="Sum of daily rates across all crews on every day FoxFitt has logged work, divided by the count of those days. Uses each worker's CURRENT salary — a recent raise will retroactively change this figure.">
Lifetime average across all crews
</div>
</div> </div>
</div> </div>
<div class="col-lg-3 col-md-6"> <div class="col-lg-3 col-md-6">
<div class="stat-card stat-card--info stat-card--hero h-100"> <div class="stat-card stat-card--info stat-card--hero h-100">
<div class="stat-label">Company Avg / Month</div> <div class="stat-label">Avg Labour Cost / Month</div>
<div class="stat-value">R {{ company_avg_monthly|money }}</div> <div class="stat-value">R {{ company_avg_monthly|money }}</div>
<div class="stat-subline">lifetime &middot; ~30.44 days/month &middot; at current pay rates</div> <div class="stat-subline"
data-bs-toggle="tooltip"
title="The daily figure to the left multiplied by 30.44 (the average number of days in a month, i.e. 365.25 ÷ 12). Same lifetime + current-pay-rates basis.">
Daily figure &times; 30.44 days
</div>
</div> </div>
</div> </div>
</div> </div>
@ -390,8 +402,10 @@
<table class="table table-sm mb-0"> <table class="table table-sm mb-0">
<thead><tr><th>Category</th><th class="text-end">Total</th></tr></thead> <thead><tr><th>Category</th><th class="text-end">Total</th></tr></thead>
<tbody> <tbody>
{# Sign-aware rendering: deductive types show as red "-R …"; {% comment %}
additive types stay in default colour with no sign. Finding 4. #} Sign-aware rendering: deductive types show as red "-R ...";
additive types stay in default colour with no sign. Finding 4.
{% endcomment %}
{% for item in adjustment_totals %} {% for item in adjustment_totals %}
<tr> <tr>
<td>{{ item.label }}</td> <td>{{ item.label }}</td>
@ -487,8 +501,10 @@
<th>Worker</th> <th>Worker</th>
<th class="text-end">Days</th> <th class="text-end">Days</th>
<th class="text-end">Total Paid</th> <th class="text-end">Total Paid</th>
{# Sign-aware headers: deductive types render in muted red {% comment %}
so the negative sign on rows below is unmistakable. Finding 4. #} Sign-aware headers: deductive types render in muted red
so the negative sign on rows below is unmistakable. Finding 4.
{% endcomment %}
{% for h in active_adj_headers %} {% for h in active_adj_headers %}
<th class="text-end d-none d-md-table-cell {% if h.is_deductive %}text-danger{% endif %}" style="font-size: 0.75rem;">{{ h.label }}</th> <th class="text-end d-none d-md-table-cell {% if h.is_deductive %}text-danger{% endif %}" style="font-size: 0.75rem;">{{ h.label }}</th>
{% endfor %} {% endfor %}
@ -500,8 +516,10 @@
<td class="fw-medium">{{ w.name }}</td> <td class="fw-medium">{{ w.name }}</td>
<td class="text-end">{{ w.days }}</td> <td class="text-end">{{ w.days }}</td>
<td class="text-end fw-semibold">R {{ w.total_paid|money }}</td> <td class="text-end fw-semibold">R {{ w.total_paid|money }}</td>
{# Each val is now {'amount': Decimal, 'is_deductive': bool} {% comment %}
— render "-R 500.00" in red for deductive types. #} Each val is now an "amount" Decimal plus an "is_deductive" bool
— render the amount in red with a leading minus sign for deductive types.
{% endcomment %}
{% for val in w.adj_values %} {% for val in w.adj_values %}
<td class="text-end d-none d-md-table-cell {% if val.is_deductive and val.amount %}text-danger{% endif %}" style="font-size: 0.8rem;"> <td class="text-end d-none d-md-table-cell {% if val.is_deductive and val.amount %}text-danger{% endif %}" style="font-size: 0.8rem;">
{% if val.amount %} {% if val.amount %}
@ -514,8 +532,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{# Footnote — explains why Days and Total Paid can read as {% comment %}
"not matching" within a single period (different timing). #} Footnote — explains why Days and Total Paid can read as
"not matching" within a single period (different timing).
{% endcomment %}
<p class="text-muted px-3 py-2 mb-0" style="font-size: 0.75rem; border-top: 1px solid var(--border-subtle);"> <p class="text-muted px-3 py-2 mb-0" style="font-size: 0.75rem; border-top: 1px solid var(--border-subtle);">
<i class="fas fa-info-circle me-1"></i> <i class="fas fa-info-circle me-1"></i>
<strong>Days</strong> reflect work logged in this period. <strong>Days</strong> reflect work logged in this period.