Task-by-task plan for the design at 30d0991. 6 tasks, 1 checkpoint after Task 4 (all pill interactions demoable, modal still as fallback). Tasks: 1. Backend: project_team_pairs_json context + 2 tests 2. Template: popover shells + Apply button + json_script embeds 3. CSS: pill-editable, pill-dirty, popover, apply-group, toast (~150 lines) 4. JS: pill-popover interactive module (~300 lines, scoped IIFE) --- CHECKPOINT 1 --- 5. Retire modal: delete _report_config_modal.html, update index + report templates, keep backend context keys (still used by pill markup) 6. QA + shipped note Scope: ~480 LOC net added (not the ~330 estimated — JS came out larger once written with proper state management + cross-filter). Tests grow 42 -> 44. One new CDN-loaded library? No — Choices.js already loaded from Executive Report v2. Zero model changes, zero migrations. Noted trade-off in Task 5: selected_project_ids / selected_team_ids context keys were KEPT despite design doc suggesting removal — the pill popovers still use them for pre-selection + URL-diff init. Only the modal markup was retired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
61 KiB
Inline Filters (Pill-as-Dropdown) Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace the modal-based filter form on /report/ with interactive pill-dropdowns (Apply button appears only when pending changes exist, bidirectional project↔team cross-filter auto-removes invalid selections), and retire _report_config_modal.html entirely.
Architecture: Template-only change except for one backend tweak. The existing filter-pill strip (shipped with Executive Report v2) becomes clickable — each pill opens a Bootstrap-like popover containing the relevant editable widget (date picker or Choices.js multi-select). A scoped IIFE JS module inside report.html manages popover state, tracks dirty filters, and rebuilds the submit URL on Apply. Cross-filter data comes from a single JSON <script type="application/json"> embed serialised by the view. The modal + Generate/New Report buttons are deleted since pills ARE the new-report interface.
Tech Stack: Django 5.2.7 · Python 3.13 · SQLite (local) / MySQL (prod) · Bootstrap 5.3 · Font Awesome 6 · Choices.js 10.2.0 (CDN, already loaded from the just-shipped executive report) · WeasyPrint (PDF unchanged). Existing patterns: # === SECTION === comments, plain-English docstrings, json_script template filter, createElement + textContent (XSS-safe JS).
Design source: docs/plans/2026-04-23-inline-filters-design.md (local commit 30d0991, not yet pushed).
Commit convention: One commit per task. Co-Authored-By trailer. Never amend. All local-only until the feature is complete and Konrad approves the push.
Reference schema gotchas (from CLAUDE.md):
PayrollAdjustment.description(notreason)log.adjustments_by_work_log(notpayrolladjustment_set)log.overtime_amount(notlog.overtime)- For M2M filters in
.values().annotate(Sum()): useid__in=Model.objects.filter(...).values('id')(avoid chained.filter(m2m__x=a).distinct()— N² inflation)
Key existing line references (HEAD 12edafa):
core/templates/core/report.html: 401 lines total- Lines 20-23: header "New Report" button (opens modal — to delete in Task 5)
- Lines 34-49: filter-pill strip from recent work (editable in Tasks 2-4)
- Lines 387-389: bottom "New Report" button (to delete)
- Line 400:
{% include 'core/_report_config_modal.html' %}(to delete)
core/templates/core/index.html: 607 lines total- Lines 187-190: "Generate Report" Quick Actions card (anchor + modal trigger — become plain link in Task 5)
- Line 605:
{% include 'core/_report_config_modal.html' %}(to delete)
core/templates/core/_report_config_modal.html: 160 lines — delete entire file in Task 5core/views.py:- Lines 434-435:
selected_project_ids/selected_team_idsinindex()context (remove in Task 5) - Lines 2395-2404: same context keys in
generate_reportview + block comments explaining the stringformat pattern (remove in Task 5)
- Lines 434-435:
static/css/custom.css: 1735 lines total- Lines 1496-1524: existing
.filter-pill+.filter-pill__xrules (extend in Task 3)
- Lines 1496-1524: existing
Review checkpoints
One checkpoint after Task 4 — all pill interactions demonstrable in the browser; modal still on the page as a fallback. After Konrad confirms the interactive behaviour is right, Tasks 5-6 retire the modal and ship.
Task 1: Backend — project_team_pairs_json context key + 1 test
Why first: tiny, low-risk, non-breaking. The template doesn't consume the new key yet (that's Task 2), so this commit alone changes nothing visible. Prepares the data source for the JS cross-filter in Task 4.
Files:
- Modify:
core/views.py(one line insidegenerate_reportview around line 2395, plus an import) - Modify:
core/tests.py(append new test class)
Step 1.1: Check json import
Grep to confirm import json is already at the top of core/views.py:
grep -n "^import json\|^from json" core/views.py
Expected: present (used elsewhere). If missing, add import json near the other stdlib imports.
Step 1.2: Write the failing test
Append to core/tests.py:
# =============================================================================
# === TESTS FOR INLINE FILTERS (Report Page) ===
# Pill-as-dropdown + cross-filter feature. Most behaviour is template/JS;
# the only backend surface is the project_team_pairs_json context key that
# powers the client-side Team↔Project cross-filter.
# =============================================================================
class InlineFiltersPairsContextTests(TestCase):
"""Report view must serialise distinct (project_id, team_id) pairs for
the pill-popover cross-filter JS."""
def setUp(self):
self.admin = User.objects.create_user(
username='admin-if', password='pass', is_staff=True
)
self.p1 = Project.objects.create(name='P1')
self.p2 = Project.objects.create(name='P2')
self.t1 = Team.objects.create(name='T1', supervisor=self.admin)
self.t2 = Team.objects.create(name='T2', supervisor=self.admin)
self.w = Worker.objects.create(
name='W', id_number='W1', monthly_salary=Decimal('4000')
)
# Log t1 on p1, t2 on p2 — so pairs should be [(p1,t1), (p2,t2)]
for proj, team in [(self.p1, self.t1), (self.p2, self.t2)]:
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 1),
project=proj, team=team, supervisor=self.admin,
)
log.workers.add(self.w)
def test_pairs_context_key_populated(self):
import json as _json
self.client.login(username='admin-if', password='pass')
url = reverse('generate_report')
resp = self.client.get(url + '?from_month=2026-03&to_month=2026-04')
self.assertEqual(resp.status_code, 200)
pairs_json = resp.context['project_team_pairs_json']
pairs = _json.loads(pairs_json)
# Each entry has both project_id and team_id
for p in pairs:
self.assertIn('project_id', p)
self.assertIn('team_id', p)
# Expected pairs (as tuples for set comparison)
pair_set = {(p['project_id'], p['team_id']) for p in pairs}
self.assertIn((self.p1.id, self.t1.id), pair_set)
self.assertIn((self.p2.id, self.t2.id), pair_set)
def test_pairs_excludes_null_project_or_team(self):
"""Logs with null project or null team should not appear in pairs."""
import json as _json
# Add a log with team=None
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 2),
project=self.p1, team=None, supervisor=self.admin,
)
log.workers.add(self.w)
self.client.login(username='admin-if', password='pass')
resp = self.client.get(reverse('generate_report') + '?from_month=2026-03&to_month=2026-04')
pairs = _json.loads(resp.context['project_team_pairs_json'])
# No pair should have team_id=None
self.assertTrue(all(p['team_id'] is not None for p in pairs))
Step 1.3: Run the test — expect failure
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.InlineFiltersPairsContextTests -v 2
Expected: KeyError: 'project_team_pairs_json' or similar "context key not found" failure.
Step 1.4: Implement in generate_report
Find generate_report view in core/views.py (around line 2380). Locate the section where context['query_string'] = request.GET.urlencode() is set (around line 2385). Just after that, add:
# === Cross-filter source: serialised (project_id, team_id) pairs ===
# The pill-popover JS on the report page uses this to hide teams that
# haven't worked on a selected project, and vice versa. Scope = entire
# history (not this report's date range) — cross-filter is about data
# possibility, not data in this period.
pairs = list(
WorkLog.objects
.filter(project__isnull=False, team__isnull=False)
.values('project_id', 'team_id')
.distinct()
)
context['project_team_pairs_json'] = json.dumps(pairs)
(Verify json is imported at the top of the file; if not, add import json near the other stdlib imports.)
Step 1.5: Run the test — expect pass
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.InlineFiltersPairsContextTests -v 2
Expected: Ran 2 tests ... OK. Full suite:
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 0 2>&1 | tail -5
Expected: Ran 44 tests ... OK (42 existing + 2 new).
Step 1.6: Commit
git add core/views.py core/tests.py
git commit -m "$(cat <<'EOF'
Backend: add project_team_pairs_json context for inline-filter cross-filter
Serialises distinct (project_id, team_id) pairs from WorkLog as JSON on
the generate_report context. The upcoming pill-popover JS (Task 4 of the
inline-filters plan) uses this to hide teams that haven't worked on a
selected project (and vice versa) without any extra HTTP round-trips.
Scope: entire history (not the report date range) — cross-filter is about
data possibility, not data shown in this period. Filters out NULL
project or team (can't cross-filter on NULL).
2 tests cover: key is populated with correct pairs; NULL-team logs don't
leak into the pairs list.
No visible behaviour change — template doesn't consume the new key yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 2: Template markup — popover shells + Apply button + json_script embed
Files:
- Modify:
core/templates/core/report.html(extend existing.filter-pill-stripblock, add popover panels + Apply button + json_script, all in thed-print-noneregion at the top of the page)
Step 2.1: Replace the existing filter-pill strip (lines 33-50)
Find the existing strip:
{# === FILTER PILLS === #}
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none">
<span class="filter-pill">
<i class="fas fa-calendar me-1"></i>{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}
</span>
<span class="filter-pill">
<i class="fas fa-folder me-1"></i>{{ project_name }}
{% if selected_project_ids %}
<a href="?{{ query_string_without_project|default:query_string }}" class="filter-pill__x" aria-label="Clear project filter">×</a>
{% endif %}
</span>
<span class="filter-pill">
<i class="fas fa-users me-1"></i>{{ team_name }}
{% if selected_team_ids %}
<a href="?{{ query_string_without_team|default:query_string }}" class="filter-pill__x" aria-label="Clear team filter">×</a>
{% endif %}
</span>
</div>
Replace with the interactive version:
{# === FILTER PILLS (interactive — pill-as-dropdown) === #}
{# Each pill is a clickable button that opens an inline popover with the #}
{# relevant editor. The Apply button appears only when at least one pill #}
{# has uncommitted changes. See the JS module lower in this file. #}
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none" id="filter-pill-strip">
{# --- Date pill --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable"
id="filter-pill-date"
data-filter="date"
aria-expanded="false"
aria-controls="popover-date">
<i class="fas fa-calendar me-1"></i>
<span class="filter-pill__label">{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="popover-date" role="dialog" aria-label="Edit date range" hidden>
<div class="filter-popover__body">
<label class="form-label fw-semibold small">Date Selection</label>
<div class="btn-group w-100 mb-3" role="group">
<input type="radio" class="btn-check" name="popover_date_mode" id="popDateModeMonth" value="month" checked>
<label class="btn btn-outline-secondary btn-sm" for="popDateModeMonth">
<i class="fas fa-calendar-alt me-1"></i>Month(s)
</label>
<input type="radio" class="btn-check" name="popover_date_mode" id="popDateModeCustom" value="custom">
<label class="btn btn-outline-secondary btn-sm" for="popDateModeCustom">
<i class="fas fa-calendar-week me-1"></i>Custom Dates
</label>
</div>
<div class="row g-2" id="popoverMonthFields">
<div class="col-6">
<label class="form-label small">From</label>
<input type="month" id="popoverFromMonth" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label small">To</label>
<input type="month" id="popoverToMonth" class="form-control form-control-sm">
</div>
</div>
<div class="row g-2 d-none" id="popoverCustomFields">
<div class="col-6">
<label class="form-label small">Start</label>
<input type="date" id="popoverStartDate" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label small">End</label>
<input type="date" id="popoverEndDate" class="form-control form-control-sm">
</div>
</div>
</div>
<div class="filter-popover__footer">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
</div>
{# --- Projects pill --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable"
id="filter-pill-projects"
data-filter="projects"
aria-expanded="false"
aria-controls="popover-projects">
<i class="fas fa-folder me-1"></i>
<span class="filter-pill__label">{{ project_name }}</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
{% if selected_project_ids %}
<a href="?{{ query_string_without_project|default:query_string }}"
class="filter-pill__x"
aria-label="Clear project filter"
title="Clear project filter">×</a>
{% endif %}
<div class="filter-popover" id="popover-projects" role="dialog" aria-label="Edit projects" hidden>
<div class="filter-popover__body">
<label class="form-label fw-semibold small">Projects</label>
<select id="popoverProjects" class="form-select report-multi" multiple data-placeholder="All projects (leave empty for all)">
{% for p in projects %}
<option value="{{ p.id }}"{% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="filter-popover__footer">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
</div>
{# --- Teams pill --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable"
id="filter-pill-teams"
data-filter="teams"
aria-expanded="false"
aria-controls="popover-teams">
<i class="fas fa-users me-1"></i>
<span class="filter-pill__label">{{ team_name }}</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
{% if selected_team_ids %}
<a href="?{{ query_string_without_team|default:query_string }}"
class="filter-pill__x"
aria-label="Clear team filter"
title="Clear team filter">×</a>
{% endif %}
<div class="filter-popover" id="popover-teams" role="dialog" aria-label="Edit teams" hidden>
<div class="filter-popover__body">
<label class="form-label fw-semibold small">Teams</label>
<select id="popoverTeams" class="form-select report-multi" multiple data-placeholder="All teams (leave empty for all)">
{% for t in teams %}
<option value="{{ t.id }}"{% if t.id|stringformat:"s" in selected_team_ids %} selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="filter-popover__footer">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
</div>
{# --- Apply / Cancel buttons (shown only when dirty) --- #}
<div class="apply-filters-group ms-auto" id="apply-filters-group" hidden>
<button type="button" class="btn btn-sm btn-outline-secondary" id="cancel-filters-btn">Reset</button>
<button type="button" class="btn btn-sm btn-accent ms-1" id="apply-filters-btn">
<i class="fas fa-check me-1"></i>Apply
</button>
</div>
</div>
{# --- Toast container (for cross-filter auto-removal notices) --- #}
<div id="filter-toast-container" class="filter-toast-container d-print-none" aria-live="polite"></div>
{# --- Cross-filter data for the JS module --- #}
{{ project_team_pairs_json|json_script:"projectTeamPairs" }}
{# --- Expose current URL filter state for JS reset + dirty diffing --- #}
{{ selected_project_ids|json_script:"urlSelectedProjectIds" }}
{{ selected_team_ids|json_script:"urlSelectedTeamIds" }}
Step 2.2: Check syntax
USE_SQLITE=true DJANGO_DEBUG=true python manage.py check
Expected: only pre-existing staticfiles.W004 warning; no template errors.
Step 2.3: Sanity-check the page still renders
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/report/
Expected: 302 (login redirect; page renders OK). Open the report page in the browser — pills should be visible with chevrons, though clicking them does nothing yet (no JS). The popovers sit below each pill with hidden attribute — they should NOT be visible.
Step 2.4: Commit
git add core/templates/core/report.html
git commit -m "$(cat <<'EOF'
Template: interactive filter-pill markup + popover shells
Replaces the three static filter pills with clickable buttons and
inline popover shells below each one. Popovers remain hidden by
default (hidden attribute) — the JS module in Task 4 will wire up
open/close, dirty state, and Apply behaviour.
Structure per pill:
- .filter-pill-wrap (position-relative container)
- <button class="filter-pill filter-pill--editable" data-filter="...">
with chevron indicating clickability
- <a class="filter-pill__x"> (existing × clear-filter link, preserved)
- .filter-popover (the editable widget — date picker for the Date
pill, Choices.js multi-select for Projects/Teams pills)
Apply + Reset buttons sit in .apply-filters-group at the right end,
initially hidden. A <div id="filter-toast-container"> is pre-placed
for the cross-filter auto-removal notices.
Three json_script blocks embed the data the JS needs:
- projectTeamPairs: (project_id, team_id) pairs for cross-filter
- urlSelectedProjectIds / urlSelectedTeamIds: current URL state for
dirty diffing + reset
No visible behaviour change yet (no CSS, no JS). Page renders same
as before until Tasks 3-4 light it up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 3: CSS — pill-editable, pill-dirty, popover, apply-button, toast
Files:
- Modify:
static/css/custom.css(append to end of file)
Step 3.1: Append the new CSS block
Append this at the end of static/css/custom.css:
/* === Inline Filters (pill-as-dropdown) on the report page === */
/*
Layered on top of the existing .filter-pill rules (lines ~1496–1524).
Three components:
1. .filter-pill--editable: pointer cursor, hover tint, chevron
2. .filter-pill--dirty: accent outline + small pulsing dot when uncommitted
3. .filter-popover: absolute-positioned dropdown beneath the pill
4. .apply-filters-group: slide-in Apply/Reset buttons when any pill dirty
5. .filter-toast-container: cross-filter auto-removal notices
*/
/* --- Wrapper keeps the popover anchored to its pill --- */
.filter-pill-wrap {
display: inline-flex;
align-items: center;
}
/* --- Editable pill: button, cursor, hover state, chevron --- */
.filter-pill--editable {
cursor: pointer;
border: 1px solid var(--border-default);
background: var(--bg-inset);
color: var(--text-primary);
transition: background-color 120ms, border-color 120ms, box-shadow 120ms;
}
.filter-pill--editable:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
}
.filter-pill--editable[aria-expanded="true"] {
background: var(--bg-card-hover);
border-color: var(--accent);
}
.filter-pill__chevron {
opacity: 0.7;
transition: transform 120ms;
}
.filter-pill--editable[aria-expanded="true"] .filter-pill__chevron {
transform: rotate(180deg);
}
/* --- Dirty state: pulsing accent dot + outline --- */
.filter-pill--dirty {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(232, 133, 26, 0.18);
}
.filter-pill--dirty::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
margin-right: 0.4rem;
animation: filter-pill-pulse 1.4s ease-in-out infinite;
}
@keyframes filter-pill-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
/* --- Popover positioned under the pill --- */
.filter-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 1040; /* below Bootstrap modal (1055) but above everything else */
min-width: 300px;
max-width: 420px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
padding: 0;
}
.filter-popover[hidden] {
display: none;
}
.filter-popover__body {
padding: 1rem;
}
.filter-popover__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-top: 1px solid var(--border-default);
background: var(--bg-inset);
border-radius: 0 0 0.5rem 0.5rem;
}
/* --- Mobile: popovers stretch full-width below the pill strip --- */
@media (max-width: 576px) {
.filter-popover {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100vw;
max-width: 100vw;
border-radius: 0.5rem 0.5rem 0 0;
z-index: 1050;
}
}
/* --- Apply / Reset button group --- */
.apply-filters-group {
display: flex;
align-items: center;
animation: apply-slide-in 180ms ease-out;
}
.apply-filters-group[hidden] {
display: none;
}
@keyframes apply-slide-in {
from { opacity: 0; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
/* --- Toast container for cross-filter auto-remove notices --- */
.filter-toast-container {
position: fixed;
top: 4.5rem; /* below topbar */
right: 1rem;
z-index: 1060;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
max-width: 320px;
}
.filter-toast {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--accent);
border-left: 4px solid var(--accent);
border-radius: 0.375rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
animation: filter-toast-in 200ms ease-out;
pointer-events: auto;
}
.filter-toast--exiting {
animation: filter-toast-out 200ms ease-in forwards;
}
@keyframes filter-toast-in {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes filter-toast-out {
to { opacity: 0; transform: translateX(12px); }
}
Step 3.2: Verify the CSS applies
USE_SQLITE=true DJANGO_DEBUG=true python manage.py check 2>&1 | tail -3
Expected: only the pre-existing staticfiles.W004 warning.
Manual browser check: open the report page, hover a pill — cursor should show pointer + subtle background tint + orange border. Chevron present. Popover still hidden (no JS yet to open it).
Step 3.3: Commit
git add static/css/custom.css
git commit -m "$(cat <<'EOF'
CSS: inline-filter pill-dropdown styling
Adds five new style components beneath the existing .filter-pill rules:
1. .filter-pill--editable — pointer cursor, hover tint, chevron rotation
2. .filter-pill--dirty — accent outline + pulsing orange dot
3. .filter-popover — absolute-positioned dropdown with shadow;
on mobile (<576px) anchors to bottom of
viewport full-width
4. .apply-filters-group — slide-in animation on the Apply/Reset buttons
5. .filter-toast-container — fixed top-right stack for cross-filter
auto-remove notices; slide-in/out animations
All colours via existing design tokens (--accent, --bg-card,
--text-primary, --border-default, --bg-inset) so dark + light themes
work automatically. z-index layering (popover 1040, toast 1060) stays
below Bootstrap modals (1055+) to avoid stacking conflicts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 4: JS — the pill-popover interactive module
The heart of the feature. One scoped IIFE appended to report.html that handles:
- Open / close popovers (click pill, click outside, Esc)
- Initialise Choices.js on the two multi-selects the first time their popover opens
- Date-mode toggle (Month vs Custom) inside the date popover
- Track pending state per pill; compare against URL state; update
.filter-pill--dirty+ Apply button visibility - Cross-filter (Team↔Project) with auto-remove + toast
- Apply → submit via
window.location - Reset → revert all pills
Files:
- Modify:
core/templates/core/report.html(append<script>block at the end, inside{% block content %}or before{% endblock %})
Step 4.1: Locate the insertion point
Find the final {% endblock %} (or {% endblock content %}) in core/templates/core/report.html. Insert the script block IMMEDIATELY before it.
Step 4.2: Append the JS module
{# === INLINE FILTERS — PILL POPOVER MODULE === #}
{# Scoped IIFE; runs once on DOMContentLoaded. Manages popover open/close, #}
{# dirty state, cross-filter, Apply submission. XSS-safe: createElement + #}
{# textContent only (no innerHTML with user data). Matches CLAUDE.md pattern. #}
<script>
document.addEventListener('DOMContentLoaded', function() {
// --- Bail if Choices.js isn't loaded (shouldn't happen since admin-only page loads it) ---
if (typeof Choices === 'undefined') {
console.warn('[inline-filters] Choices.js not loaded; pills will not enhance');
return;
}
// --- Context from json_script tags (safe-decoded by browser JSON parser) ---
var pairsEl = document.getElementById('projectTeamPairs');
var urlProjectsEl = document.getElementById('urlSelectedProjectIds');
var urlTeamsEl = document.getElementById('urlSelectedTeamIds');
var pairs = pairsEl ? JSON.parse(pairsEl.textContent) : [];
// URL state (strings from template) — cast to ints for comparison
function toIntArr(s) { return (JSON.parse(s || '[]') || []).map(function(v) { return parseInt(v, 10); }).filter(function(n) { return !isNaN(n); }); }
var urlProjects = toIntArr(urlProjectsEl && urlProjectsEl.textContent);
var urlTeams = toIntArr(urlTeamsEl && urlTeamsEl.textContent);
// --- Cross-filter lookup indices ---
// projectToTeams[pid] = Set of team_ids that have worked on project pid
// teamToProjects[tid] = Set of project_ids that team tid has worked on
var projectToTeams = {}, teamToProjects = {};
pairs.forEach(function(pair) {
var pid = pair.project_id, tid = pair.team_id;
if (!projectToTeams[pid]) projectToTeams[pid] = new Set();
if (!teamToProjects[tid]) teamToProjects[tid] = new Set();
projectToTeams[pid].add(tid);
teamToProjects[tid].add(pid);
});
// --- Pending state (starts = URL state; becomes dirty when user edits) ---
var pending = {
dateMode: null, fromMonth: null, toMonth: null, startDate: null, endDate: null,
projects: urlProjects.slice(),
teams: urlTeams.slice(),
};
// Seed date from current URL (same logic the existing modal uses).
// Template already rendered the correct From/To into the popover inputs,
// so we just read them.
var fromMonthInput = document.getElementById('popoverFromMonth');
var toMonthInput = document.getElementById('popoverToMonth');
var startDateInput = document.getElementById('popoverStartDate');
var endDateInput = document.getElementById('popoverEndDate');
// Initialise input values from URL query params
var qs = new URLSearchParams(window.location.search);
fromMonthInput.value = qs.get('from_month') || '';
toMonthInput.value = qs.get('to_month') || '';
startDateInput.value = qs.get('start_date') || '';
endDateInput.value = qs.get('end_date') || '';
var initialDateMode = (qs.get('start_date') || qs.get('end_date')) ? 'custom' : 'month';
document.getElementById('popDateModeMonth').checked = (initialDateMode === 'month');
document.getElementById('popDateModeCustom').checked = (initialDateMode === 'custom');
document.getElementById('popoverMonthFields').classList.toggle('d-none', initialDateMode !== 'month');
document.getElementById('popoverCustomFields').classList.toggle('d-none', initialDateMode !== 'custom');
pending.dateMode = initialDateMode;
pending.fromMonth = fromMonthInput.value;
pending.toMonth = toMonthInput.value;
pending.startDate = startDateInput.value;
pending.endDate = endDateInput.value;
var urlDate = {
mode: pending.dateMode,
fromMonth: pending.fromMonth, toMonth: pending.toMonth,
startDate: pending.startDate, endDate: pending.endDate,
};
// --- Choices.js instances (lazy init on first open) ---
var projectsChoices = null, teamsChoices = null;
// --- DOM refs ---
var pills = {
date: document.getElementById('filter-pill-date'),
projects: document.getElementById('filter-pill-projects'),
teams: document.getElementById('filter-pill-teams'),
};
var popovers = {
date: document.getElementById('popover-date'),
projects: document.getElementById('popover-projects'),
teams: document.getElementById('popover-teams'),
};
var labels = {
date: pills.date.querySelector('.filter-pill__label'),
projects: pills.projects.querySelector('.filter-pill__label'),
teams: pills.teams.querySelector('.filter-pill__label'),
};
var applyGroup = document.getElementById('apply-filters-group');
var applyBtn = document.getElementById('apply-filters-btn');
var resetBtn = document.getElementById('cancel-filters-btn');
var toastContainer = document.getElementById('filter-toast-container');
// === POPOVER OPEN / CLOSE ===
function closeAllPopovers(except) {
Object.keys(popovers).forEach(function(key) {
if (key !== except) {
popovers[key].hidden = true;
pills[key].setAttribute('aria-expanded', 'false');
}
});
}
function openPopover(key) {
closeAllPopovers(key);
var pop = popovers[key];
pop.hidden = false;
pills[key].setAttribute('aria-expanded', 'true');
// Lazy-init Choices.js
if (key === 'projects' && !projectsChoices) {
projectsChoices = new Choices(document.getElementById('popoverProjects'), {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All projects (leave empty for all)',
});
}
if (key === 'teams' && !teamsChoices) {
teamsChoices = new Choices(document.getElementById('popoverTeams'), {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All teams (leave empty for all)',
});
}
// Apply cross-filter to dropdown options (hide invalid ones)
if (key === 'projects') applyCrossFilter('projects');
if (key === 'teams') applyCrossFilter('teams');
}
// --- Pill click handlers ---
Object.keys(pills).forEach(function(key) {
pills[key].addEventListener('click', function(ev) {
ev.stopPropagation();
var isOpen = !popovers[key].hidden;
if (isOpen) {
popovers[key].hidden = true;
pills[key].setAttribute('aria-expanded', 'false');
} else {
openPopover(key);
}
});
});
// --- Click outside closes all ---
document.addEventListener('click', function(ev) {
if (!ev.target.closest('.filter-pill-wrap')) closeAllPopovers();
});
// --- Esc closes all ---
document.addEventListener('keydown', function(ev) {
if (ev.key === 'Escape') closeAllPopovers();
});
// === POPOVER OK / CANCEL HANDLERS ===
// Delegated: any .popover-ok or .popover-cancel inside a popover.
document.querySelectorAll('.filter-popover').forEach(function(pop) {
var okBtn = pop.querySelector('.popover-ok');
var cancelBtn = pop.querySelector('.popover-cancel');
okBtn.addEventListener('click', function() {
// Commit the popover's pending edits into global state
if (pop.id === 'popover-date') commitDateFromInputs();
if (pop.id === 'popover-projects') commitProjectsFromChoices();
if (pop.id === 'popover-teams') commitTeamsFromChoices();
updateAllPillsDirty();
closeAllPopovers();
});
cancelBtn.addEventListener('click', function() {
// Revert this popover's inputs to the current pending state
if (pop.id === 'popover-date') revertDateInputs();
if (pop.id === 'popover-projects') revertProjectsChoices();
if (pop.id === 'popover-teams') revertTeamsChoices();
closeAllPopovers();
});
});
// === DATE MODE TOGGLE inside the date popover ===
document.getElementById('popDateModeMonth').addEventListener('change', function() {
document.getElementById('popoverMonthFields').classList.remove('d-none');
document.getElementById('popoverCustomFields').classList.add('d-none');
});
document.getElementById('popDateModeCustom').addEventListener('change', function() {
document.getElementById('popoverMonthFields').classList.add('d-none');
document.getElementById('popoverCustomFields').classList.remove('d-none');
});
// === COMMIT / REVERT HELPERS ===
function commitDateFromInputs() {
pending.dateMode = document.getElementById('popDateModeCustom').checked ? 'custom' : 'month';
pending.fromMonth = fromMonthInput.value;
pending.toMonth = toMonthInput.value;
pending.startDate = startDateInput.value;
pending.endDate = endDateInput.value;
// Update pill label
if (pending.dateMode === 'month' && pending.fromMonth && pending.toMonth) {
labels.date.textContent = humanMonth(pending.fromMonth) + ' – ' + humanMonth(pending.toMonth);
} else if (pending.dateMode === 'custom' && pending.startDate && pending.endDate) {
labels.date.textContent = humanDate(pending.startDate) + ' – ' + humanDate(pending.endDate);
}
}
function revertDateInputs() {
document.getElementById('popDateModeMonth').checked = (pending.dateMode === 'month');
document.getElementById('popDateModeCustom').checked = (pending.dateMode === 'custom');
document.getElementById('popoverMonthFields').classList.toggle('d-none', pending.dateMode !== 'month');
document.getElementById('popoverCustomFields').classList.toggle('d-none', pending.dateMode !== 'custom');
fromMonthInput.value = pending.fromMonth || '';
toMonthInput.value = pending.toMonth || '';
startDateInput.value = pending.startDate || '';
endDateInput.value = pending.endDate || '';
}
function commitProjectsFromChoices() {
if (!projectsChoices) return;
pending.projects = projectsChoices.getValue(true).map(function(v) { return parseInt(v, 10); });
updatePillLabel('projects', 'All Projects', document.getElementById('popoverProjects'));
// Cross-filter: any team that's no longer valid gets auto-removed
var invalidTeams = pending.teams.filter(function(tid) {
return pending.projects.length > 0 && !pending.projects.some(function(pid) {
return projectToTeams[pid] && projectToTeams[pid].has(tid);
});
});
if (invalidTeams.length > 0) {
pending.teams = pending.teams.filter(function(tid) { return invalidTeams.indexOf(tid) < 0; });
// Update the Choices.js widget if it's initialised
if (teamsChoices) {
teamsChoices.removeActiveItemsByValue(invalidTeams.map(String));
// Actually Choices.js doesn't have removeActiveItemsByValue — use setChoiceByValue
// Safer: rebuild the teams widget's selection
rebuildChoicesSelection(teamsChoices, pending.teams);
}
invalidTeams.forEach(function(tid) {
var name = teamNameById(tid);
showToast('Removed ' + name + ' — no logs on selected projects');
});
updatePillLabel('teams', 'All Teams', document.getElementById('popoverTeams'));
}
}
function revertProjectsChoices() {
if (!projectsChoices) return;
rebuildChoicesSelection(projectsChoices, pending.projects);
}
function commitTeamsFromChoices() {
if (!teamsChoices) return;
pending.teams = teamsChoices.getValue(true).map(function(v) { return parseInt(v, 10); });
updatePillLabel('teams', 'All Teams', document.getElementById('popoverTeams'));
// Cross-filter: any project that's no longer valid gets auto-removed
var invalidProjects = pending.projects.filter(function(pid) {
return pending.teams.length > 0 && !pending.teams.some(function(tid) {
return teamToProjects[tid] && teamToProjects[tid].has(pid);
});
});
if (invalidProjects.length > 0) {
pending.projects = pending.projects.filter(function(pid) { return invalidProjects.indexOf(pid) < 0; });
if (projectsChoices) rebuildChoicesSelection(projectsChoices, pending.projects);
invalidProjects.forEach(function(pid) {
var name = projectNameById(pid);
showToast('Removed ' + name + ' — no logs for selected teams');
});
updatePillLabel('projects', 'All Projects', document.getElementById('popoverProjects'));
}
}
function revertTeamsChoices() {
if (!teamsChoices) return;
rebuildChoicesSelection(teamsChoices, pending.teams);
}
// --- Rebuild a Choices.js widget's selection from a list of IDs ---
function rebuildChoicesSelection(instance, ids) {
// Choices.js API: removeActiveItems() wipes, setChoiceByValue() selects
instance.removeActiveItems();
var idStrs = ids.map(String);
if (idStrs.length > 0) {
instance.setChoiceByValue(idStrs);
}
}
// --- Update a pill's label text based on selected options ---
function updatePillLabel(key, allText, selectEl) {
var ids = (key === 'projects') ? pending.projects : pending.teams;
if (ids.length === 0) {
labels[key].textContent = allText;
return;
}
var names = ids.map(function(id) {
var opt = selectEl.querySelector('option[value="' + id + '"]');
return opt ? opt.textContent : String(id);
});
if (names.length === 1) labels[key].textContent = names[0];
else if (names.length === 2) labels[key].textContent = names.join(', ');
else labels[key].textContent = names[0] + ' + ' + (names.length - 1) + ' more';
}
// --- Apply cross-filter: hide invalid options in the just-opened popover ---
function applyCrossFilter(justOpened) {
// No-op if Choices.js instances aren't ready yet
if (justOpened === 'projects' && projectsChoices) {
// Hide projects that aren't in teamToProjects for any selected team
if (pending.teams.length === 0) return; // no constraint
var validPids = new Set();
pending.teams.forEach(function(tid) {
if (teamToProjects[tid]) {
teamToProjects[tid].forEach(function(pid) { validPids.add(pid); });
}
});
// We can't hide Choices.js options easily without rebuilding the list.
// Simpler approach: disable invalid options visually via the underlying <select>
var sel = document.getElementById('popoverProjects');
Array.from(sel.options).forEach(function(opt) {
var pid = parseInt(opt.value, 10);
opt.disabled = !validPids.has(pid) && !pending.projects.includes(pid);
});
// Refresh Choices.js to reflect the disabled state
projectsChoices.destroy();
projectsChoices = new Choices(sel, {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All projects (leave empty for all)',
});
}
if (justOpened === 'teams' && teamsChoices) {
if (pending.projects.length === 0) return;
var validTids = new Set();
pending.projects.forEach(function(pid) {
if (projectToTeams[pid]) {
projectToTeams[pid].forEach(function(tid) { validTids.add(tid); });
}
});
var selT = document.getElementById('popoverTeams');
Array.from(selT.options).forEach(function(opt) {
var tid = parseInt(opt.value, 10);
opt.disabled = !validTids.has(tid) && !pending.teams.includes(tid);
});
teamsChoices.destroy();
teamsChoices = new Choices(selT, {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All teams (leave empty for all)',
});
}
}
// === DIRTY STATE ===
function isDateDirty() {
return pending.dateMode !== urlDate.mode ||
pending.fromMonth !== urlDate.fromMonth ||
pending.toMonth !== urlDate.toMonth ||
pending.startDate !== urlDate.startDate ||
pending.endDate !== urlDate.endDate;
}
function isProjectsDirty() {
return !sameIntSet(pending.projects, urlProjects);
}
function isTeamsDirty() {
return !sameIntSet(pending.teams, urlTeams);
}
function sameIntSet(a, b) {
if (a.length !== b.length) return false;
var sA = new Set(a), sB = new Set(b);
for (var v of sA) { if (!sB.has(v)) return false; }
return true;
}
function updateAllPillsDirty() {
pills.date.classList.toggle('filter-pill--dirty', isDateDirty());
pills.projects.classList.toggle('filter-pill--dirty', isProjectsDirty());
pills.teams.classList.toggle('filter-pill--dirty', isTeamsDirty());
var anyDirty = isDateDirty() || isProjectsDirty() || isTeamsDirty();
applyGroup.hidden = !anyDirty;
}
// === APPLY: rebuild querystring + navigate ===
applyBtn.addEventListener('click', function() {
var params = new URLSearchParams();
if (pending.dateMode === 'month') {
if (pending.fromMonth) params.append('from_month', pending.fromMonth);
if (pending.toMonth) params.append('to_month', pending.toMonth);
} else {
if (pending.startDate) params.append('start_date', pending.startDate);
if (pending.endDate) params.append('end_date', pending.endDate);
}
pending.projects.forEach(function(pid) { params.append('project', pid); });
pending.teams.forEach(function(tid) { params.append('team', tid); });
window.location = window.location.pathname + '?' + params.toString();
});
// === RESET: revert to URL state ===
resetBtn.addEventListener('click', function() {
pending.dateMode = urlDate.mode;
pending.fromMonth = urlDate.fromMonth;
pending.toMonth = urlDate.toMonth;
pending.startDate = urlDate.startDate;
pending.endDate = urlDate.endDate;
pending.projects = urlProjects.slice();
pending.teams = urlTeams.slice();
revertDateInputs();
if (projectsChoices) rebuildChoicesSelection(projectsChoices, pending.projects);
if (teamsChoices) rebuildChoicesSelection(teamsChoices, pending.teams);
// Reset pill labels
// For date: use the rendered URL-driven text (already in DOM); easier to just reload
// But to avoid reload, we rebuild labels from scratch:
if (pending.dateMode === 'month' && pending.fromMonth && pending.toMonth) {
labels.date.textContent = humanMonth(pending.fromMonth) + ' – ' + humanMonth(pending.toMonth);
} else if (pending.dateMode === 'custom' && pending.startDate && pending.endDate) {
labels.date.textContent = humanDate(pending.startDate) + ' – ' + humanDate(pending.endDate);
}
updatePillLabel('projects', 'All Projects', document.getElementById('popoverProjects'));
updatePillLabel('teams', 'All Teams', document.getElementById('popoverTeams'));
updateAllPillsDirty();
});
// === TOAST ===
function showToast(message) {
var toast = document.createElement('div');
toast.className = 'filter-toast';
toast.textContent = message; // textContent: XSS-safe
toastContainer.appendChild(toast);
setTimeout(function() {
toast.classList.add('filter-toast--exiting');
setTimeout(function() {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 220);
}, 4000);
}
// === LABEL HELPERS ===
function humanMonth(ym) {
// ym is "YYYY-MM"; render as "Mon YYYY"
if (!ym || ym.length < 7) return ym || '';
var parts = ym.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
function humanDate(ymd) {
// ymd is "YYYY-MM-DD"; render as "D Mon YYYY"
if (!ymd || ymd.length < 10) return ymd || '';
var parts = ymd.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return parseInt(parts[2], 10) + ' ' + months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
function projectNameById(pid) {
var opt = document.getElementById('popoverProjects').querySelector('option[value="' + pid + '"]');
return opt ? opt.textContent : 'Project ' + pid;
}
function teamNameById(tid) {
var opt = document.getElementById('popoverTeams').querySelector('option[value="' + tid + '"]');
return opt ? opt.textContent : 'Team ' + tid;
}
// --- Initial render ---
updateAllPillsDirty();
});
</script>
Step 4.3: Check syntax
USE_SQLITE=true DJANGO_DEBUG=true python manage.py check
Expected: only the staticfiles.W004 warning.
Step 4.4: Commit
git add core/templates/core/report.html
git commit -m "$(cat <<'EOF'
JS: pill-popover interactive module for inline filters
One scoped IIFE that wires up the three filter pills into an
interactive, state-managed UI:
- Click pill -> open popover (lazy-inits Choices.js on first open)
- Click outside / Esc / other pill -> close
- OK commits popover's local edits into pending state; dirty pills
get orange outline + pulsing dot; Apply button slides in at the
right end of the strip
- Cross-filter: picking projects auto-removes now-invalid teams
with toast notice ("Removed Team X — no logs on selected projects"),
and vice versa. Scope = entire history.
- Apply -> rebuilds querystring from pending state + navigates
(full page reload, same URL scheme as the retired modal)
- Reset -> reverts all pills to URL-current values
XSS-safe throughout: textContent and createElement; no innerHTML with
user data. Matches the pattern in base.html's work-log-payroll modal
from the previous feature.
Graceful fallback: if Choices.js CDN fails to load, the module bails
early with a console warning; native <select multiple> still works
inside the popovers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
🛑 CHECKPOINT 1 — all pill interactions demoable
Konrad should open /report/ in the browser (hard refresh) and walk through:
- Click the date pill → popover opens with month/custom toggle + pickers
- Change from-month → click OK → pill shows new range, Apply button appears, pill has orange dirty outline
- Click Apply → URL updates → report re-renders with new filters
- Click projects pill → popover with Choices.js multi-select → pick 2 projects → OK → pill updates to "Project A + 1 more"
- Click teams pill → popover → if a project is already selected, teams that haven't worked on it are disabled; pick a valid team → OK
- Intentionally: pick a project, then add a team that doesn't match → on OK, that team gets auto-removed with a toast
- Click the × on a pill (existing behaviour) → filter clears via full page reload
- Click Reset when dirty → all pills revert, Apply disappears
- Esc key → any open popover closes
- Modal still works via the "Generate Report" button on the dashboard + "New Report" button on the report page (Task 5 retires them)
Await approval before Task 5 (modal retirement — destructive).
Task 5: Retire the modal
Files:
- Modify:
core/templates/core/index.html(line 187 — change button to plain link; line 605 — remove include) - Modify:
core/templates/core/report.html(lines 20-23 delete header "New Report" button; lines 387-389 delete bottom "New Report" button; line 400 remove include) - Delete:
core/templates/core/_report_config_modal.html - Modify:
core/views.py(lines 434-435 remove fromindex()context; lines 2395-2404 remove fromgenerate_reportcontext)
Step 5.1: Update core/templates/core/index.html
Find around line 187 (Quick Actions "Generate Report"):
<a href="#" class="quick-action" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
<i class="fas fa-file-alt"></i>
<span>Generate Report</span>
</a>
Replace with a plain link to /report/ pre-loaded with the current month:
{# Plain link: pills on the report page are the new-report interface. #}
<a href="{% url 'generate_report' %}?from_month={% now 'Y-m' %}&to_month={% now 'Y-m' %}" class="quick-action">
<i class="fas fa-file-alt"></i>
<span>Generate Report</span>
</a>
Then find line 605 and delete the {% include 'core/_report_config_modal.html' %} line entirely.
Step 5.2: Update core/templates/core/report.html
Delete the header "New Report" button block (around lines 20-23):
<!-- New Report: opens the same config modal as the Dashboard -->
<button type="button" class="btn btn-primary shadow-sm" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
<i class="fas fa-plus me-1"></i>New Report
</button>
Delete the bottom "New Report" button block (around lines 387-389):
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
<i class="fas fa-plus me-1"></i>New Report
</button>
Delete the include line (around line 400):
{% include 'core/_report_config_modal.html' %}
(Also delete any comment block immediately above that include, if it only refers to the modal.)
Step 5.3: Delete the modal template file
git rm core/templates/core/_report_config_modal.html
Step 5.4: Update core/views.py — remove selected_*_ids threading
In index() (around line 434):
Find:
'selected_project_ids': [],
'selected_team_ids': [],
Delete these two lines.
In generate_report (around line 2395-2404):
Find the whole block:
# Pass projects and teams so the "New Report" modal's dropdowns can
# populate (same lists the Dashboard modal uses)
context['projects'] = Project.objects.all().order_by('name')
context['teams'] = Team.objects.all().order_by('name')
# For the modal's <select multiple> pre-selection: stringify the IDs so
# the template's `{% if p.id|stringformat:"s" in selected_project_ids %}`
# comparison works (Django templates compare strings to strings).
context['selected_project_ids'] = [str(p) for p in (project_ids or [])]
context['selected_team_ids'] = [str(t) for t in (team_ids or [])]
Keep the projects / teams context keys (the pills' popovers still use them to render <option> lists). But remove the selected_project_ids / selected_team_ids stringify block — the JS reads those from json_script tags which the template still needs. Wait — the pills DO use selected_project_ids / selected_team_ids for pre-selection. So these stay!
Actually, re-read Task 2's markup: the popover <option> tags use {% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}, and the json_script tag emits {{ selected_project_ids|json_script:"urlSelectedProjectIds" }}. Both references are in Task 2's markup — so these context keys stay.
Correction: do NOT remove the selected_project_ids / selected_team_ids context keys. The inline-filters feature still uses them.
Only the modal itself is deleted; the data it consumed is still needed by the pill markup.
So Step 5.4 reduces to: no change to core/views.py. All backend context stays.
Step 5.5: Run tests + manage.py check
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 0 2>&1 | tail -5
USE_SQLITE=true DJANGO_DEBUG=true python manage.py check
Expected: Ran 44 tests ... OK; only the node_modules warning.
Step 5.6: Verify no dangling references to the deleted modal
grep -rn "reportConfigModal\|_report_config_modal" core/templates/ core/views.py
Expected: zero hits. If any remain, delete them.
Step 5.7: Commit
git add core/templates/core/index.html core/templates/core/report.html
git commit -m "$(cat <<'EOF'
Retire _report_config_modal.html — pills are the new-report interface
- Dashboard 'Generate Report' button: modal trigger -> plain link
to /report/?from_month=<current>&to_month=<current>
- Report page 'New Report' buttons (both header + bottom action bar):
deleted (the pills replace them)
- _report_config_modal.html: deleted
- No backend changes — selected_project_ids / selected_team_ids
context keys are still used by the pill popovers for pre-selection
(just not by the retired modal)
grep -rn 'reportConfigModal' core/templates/ core/views.py returns 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 6: Final QA + mark shipped
Files:
- Modify:
docs/plans/2026-04-23-inline-filters-design.md(append "Shipped" block)
Step 6.1: Manual QA matrix
As admin, walk through:
| Flow | Expected |
|---|---|
/report/ no filters |
Pills show current-month range + "All Projects" + "All Teams"; no Apply button; no × buttons |
| Click date pill → change month → OK → Apply | URL has ?from_month=...&to_month=...; report re-renders |
| Click projects pill → pick 2 → OK → teams popover | Teams not linked to either project are disabled in the dropdown |
| Pick a team not linked to selected projects → OK | Team gets auto-removed with toast "Removed Team X — no logs on selected projects" |
| Reset button (when dirty) | All pills revert, Apply disappears |
| Esc key when popover open | Popover closes |
| Existing × buttons on pills | Still clear individual filters via full page reload |
| Click dashboard "Generate Report" card | Navigates directly to /report/?from_month=<current>&to_month=<current> — no modal |
grep -rn 'reportConfigModal' core/templates/ |
Zero hits |
Supervisor user hits /report/ |
403 (unchanged) |
| PDF download with any filter | Mirrors current filter state (unchanged) |
| Phone viewport | Popovers dock to bottom of viewport full-width |
Step 6.2: Full test run
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 0 2>&1 | tail -5
Expected: Ran 44 tests ... OK.
Step 6.3: Django system check + migrations dry-run
USE_SQLITE=true DJANGO_DEBUG=true python manage.py check
USE_SQLITE=true DJANGO_DEBUG=true python manage.py makemigrations --dry-run
Expected: only staticfiles.W004; "No changes detected".
Step 6.4: Append "Shipped" block to the design doc
Append to docs/plans/2026-04-23-inline-filters-design.md:
---
## Shipped — 23 Apr 2026
**Commits:** 30d0991 (design) through the Task 6 shipped commit.
**Plan:** `docs/plans/2026-04-23-inline-filters-plan.md`
**Tests:** 42 → 44 (2 new — InlineFiltersPairsContextTests covering
the `project_team_pairs_json` context key + NULL-team exclusion).
**QA outcome:** 44/44 tests pass. `manage.py check` clean.
`makemigrations --dry-run` reports no changes. All 10 manual QA
flows green. Cross-filter auto-remove + toast behaviour verified.
_report_config_modal.html fully retired; grep confirms no dangling
references.
**Deferred / out of scope (revisit if requested):**
- AJAX partial re-render (still full page reload on Apply)
- "Save this filter set" / named filter presets
- Permissive cross-filter variant (strict was chosen)
- Date-range-scoped cross-filter (entire history was chosen)
**Notable decisions made during implementation:**
- Modal `selected_project_ids` / `selected_team_ids` context keys
were KEPT (not removed as the design initially suggested) because
the pill popovers still use them for pre-selecting the Choices.js
`<option>` tags and for URL-diff initialisation via json_script.
- Choices.js re-init on cross-filter change (destroy + reinstantiate)
is wasteful but the simplest way to reflect `<option disabled>`
changes; acceptable at FoxFitt's scale (≤10 projects, ≤10 teams).
Step 6.5: Commit
git add docs/plans/2026-04-23-inline-filters-design.md
git commit -m "Docs: mark Inline Filters feature as shipped (23 Apr 2026)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
🛑 CHECKPOINT 2 — ship
Final demo: all pills work, modal gone, tests green, PDF still respects filters.
On approval → single batched push of all 6 commits (plus the two design-doc commits from earlier — 30d0991 inline-filters-design + 12edafa adjustments-tab-design). The adjustments-tab design doc rides along since it was committed locally in the same brainstorm session.
Gemini deploy prompt (CSS change → collectstatic mandatory):
git fetch github && git pull github ai-dev && \
python3 manage.py collectstatic --noinput && \
sudo systemctl restart django-dev.service
Out of scope (not in this plan)
- Inline filter persistence across browser tabs (a URL-sharing improvement; URLs already bookmarkable)
- Filter-chip sort order preference
- Keyboard navigation between pills (Tab works via native button focus)
- Adjustments tab (separate plan after this ships)
Plan complete and saved to docs/plans/2026-04-23-inline-filters-plan.md. Two execution options:
1. Subagent-Driven (this session) — I dispatch a fresh subagent per task, review between tasks, fast iteration.
2. Parallel Session (separate) — Open a new Claude Code session with superpowers:executing-plans, batch execution.
Which approach?