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:
parent
ffb3ef6800
commit
71f8558ff5
@ -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);
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 [])]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user