feat(report): Until-primary date picker + date-scoped project/team lists

Checkpoint 1 second-round UX feedback (Konrad, 2026-04-23):

(1) "The until option must be auto filled (and used for single month) and
    the from date must be optional — this makes more sense and less clicks
    if the user wants to eg check the last 3 months."

    → Inverted the month pickers. "Until" is now the always-filled anchor
    (defaults to URL to_month, falling back to the current YYYY-MM when no
    filter is set). "From (optional)" is the disclosure; blank = single
    month (JS submits from_month = to_month). Visual order swapped so
    Until sits on the left as the primary action. Matches the admin mental
    model: "I want data ending now, maybe going back N months."

(2) "Is it possible to show only teams and projects that has transactions
    within the selected dates — filter out teams and projects that has no
    log for any of the dates chosen?"

    → The pill pickers AND the cross-filter (project_team_pairs_json) are
    now scoped to the current date range. A team/project with zero logs in
    the window doesn't clutter the lists. The (project_id, team_id) pair
    map follows the same rule — cross-filter disables options that never
    paired inside THIS window.

    Guarantee: entries that are currently in the URL's ?project= / ?team=
    selection are always unioned back in, so the user's own picks can
    never disappear from the list even when they'd otherwise be out of
    scope (e.g. picking a project, then narrowing the date range to a
    period with no logs on that project).

Design-doc note at lines 108-112 of 2026-04-23-inline-filters-design.md
originally said "Scope = entire history" — Konrad's real-usage feedback
overrides that decision. Will be recorded in the Task 6 "Shipped" block.

Tests: two new ones lock in the behaviour —
  - test_pickers_and_pairs_are_date_scoped: out-of-range project/team
    absent from both the picker lists and the pair map
  - test_url_selected_projects_survive_even_out_of_range: URL selection
    unioned in regardless of date window
Plus existing 3 tests still green. 47/47 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-23 12:25:45 +02:00
parent ffb3ef6800
commit 71f8558ff5
3 changed files with 127 additions and 35 deletions

View File

@ -62,20 +62,20 @@
</label>
</div>
{# --- Month-mode pickers --- #}
{# "To" is optional: leave it blank for a single-month report. #}
{# If blank on OK, the JS submits `to_month = from_month` so the #}
{# report window collapses to just that month. Reduces the common #}
{# "I want only this month" flow from two picks to one. #}
{# "Until" is the anchor: always filled (URL to_month or current #}
{# month). "From" is optional: blank means single-month report #}
{# (JS submits from_month = to_month). Reduces the common "this #}
{# month" and "up to now, going back N months" flows. #}
<div class="row g-2" id="popoverMonthFields">
<div class="col-6">
<label class="form-label small">Month</label>
<input type="month" id="popoverFromMonth" class="form-control form-control-sm">
<label class="form-label small">Until</label>
<input type="month" id="popoverToMonth" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label small">
Until <span class="text-muted">(optional)</span>
From <span class="text-muted">(optional)</span>
</label>
<input type="month" id="popoverToMonth" class="form-control form-control-sm">
<input type="month" id="popoverFromMonth" class="form-control form-control-sm">
<div class="form-text small" style="font-size: 0.72rem; opacity: 0.75;">
Leave blank for a single month
</div>
@ -613,10 +613,17 @@ document.addEventListener('DOMContentLoaded', function() {
var urlStartDate = qs.get('start_date') || '';
var urlEndDate = qs.get('end_date') || '';
fromMonthInput.value = urlFromMonth;
// Single-month URLs (from == to) show the "To" picker blank so the UI
// naturally reflects the optional-until semantics. Range URLs show both.
toMonthInput.value = (urlFromMonth && urlFromMonth === urlToMonth) ? '' : urlToMonth;
// "Until" is the anchor — always filled. Falls back to current month
// (YYYY-MM) when the URL has no to_month (e.g. no filters yet).
function currentYearMonth() {
var d = new Date();
var m = String(d.getMonth() + 1).padStart(2, '0');
return d.getFullYear() + '-' + m;
}
toMonthInput.value = urlToMonth || currentYearMonth();
// "From" is optional: blank signals single-month mode. Range URLs
// (from != to) fill it; single-month URLs (from == to) leave it blank.
fromMonthInput.value = (urlFromMonth && urlFromMonth === urlToMonth) ? '' : urlFromMonth;
startDateInput.value = urlStartDate;
endDateInput.value = urlEndDate;
@ -738,9 +745,11 @@ document.addEventListener('DOMContentLoaded', function() {
if (startDateInput.value) params.set('start_date', startDateInput.value);
if (endDateInput.value) params.set('end_date', endDateInput.value);
} else {
var from = fromMonthInput.value;
// Blank "To" = single-month report (use From for both ends).
var to = toMonthInput.value || from;
// Month mode: "Until" is the anchor (always required; defaults
// to current month in the picker); "From" is optional (blank =
// single-month, so use Until for both ends).
var to = toMonthInput.value || currentYearMonth();
var from = fromMonthInput.value || to;
if (from) params.set('from_month', from);
if (to) params.set('to_month', to);
}

View File

@ -847,3 +847,62 @@ class InlineFiltersPairsContextTests(TestCase):
self.assertIsInstance(entry, dict)
self.assertIn('project_id', entry)
self.assertIn('team_id', entry)
def test_pickers_and_pairs_are_date_scoped(self):
"""Checkpoint-1 refinement: projects/teams lists + the pair map
include only entries with WorkLog activity INSIDE the selected
date range NOT entire-history entries. Entries that are in the
URL's `?project=` or `?team=` selection are always preserved,
though, so the user's pick can never vanish."""
# Add a third project/team that ONLY worked outside the report window
out_project = Project.objects.create(name='P-out')
out_team = Team.objects.create(name='T-out', supervisor=self.admin)
out_log = WorkLog.objects.create(
date=datetime.date(2026, 1, 15), # outside March window
project=out_project, team=out_team, supervisor=self.admin,
)
out_log.workers.add(self.w)
self.client.login(username='admin-if', password='pass')
# Request only March 2026 — Jan logs should be excluded
resp = self.client.get(
reverse('generate_report') + '?from_month=2026-03&to_month=2026-03'
)
self.assertEqual(resp.status_code, 200)
# Picker lists: out-of-range project + team should NOT appear
project_ids = {p.id for p in resp.context['projects']}
team_ids = {t.id for t in resp.context['teams']}
self.assertIn(self.p1.id, project_ids)
self.assertIn(self.p2.id, project_ids)
self.assertNotIn(out_project.id, project_ids,
'Out-of-range project must not appear in the date-scoped list')
self.assertIn(self.t1.id, team_ids)
self.assertIn(self.t2.id, team_ids)
self.assertNotIn(out_team.id, team_ids,
'Out-of-range team must not appear in the date-scoped list')
# Pair map: must also be date-scoped
pair_set = {(p['project_id'], p['team_id'])
for p in resp.context['project_team_pairs_json']}
self.assertNotIn((out_project.id, out_team.id), pair_set,
'Out-of-range pair must not appear in the cross-filter map')
def test_url_selected_projects_survive_even_out_of_range(self):
"""A project explicitly in the URL's ?project= selection must
remain in the picker list even if it has no logs in the current
date range otherwise the user couldn't see (or deselect) their
own pick."""
out_project = Project.objects.create(name='P-out')
# Never logs anything in any date range
self.client.login(username='admin-if', password='pass')
resp = self.client.get(
reverse('generate_report')
+ '?from_month=2026-03&to_month=2026-03'
+ f'&project={out_project.id}'
)
self.assertEqual(resp.status_code, 200)
project_ids = {p.id for p in resp.context['projects']}
self.assertIn(out_project.id, project_ids,
'URL-selected project must survive the date-scope filter')

View File

@ -2393,32 +2393,56 @@ def generate_report(request):
return qd.urlencode()
context['query_string_without_project'] = _qs_without('project')
context['query_string_without_team'] = _qs_without('team')
# === 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.
# === Date-scoped pickers + cross-filter ===
# Admin UX decision (Konrad, 2026-04-23 Checkpoint 1 feedback):
# The project/team pills should only show entries that actually have
# WorkLog activity within the currently-selected date range. Same for
# the (project_id, team_id) pair map that powers the cross-filter.
# Rationale: "show me teams I'm actually looking at right now," not
# "every team that ever existed."
#
# Guarantee: a project or team that's currently in the URL selection
# MUST remain in the list — even if it has no logs in this window —
# so the user can always see and deselect their own picks.
logs_in_range = WorkLog.objects.filter(
date__gte=start_date, date__lte=end_date,
)
project_ids_in_range = set(
logs_in_range.values_list('project_id', flat=True).distinct()
)
team_ids_in_range = set(
logs_in_range.values_list('team_id', flat=True).distinct()
)
# Logs without a project/team contribute a None — drop it
project_ids_in_range.discard(None)
team_ids_in_range.discard(None)
# Union with the user's URL selections so picks never vanish
selected_p_int = {int(x) for x in (project_ids or [])}
selected_t_int = {int(x) for x in (team_ids or [])}
project_ids_to_show = project_ids_in_range | selected_p_int
team_ids_to_show = team_ids_in_range | selected_t_int
# Cross-filter pair map, scoped to the same date range
# (raw Python list — |json_script in the template handles serialisation)
pairs = list(
WorkLog.objects
logs_in_range
.filter(project__isnull=False, team__isnull=False)
.values('project_id', 'team_id')
.distinct()
)
# Pass the raw Python list — Django's |json_script filter does the single,
# XSS-safe JSON serialization at template render time. Calling json.dumps()
# here would double-encode: json_script would re-serialize the already-JSON
# string, producing `"[{\"project_id\": 1, ...}]"` — a quoted string literal
# that JSON.parse() returns as a string, breaking `pairs.forEach(...)` in
# the pill-popover JS. Matches the pattern used for team_workers_map_json
# and other *_json context keys on the payroll dashboard.
context['project_team_pairs_json'] = pairs
# 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).
# Picker lists (only projects/teams with activity in this window,
# union'd with current URL selection)
context['projects'] = (
Project.objects.filter(id__in=project_ids_to_show).order_by('name')
)
context['teams'] = (
Team.objects.filter(id__in=team_ids_to_show).order_by('name')
)
# Template's `{% if p.id|stringformat:"s" in selected_project_ids %}`
# comparison needs strings on both sides.
context['selected_project_ids'] = [str(p) for p in (project_ids or [])]
context['selected_team_ids'] = [str(t) for t in (team_ids or [])]