38686-vm/docs/plans/2026-04-23-inline-filters-design.md
Konrad du Plessis 54080a3e0a docs(inline-filters): append Shipped 2026-04-23 block to design doc
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>
2026-04-23 14:40:13 +02:00

17 KiB
Raw Blame History

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).

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.htmldelete (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:

# 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):

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):

  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.


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_populated
    • test_pairs_excludes_null_project_or_team
    • test_pairs_renders_as_valid_json_in_template — end-to-end HTML check for the double-encoding bug
    • test_pickers_and_pairs_are_date_scoped — out-of-range entries absent from picker and pair map
    • test_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.