Brainstorm output — Konrad's Checkpoint-3 UX request, now spec'd. Key decisions: - Pill-as-dropdown: existing filter pills become clickable popovers - Explicit Apply button; hidden when no pending changes - Modal retired; dashboard 'Generate Report' becomes a plain link - Bidirectional cross-filter: selecting a project hides teams that haven't worked on it (and vice versa). Strict behaviour with auto-removal of now-invalid selections + toast notice. - URL contract unchanged; PDF download unchanged (still uses current querystring). One new context key (project_team_pairs_json) serialises distinct (project_id, team_id) pairs from WorkLog for client-side cross-filter. ~80 CSS lines for popover + dirty state + toast; ~150 JS lines for one scoped module (createElement + textContent, XSS-safe). Scope: 5-6 focused tasks, 1 checkpoint. Next step: Feature 2 brainstorm (Payroll Adjustments Browser) before handing both to writing-plans. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
Inline Filters on Report Page — Design (23 Apr 2026)
Goal
Replace the modal-based filter form on /report/ with inline, interactive filter pills so Konrad can tweak project/team/date filters without opening a modal every time. Adds cross-filter awareness: selecting a project hides teams that never worked on it (and vice versa).
Origin
Raised by Konrad at Checkpoint 3 of the Executive Report v2 work (just shipped):
"I wonder if the generate should not rather have static filters instead of the popup open up for every report? Easier to add or remove a project or team or change dates instead of having to start from scratch."
And the cross-filter request that came later:
"Is it possible to filter out teams when selecting a project that has not worked on that project?"
Who it's for
Admins (is_staff or is_superuser). Supervisors keep no report access.
Architecture at a glance
- Every filter pill on the report page becomes a clickable dropdown. Click → popover opens directly under that pill → edit → click OK inside the popover → popover closes + pill shows dirty state + Apply button appears at the right end of the pill strip.
- Apply submits to
/report/?<reconstructed querystring>— full page reload, same URL scheme as today. No AJAX. - The Generate Report modal is deleted. The dashboard's "Generate Report" button becomes a plain link to
/report/?from_month=<current>&to_month=<current>. - Cross-filter: a serialised map of
(project_id, team_id)pairs fromWorkLogis passed as JSON to the page. Popovers filter their options based on the other pill's current selection. - Backend code touched: minimal — one new context key (
project_team_pairs_json) and removal of the now-unusedselected_project_ids/selected_team_idskeys the modal needed.
1. Filter trigger — explicit Apply with dirty-state indicator
Chosen over auto-apply-on-change because:
- Admin users care about correctness; brief lag between "change filter" and "see numbers" is fine.
_build_report_contextdoes ~10 aggregation queries; running it on every checkbox tick is wasteful.- Matches the rest of the codebase's pattern (Worker/Team/Project edit pages all use explicit Save).
Refinement: Apply button is hidden when filters are in sync with URL, so it can't be "forgotten" when there are no pending changes.
2. Three interactive pills, one Apply action
┌──────────────────────────┬──────────────────────────────────┬──────────────────────┬──────────┐
│ 📅 Mar 2026 – Apr 2026 ▾ │ 📁 Wilkot Boerdery + 1 more ▾ × │ 👥 All Teams ▾ │ [Apply] │
└──────────────────────────┴──────────────────────────────────┴──────────────────────┴──────────┘
Pill states:
- Closed: current value + ▾ chevron; cursor pointer; tooltip "Click to edit".
- Dirty (has uncommitted changes): accent-orange outline + small pulsing dot; Apply button appears.
- Open: popover expanded; pill background darker to indicate focus.
× button on project and team pills: still clears that filter instantly (shortcut for "reset to All"). Only shown when a filter is active.
2.1 Date pill popover
Click 📅 Mar 2026 – Apr 2026 ▾ → popover opens with same fields as the current modal:
- Date Selection radio toggle: Month(s) / Custom Dates
- From / To month pickers (when Month mode)
- Start Date / End Date date pickers (when Custom mode)
- Cancel / OK buttons
Click OK → popover closes, pill updates to new range in dirty state.
2.2 Projects / Teams pill popover
Click 📁 Wilkot Boerdery + 1 more ▾ → popover with a Choices.js multi-select (the same widget used in the just-retired modal):
- Chip-style selected items at top; typing filters options.
- Option list below the input (cross-filtered — see section 2.5).
- Cancel / OK at the bottom.
Click OK → popover closes, pill text updates. Pill display rules:
- 0 selected: "All Projects" (or "All Teams")
- 1 selected: show the name
- 2 selected: show both comma-joined
- 3+ selected: show first + "+ N more"
2.3 Apply button behaviour
- Hidden when filters match the URL (no dirty state).
- Appears when any pill is dirty.
- Click Apply → submits via
window.location = '/report/?' + querystring. Full page reload. - Cancel button appears alongside Apply when dirty → reverts all pills to URL-current values without submitting.
- Browser back button works normally (each Apply = real URL change).
2.4 Generate Report button → plain link
Before (shipped):
- Dashboard "Quick Actions" card → "Generate Report" button → modal → submit.
- Report page "New Report" button → modal → submit.
After:
- Dashboard "Generate Report" card →
<a>link to/report/?from_month={{ current_month }}&to_month={{ current_month }}. One click → land on report with current month defaults → all filters are pills. - Report page "New Report" button → deleted. Pills are the new-report interface.
Files affected by retirement:
core/templates/core/_report_config_modal.html— delete (no other callers).core/templates/core/index.html— remove{% include 'core/_report_config_modal.html' %}; change "Generate Report" button to plain link.core/templates/core/report.html— remove{% include %}; remove "New Report" button.core/views.py— removecontext['selected_project_ids']/selected_team_idsfromindex()andgenerate_report(only existed for the modal's pre-selection).
2.5 Cross-filter (project ↔ team)
When a project is selected, the Teams popover shows only teams that have worked on at least one of the selected projects. Symmetric: selecting teams filters the Projects popover.
Semantics:
- Union across selections: 2 projects selected → teams that have worked on EITHER project appear.
- All (no cross-filter): if no project is selected, all teams appear in the Teams popover. Same for projects.
- Scope = entire history: "has worked on" means "has at least one
WorkLogon that project, ever". NOT filtered by the report's date range — date is about data shown; cross-filter is about data possible.
Auto-removal of invalid selections: If you have Team Beta selected, then add Project Wilkot, and Beta has never worked on Wilkot → Beta is auto-removed from the team pill with a brief inline notice ("Team Beta removed — no logs on selected projects"). Toast-style, auto-dismisses after 4 seconds.
Data source — one new context key:
# In generate_report view, serialise distinct (project_id, team_id) pairs as JSON.
# The frontend uses this to filter dropdown options + auto-remove invalid selections.
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)
Injected into the template via {{ project_team_pairs_json|json_script:"projectTeamPairs" }} (Django's safe-JSON pattern, already used for team_workers_map_json on the payroll dashboard per CLAUDE.md).
Frontend JS (inside the pill-popover module):
- When a popover opens, determine which options to hide based on the OTHER pill's current selection.
- When a popover "OK" fires, diff the new selection against pairs; remove invalid entries from the other pill; show a toast.
- Choices.js supports programmatically setting/hiding options via its API.
Edge cases:
- Team with zero work logs ever (e.g. just created) → invisible when any project is selected. Correct behaviour.
- Deleted project/team references (
SET_NULLin the DB) → already filtered out by thefilter(project__isnull=False, team__isnull=False)clause. - If the URL specifies a team that isn't valid for the current project selection (shouldn't happen in practice, but if someone edits the URL) → the team still renders on the report's selected-period data (backend doesn't know about cross-filter); the pill shows it normally; next Apply will clean it up.
3. PDF stays identical
The "Download PDF" button uses ?{{ query_string }} — whatever filters are in the URL flow into the PDF. No changes to generate_report_pdf or the PDF template.
4. CSS — new rules
In static/css/custom.css:
.filter-pill--editable— pointer cursor, hover tint, ▾ chevron alignment.filter-pill--dirty— accent-orange outline, small pulsing dot (subtle).filter-popover— absolute-positioned below the pill,--bg-card+--border-default+ shadow (same tokens as work log payroll modal).filter-popover__footer— right-aligned Cancel + OK buttons.apply-filters-btn— primary button, slides in from right edge when dirty.filter-toast— small accent-orange toast at the top of the report, auto-dismisses
~80 lines.
5. JS — one scoped module
Inline <script> in report.html. Structure (~150 lines):
document.addEventListener('DOMContentLoaded', function() {
// Parse the pairs map from json_script
const pairs = JSON.parse(document.getElementById('projectTeamPairs').textContent);
// Build lookup indices: project_id -> Set(team_id), team_id -> Set(project_id)
const teamsByProject = buildIndex(pairs, 'project_id', 'team_id');
const projectsByTeam = buildIndex(pairs, 'team_id', 'project_id');
// State
const state = {
urlProjects: [...], urlTeams: [...], urlDateRange: {...},
pendingProjects: [...], pendingTeams: [...], pendingDateRange: {...},
};
// Pill click handlers → open popovers
// Popover OK handlers → update pending state + re-render pill text + trigger cross-filter + maybe show toast
// Apply button → navigate to /report/?querystring
// Cancel button → reset pending to url state
// Esc key → close open popover
});
Uses createElement + textContent (XSS-safe; matches work log payroll modal pattern). No innerHTML.
6. Testing
No new backend tests — URL contract unchanged, context contract unchanged, PDF contract unchanged. All 42 existing tests keep passing.
One new test for the cross-filter context key:
class ProjectTeamPairsTests(TestCase):
def test_pairs_context_key_populated(self):
"""Report view exposes (project_id, team_id) pairs for cross-filter JS."""
# Assert generate_report renders with project_team_pairs_json containing
# the (p, t) pairs from created work logs.
Manual QA checklist (10 flows):
/report/→ pills show current filters; no Apply button (clean state)- Click date pill → popover opens; change month; OK → pill dirty; Apply visible
- Click Apply → URL updates; re-render; clean state
- Click project pill → popover; pick 2 projects; OK → dirty
- Team pill options now show only teams that worked on those projects
- Select a team that wasn't in the project's history → warning toast, not selected
- Click × on project pill → filter clears; Apply button visible → click → URL drops projects
- Click pill, edit, click Cancel → no URL change; pill reverts
- Esc key closes popover
- Dashboard "Generate Report" → lands on
/report/?from_month=current&to_month=currentas plain link (no modal)
7. Scope estimate
core/views.py: ~+5 lines (project_team_pairs_jsoningenerate_report; removeselected_*_idsin bothindexandgenerate_report) — net roughly neutralcore/templates/core/index.html: -1 line (remove modal include), change one button to a linkcore/templates/core/report.html: ~+30 lines (new pill-popover markup + json_script) / -5 lines (remove modal include + "New Report" button)- Delete
core/templates/core/_report_config_modal.html(-140 lines) static/css/custom.css: +80 lines- JS (inline in report.html): ~+150 lines
- Tests: 1 new test class, ~20 lines
- Net: ~+170 / -160 across 4 files + 1 deletion
About 5-6 focused tasks, 1 checkpoint after cross-filter behaviour works end-to-end.
8. Out of scope (YAGNI)
- AJAX partial re-render — Apply still triggers a full page reload. SSR pattern matches the rest of the app.
- "Save this filter set" feature — URLs are bookmarkable already.
- Keyboard shortcuts for pills — Tab/Enter work via native button/link behaviour.
- Undo stack — browser back button is sufficient.
- Permissive cross-filter (Option B — greyed-out but still selectable) — strict cross-filter is the clearest UX for FoxFitt's admin use case.
- Date-range scoped cross-filter — "has worked on this project, ever" is simpler to explain than "within this report's date range".
9. Rollback plan
Feature is template-only (no backend behaviour change beyond one new serialised context key). Rollback = revert the commit. No data, schema, or migration impact.
Next step
Hand off to superpowers:writing-plans after Feature 2 (Payroll Adjustments Browser) has been brainstormed and its design doc committed. Both features can then become either one combined plan or two separate plans — user's choice.