diff --git a/core/templates/core/report.html b/core/templates/core/report.html index e4fa618..bdb73c7 100644 --- a/core/templates/core/report.html +++ b/core/templates/core/report.html @@ -62,20 +62,20 @@ {# --- 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. #}
- - + +
- +
Leave blank for a single month
@@ -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); } diff --git a/core/tests.py b/core/tests.py index 3faf590..f6e813b 100644 --- a/core/tests.py +++ b/core/tests.py @@ -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') diff --git a/core/views.py b/core/views.py index 7a944f0..48bfe21 100644 --- a/core/views.py +++ b/core/views.py @@ -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