Design: inline filters on report page (+ cross-filter)

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>
This commit is contained in:
Konrad du Plessis 2026-04-23 02:06:49 +02:00
parent 3dab09cea3
commit 30d0991956

View File

@ -0,0 +1,237 @@
# 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 from `WorkLog` is 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-unused `selected_project_ids` / `selected_team_ids` keys 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_context` does ~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` — remove `context['selected_project_ids']` / `selected_team_ids` from `index()` and `generate_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 `WorkLog` on 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**:
```python
# 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_NULL` in the DB) → already filtered out by the `filter(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):
```js
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:
```python
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):
1. `/report/` → pills show current filters; no Apply button (clean state)
2. Click date pill → popover opens; change month; OK → pill dirty; Apply visible
3. Click Apply → URL updates; re-render; clean state
4. Click project pill → popover; pick 2 projects; OK → dirty
5. Team pill options now show only teams that worked on those projects
6. Select a team that wasn't in the project's history → warning toast, not selected
7. Click × on project pill → filter clears; Apply button visible → click → URL drops projects
8. Click pill, edit, click Cancel → no URL change; pill reverts
9. Esc key closes popover
10. Dashboard "Generate Report" → lands on `/report/?from_month=current&to_month=current` as plain link (no modal)
## 7. Scope estimate
- `core/views.py`: ~+5 lines (`project_team_pairs_json` in `generate_report`; remove `selected_*_ids` in both `index` and `generate_report`) — net roughly neutral
- `core/templates/core/index.html`: -1 line (remove modal include), change one button to a link
- `core/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.