Captures the 6 deviations from the original design (each with driving feedback + commit SHA), the 5 non-design polish commits, and the test delta (42 → 47 passing). Keeps the design doc as the first-read for understanding the feature while preserving decision history from the Checkpoint-1 iteration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 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.
10. Shipped — 2026-04-23
Implementation and Checkpoint-1 UX approval complete. Everything on this branch before the final push. Sections below list what deviated from the design above, with the driving feedback + commit SHAs.
Deviations from the original design
| # | Original | Shipped | Why |
|---|---|---|---|
| 1 | Popover OK sets pending state; global Apply button commits when any pill is dirty (§2, §2.3) | No global Apply. Each popover's OK rebuilds the URL and navigates immediately. | Konrad on CP-1: Apply button was far-right + easy to miss, and the dirty-diff on multi-selects was unreliable. ffb3ef6 |
| 2 | Date pill uses From / To pickers, both required (§2.1) | Until is the always-filled anchor. From (optional) blank = single-month (JS submits from_month = to_month). Visual order: From (optional) left, Until right, English reading order. |
Konrad on CP-1: "Until must be auto-filled, From optional." 71f8558, 3fa3cdc |
| 3 | Cross-filter scope = entire history (§2.5 Semantics) | Cross-filter + picker lists scoped to the currently-selected date range. URL-selected IDs always unioned in so they never vanish. | Konrad on CP-1: "Filter out teams and projects that has no log for any of the dates chosen." 71f8558 |
| 4 | Cross-filter auto-removes invalid selections and shows a toast (§2.5) | Read-time only: disable invalid options on popover open. No runtime removal, no toast (the next OK submits, so the server handles validation). | Side-effect of (1) — with auto-submit-on-OK there's no pending state to patch. ffb3ef6 |
| 5 | Dashboard "Generate Report" → ?from_month=current&to_month=current (§2.4) |
Same, implemented as {% now 'Y-m' %} template tag. |
No deviation, just recording. 1d00a3a |
| 6 | Not in scope | "Last Activity" column added to All Time Projects table. | Konrad on CP-1 surprise ask. Extends _build_report_context with Max(WorkLog.date); mirrored in PDF. f6975bf |
Polish not in the original design
| Commit | What |
|---|---|
5c4162d |
Fixed double-encoded project_team_pairs_json — the view was calling json.dumps(pairs) AND the template's |json_script filter was re-serialising. Now passes raw list. Regression test added. |
c1937cd |
Tooltip on Until "(ⓘ Single month select)" + shrink (optional) helper to 0.6rem |
0bbf2ca |
Popover border → 2px accent-orange + three-layer shadow so it visually detaches from the report body. Separate light-theme shadow palette. |
dcc0eeb |
Choices.js dropdown → position: static scoped to .filter-popover so it flows inline (dropdown was being clipped by overflow: hidden and not contributing to the body's scrollHeight). Specificity trick: mirrored Choices.js's own [aria-expanded] selector to win the source-order tiebreaker. |
c26d2e0 |
Auto-open Choices dropdown on pill click via showDropdown(true) (dropdown was opening hidden until user clicked the input). Helper text colour swapped from opacity: 0.75 over Bootstrap's .form-text default to var(--text-tertiary) — was unreadable on the dark card. |
Tests
- 42 → 47 passing, +5 locked-in behaviours:
InlineFiltersPairsContextTests.test_pairs_context_key_populatedtest_pairs_excludes_null_project_or_teamtest_pairs_renders_as_valid_json_in_template— end-to-end HTML check for the double-encoding bugtest_pickers_and_pairs_are_date_scoped— out-of-range entries absent from picker and pair maptest_url_selected_projects_survive_even_out_of_range— URL selection unioned into picker list
Total churn
17 commits on ai-dev (prior to push). Across the feature: template -375 net, CSS -65 net (rewritten smaller), view +65 net, tests +130 net. Modal partial deleted (-160). JS module -200 after collapsing the pending/dirty/Apply model.