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