refactor(report): auto-submit on OK + sticky footer + optional until-month

Checkpoint 1 UX feedback (Konrad, 2026-04-23) surfaced three friction points
that all traced back to the same over-engineered "multi-stage commit" model:

1. When Choices.js opened its dropdown, it covered the popover's OK button.
   User had to click in a thin strip "outside the multi-select but inside
   the dropdown pane" to close Choices.js before OK became reachable.

2. Changing only a project/team didn't light up the global Apply button
   (dirty-state diff bug on multi-selects), and even when it did, clicking
   Apply didn't actually update the report tables. Also the Apply button
   sat at the far right of the pill strip — easy to miss on desktop.

3. Single-month reports required changing BOTH From and To pickers; for a
   low-frequency admin tool, that's a tax on the most common flow.

Instead of patching three bugs, collapsed the entire pending/dirty/Apply
model. Each popover's OK now:
  - Rebuilds the URL from its OWN inputs only (keeping other filters intact)
  - Navigates → full SSR page reload → report re-renders
The user reads the result of their change immediately; there's no "did I
remember to click Apply?" step.

Side-effect wins:
  - 'dirty state', 'pending state', 'updateAllPillsDirty', 'revert...',
    cross-filter auto-removal, and the toast system all become unnecessary.
    Net -187 lines across template + CSS.
  - The bug from (2) self-disappears because there's no dirty-diff step.
  - Sticky popover footer (position: sticky; bottom: 0; z-index: 2) pins
    OK to the popover edge even when Choices.js expands — solves (1).
  - The To month picker is labelled "Until (optional)" with "Leave blank
    for a single month" hint. Blank on submit → to_month = from_month.
    Single-month URLs round-trip with a blank To input (so the form and
    the data agree).

Cross-filter preserved: on popover open, the OTHER pill's URL selection
still disables invalid dropdown options. Just no runtime auto-remove —
unnecessary because the next OK submits and the server takes over.

Tested in the browser via preview MCP:
  - All three pills open popovers on click
  - Range URL shows both month pickers filled
  - Single-month URL shows To blank
  - OK with blank To → navigates to from_month=X&to_month=X
  - Sticky footer keeps OK in viewport when Choices.js is open
  - 45/45 tests still pass (no backend contract change)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-23 10:48:53 +02:00
parent 5c4162d2eb
commit ffb3ef6800
2 changed files with 163 additions and 350 deletions

View File

@ -61,14 +61,24 @@
<i class="fas fa-calendar-week me-1"></i>Custom Dates
</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. #}
<div class="row g-2" id="popoverMonthFields">
<div class="col-6">
<label class="form-label small">From</label>
<label class="form-label small">Month</label>
<input type="month" id="popoverFromMonth" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label small">To</label>
<label class="form-label small">
Until <span class="text-muted">(optional)</span>
</label>
<input type="month" id="popoverToMonth" 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>
</div>
</div>
<div class="row g-2 d-none" id="popoverCustomFields">
@ -157,22 +167,14 @@
</div>
</div>
{# --- Apply / Cancel buttons (shown only when dirty) --- #}
<div class="apply-filters-group ms-auto" id="apply-filters-group" hidden>
<button type="button" class="btn btn-sm btn-outline-secondary" id="cancel-filters-btn">Reset</button>
<button type="button" class="btn btn-sm btn-accent ms-1" id="apply-filters-btn">
<i class="fas fa-check me-1"></i>Apply
</button>
</div>
{# No global Apply button — each popover's OK commits + reloads directly. #}
</div>
{# --- Toast container (for cross-filter auto-removal notices) --- #}
<div id="filter-toast-container" class="filter-toast-container d-print-none" aria-live="polite"></div>
{# --- Cross-filter data for the JS module --- #}
{{ project_team_pairs_json|json_script:"projectTeamPairs" }}
{# --- Expose current URL filter state for JS reset + dirty diffing --- #}
{# --- Expose current URL filter state so the cross-filter can disable #}
{# dropdown options that are invalid given the OTHER pill's selection. #}
{{ selected_project_ids|json_script:"urlSelectedProjectIds" }}
{{ selected_team_ids|json_script:"urlSelectedTeamIds" }}
@ -544,25 +546,34 @@
{% endif %}
{# === INLINE FILTERS — PILL POPOVER MODULE === #}
{# Scoped IIFE; runs once on DOMContentLoaded. Manages popover open/close, #}
{# dirty state, cross-filter, Apply submission. XSS-safe: createElement + #}
{# textContent only (no innerHTML with user data). Matches CLAUDE.md pattern. #}
{# Scoped IIFE; runs once on DOMContentLoaded. #}
{# #}
{# Flow: each pill opens a popover; popover's OK button rebuilds the URL #}
{# (keeping other filters intact) and navigates → full SSR page reload. #}
{# Cancel just closes the popover. No "dirty" state, no global Apply. #}
{# #}
{# XSS-safe: textContent only; we never write user strings via innerHTML. #}
{% if user.is_staff or user.is_superuser %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// --- Bail if Choices.js isn't loaded (graceful fallback) ---
// --- Bail gracefully if Choices.js failed to load ---
if (typeof Choices === 'undefined') {
console.warn('[inline-filters] Choices.js not loaded; pills will not enhance');
return;
}
// --- Context from json_script tags (safe-decoded by browser JSON parser) ---
// === CONTEXT from json_script tags ===
var pairsEl = document.getElementById('projectTeamPairs');
var urlProjectsEl = document.getElementById('urlSelectedProjectIds');
var urlTeamsEl = document.getElementById('urlSelectedTeamIds');
var pairs = pairsEl ? JSON.parse(pairsEl.textContent) : [];
// URL state (strings from template) — cast to ints for comparison
function toIntArr(s) { return (JSON.parse(s || '[]') || []).map(function(v) { return parseInt(v, 10); }).filter(function(n) { return !isNaN(n); }); }
function toIntArr(s) {
return (JSON.parse(s || '[]') || [])
.map(function(v) { return parseInt(v, 10); })
.filter(function(n) { return !isNaN(n); });
}
// URL state used only for cross-filter option-disabling on popover open.
// (No pending-state diffing because OK always submits immediately.)
var urlProjects = toIntArr(urlProjectsEl && urlProjectsEl.textContent);
var urlTeams = toIntArr(urlTeamsEl && urlTeamsEl.textContent);
@ -576,40 +587,6 @@ document.addEventListener('DOMContentLoaded', function() {
teamToProjects[tid].add(pid);
});
// --- Pending state (starts = URL state; becomes dirty when user edits) ---
var pending = {
dateMode: null, fromMonth: null, toMonth: null, startDate: null, endDate: null,
projects: urlProjects.slice(),
teams: urlTeams.slice(),
};
var fromMonthInput = document.getElementById('popoverFromMonth');
var toMonthInput = document.getElementById('popoverToMonth');
var startDateInput = document.getElementById('popoverStartDate');
var endDateInput = document.getElementById('popoverEndDate');
// Initialise input values from URL query params
var qs = new URLSearchParams(window.location.search);
fromMonthInput.value = qs.get('from_month') || '';
toMonthInput.value = qs.get('to_month') || '';
startDateInput.value = qs.get('start_date') || '';
endDateInput.value = qs.get('end_date') || '';
var initialDateMode = (qs.get('start_date') || qs.get('end_date')) ? 'custom' : 'month';
document.getElementById('popDateModeMonth').checked = (initialDateMode === 'month');
document.getElementById('popDateModeCustom').checked = (initialDateMode === 'custom');
document.getElementById('popoverMonthFields').classList.toggle('d-none', initialDateMode !== 'month');
document.getElementById('popoverCustomFields').classList.toggle('d-none', initialDateMode !== 'custom');
pending.dateMode = initialDateMode;
pending.fromMonth = fromMonthInput.value;
pending.toMonth = toMonthInput.value;
pending.startDate = startDateInput.value;
pending.endDate = endDateInput.value;
var urlDate = {
mode: pending.dateMode,
fromMonth: pending.fromMonth, toMonth: pending.toMonth,
startDate: pending.startDate, endDate: pending.endDate,
};
// --- Choices.js instances (lazy init on first open) ---
var projectsChoices = null, teamsChoices = null;
@ -624,15 +601,30 @@ document.addEventListener('DOMContentLoaded', function() {
projects: document.getElementById('popover-projects'),
teams: document.getElementById('popover-teams'),
};
var labels = {
date: pills.date.querySelector('.filter-pill__label'),
projects: pills.projects.querySelector('.filter-pill__label'),
teams: pills.teams.querySelector('.filter-pill__label'),
};
var applyGroup = document.getElementById('apply-filters-group');
var applyBtn = document.getElementById('apply-filters-btn');
var resetBtn = document.getElementById('cancel-filters-btn');
var toastContainer = document.getElementById('filter-toast-container');
var fromMonthInput = document.getElementById('popoverFromMonth');
var toMonthInput = document.getElementById('popoverToMonth');
var startDateInput = document.getElementById('popoverStartDate');
var endDateInput = document.getElementById('popoverEndDate');
// === INITIALISE DATE INPUTS from URL ===
var qs = new URLSearchParams(window.location.search);
var urlFromMonth = qs.get('from_month') || '';
var urlToMonth = qs.get('to_month') || '';
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;
startDateInput.value = urlStartDate;
endDateInput.value = urlEndDate;
var initialDateMode = (urlStartDate || urlEndDate) ? 'custom' : 'month';
document.getElementById('popDateModeMonth').checked = (initialDateMode === 'month');
document.getElementById('popDateModeCustom').checked = (initialDateMode === 'custom');
document.getElementById('popoverMonthFields').classList.toggle('d-none', initialDateMode !== 'month');
document.getElementById('popoverCustomFields').classList.toggle('d-none', initialDateMode !== 'custom');
// === POPOVER OPEN / CLOSE ===
function closeAllPopovers(except) {
@ -645,11 +637,10 @@ document.addEventListener('DOMContentLoaded', function() {
}
function openPopover(key) {
closeAllPopovers(key);
var pop = popovers[key];
pop.hidden = false;
popovers[key].hidden = false;
pills[key].setAttribute('aria-expanded', 'true');
// Lazy-init Choices.js
// Lazy-init Choices.js the first time each multi-select is shown
if (key === 'projects' && !projectsChoices) {
projectsChoices = new Choices(document.getElementById('popoverProjects'), {
removeItemButton: true, shouldSort: false, placeholder: true,
@ -662,12 +653,14 @@ document.addEventListener('DOMContentLoaded', function() {
placeholderValue: 'All teams (leave empty for all)',
});
}
// Apply cross-filter to dropdown options (disable invalid ones)
// Cross-filter: disable options that are invalid given the OTHER pill's
// current URL selection. (e.g., open Teams with Project X in URL → hide
// teams that never logged on Project X.)
if (key === 'projects') applyCrossFilter('projects');
if (key === 'teams') applyCrossFilter('teams');
}
// --- Pill click handlers ---
// --- Pill click: toggle popover ---
Object.keys(pills).forEach(function(key) {
pills[key].addEventListener('click', function(ev) {
ev.stopPropagation();
@ -680,31 +673,38 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
});
// --- Click outside closes all ---
// Click outside the pill group closes all popovers
document.addEventListener('click', function(ev) {
if (!ev.target.closest('.filter-pill-wrap')) closeAllPopovers();
});
// --- Esc closes all ---
// Esc closes popovers
document.addEventListener('keydown', function(ev) {
if (ev.key === 'Escape') closeAllPopovers();
});
// === POPOVER OK / CANCEL HANDLERS ===
// OK submits immediately via navigation. Cancel just closes the popover;
// any Choices.js changes the user made are reset to URL state so the next
// open starts fresh.
document.querySelectorAll('.filter-popover').forEach(function(pop) {
var okBtn = pop.querySelector('.popover-ok');
var cancelBtn = pop.querySelector('.popover-cancel');
okBtn.addEventListener('click', function() {
if (pop.id === 'popover-date') commitDateFromInputs();
if (pop.id === 'popover-projects') commitProjectsFromChoices();
if (pop.id === 'popover-teams') commitTeamsFromChoices();
updateAllPillsDirty();
closeAllPopovers();
if (pop.id === 'popover-date') submitDateFilter();
else if (pop.id === 'popover-projects') submitProjectsFilter();
else if (pop.id === 'popover-teams') submitTeamsFilter();
// Navigation happens inside submit functions — nothing else to do.
});
cancelBtn.addEventListener('click', function() {
if (pop.id === 'popover-date') revertDateInputs();
if (pop.id === 'popover-projects') revertProjectsChoices();
if (pop.id === 'popover-teams') revertTeamsChoices();
// Reset Choices.js widgets to URL state in case the user had
// selected something. Date inputs reset on the next open via
// URL reload (no in-page state to revert otherwise).
if (pop.id === 'popover-projects' && projectsChoices) {
rebuildChoicesSelection(projectsChoices, urlProjects);
}
if (pop.id === 'popover-teams' && teamsChoices) {
rebuildChoicesSelection(teamsChoices, urlTeams);
}
closeAllPopovers();
});
});
@ -719,108 +719,64 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('popoverCustomFields').classList.remove('d-none');
});
// === COMMIT / REVERT HELPERS ===
function commitDateFromInputs() {
pending.dateMode = document.getElementById('popDateModeCustom').checked ? 'custom' : 'month';
pending.fromMonth = fromMonthInput.value;
pending.toMonth = toMonthInput.value;
pending.startDate = startDateInput.value;
pending.endDate = endDateInput.value;
if (pending.dateMode === 'month' && pending.fromMonth && pending.toMonth) {
labels.date.textContent = humanMonth(pending.fromMonth) + ' ' + humanMonth(pending.toMonth);
} else if (pending.dateMode === 'custom' && pending.startDate && pending.endDate) {
labels.date.textContent = humanDate(pending.startDate) + ' ' + humanDate(pending.endDate);
// === SUBMIT HANDLERS ===
// Each rebuilds the URL using the current popover's inputs (keeping the
// other filters intact) and navigates → full SSR page reload. Matches
// the original modal's contract; the report re-renders server-side.
function submitDateFilter() {
var params = new URLSearchParams(window.location.search);
// Clear all date-family params (current + legacy modal-form params)
params.delete('from_month');
params.delete('to_month');
params.delete('start_date');
params.delete('end_date');
params.delete('date_mode');
params.delete('search_terms');
var isCustom = document.getElementById('popDateModeCustom').checked;
if (isCustom) {
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;
if (from) params.set('from_month', from);
if (to) params.set('to_month', to);
}
navigateTo(params);
}
function revertDateInputs() {
document.getElementById('popDateModeMonth').checked = (pending.dateMode === 'month');
document.getElementById('popDateModeCustom').checked = (pending.dateMode === 'custom');
document.getElementById('popoverMonthFields').classList.toggle('d-none', pending.dateMode !== 'month');
document.getElementById('popoverCustomFields').classList.toggle('d-none', pending.dateMode !== 'custom');
fromMonthInput.value = pending.fromMonth || '';
toMonthInput.value = pending.toMonth || '';
startDateInput.value = pending.startDate || '';
endDateInput.value = pending.endDate || '';
}
function commitProjectsFromChoices() {
if (!projectsChoices) return;
pending.projects = projectsChoices.getValue(true).map(function(v) { return parseInt(v, 10); });
updatePillLabel('projects', 'All Projects', document.getElementById('popoverProjects'));
// Cross-filter: any team that's no longer valid gets auto-removed
var invalidTeams = pending.teams.filter(function(tid) {
return pending.projects.length > 0 && !pending.projects.some(function(pid) {
return projectToTeams[pid] && projectToTeams[pid].has(tid);
});
function submitProjectsFilter() {
if (!projectsChoices) { closeAllPopovers(); return; }
var params = new URLSearchParams(window.location.search);
params.delete('project');
projectsChoices.getValue(true).forEach(function(id) {
params.append('project', id);
});
if (invalidTeams.length > 0) {
pending.teams = pending.teams.filter(function(tid) { return invalidTeams.indexOf(tid) < 0; });
if (teamsChoices) rebuildChoicesSelection(teamsChoices, pending.teams);
invalidTeams.forEach(function(tid) {
var name = teamNameById(tid);
showToast('Removed ' + name + ' — no logs on selected projects');
});
updatePillLabel('teams', 'All Teams', document.getElementById('popoverTeams'));
}
navigateTo(params);
}
function revertProjectsChoices() {
if (!projectsChoices) return;
rebuildChoicesSelection(projectsChoices, pending.projects);
}
function commitTeamsFromChoices() {
if (!teamsChoices) return;
pending.teams = teamsChoices.getValue(true).map(function(v) { return parseInt(v, 10); });
updatePillLabel('teams', 'All Teams', document.getElementById('popoverTeams'));
var invalidProjects = pending.projects.filter(function(pid) {
return pending.teams.length > 0 && !pending.teams.some(function(tid) {
return teamToProjects[tid] && teamToProjects[tid].has(pid);
});
function submitTeamsFilter() {
if (!teamsChoices) { closeAllPopovers(); return; }
var params = new URLSearchParams(window.location.search);
params.delete('team');
teamsChoices.getValue(true).forEach(function(id) {
params.append('team', id);
});
if (invalidProjects.length > 0) {
pending.projects = pending.projects.filter(function(pid) { return invalidProjects.indexOf(pid) < 0; });
if (projectsChoices) rebuildChoicesSelection(projectsChoices, pending.projects);
invalidProjects.forEach(function(pid) {
var name = projectNameById(pid);
showToast('Removed ' + name + ' — no logs for selected teams');
});
updatePillLabel('projects', 'All Projects', document.getElementById('popoverProjects'));
}
navigateTo(params);
}
function revertTeamsChoices() {
if (!teamsChoices) return;
rebuildChoicesSelection(teamsChoices, pending.teams);
function navigateTo(params) {
window.location = window.location.pathname + '?' + params.toString();
}
// --- Rebuild a Choices.js widget's selection from a list of IDs ---
function rebuildChoicesSelection(instance, ids) {
instance.removeActiveItems();
var idStrs = ids.map(String);
if (idStrs.length > 0) {
instance.setChoiceByValue(idStrs);
}
}
// --- Update a pill's label text based on selected options ---
function updatePillLabel(key, allText, selectEl) {
var ids = (key === 'projects') ? pending.projects : pending.teams;
if (ids.length === 0) {
labels[key].textContent = allText;
return;
}
var names = ids.map(function(id) {
var opt = selectEl.querySelector('option[value="' + id + '"]');
return opt ? opt.textContent : String(id);
});
if (names.length === 1) labels[key].textContent = names[0];
else if (names.length === 2) labels[key].textContent = names.join(', ');
else labels[key].textContent = names[0] + ' + ' + (names.length - 1) + ' more';
}
// --- Apply cross-filter: disable invalid options in the just-opened popover ---
// === CROSS-FILTER ===
// Read-time only: when a popover opens, disable options that are invalid
// given the OTHER pill's current URL selection. Since each OK submits to
// URL directly, we don't need runtime auto-removal or pending-state sync.
function applyCrossFilter(justOpened) {
if (justOpened === 'projects' && projectsChoices) {
if (pending.teams.length === 0) return; // no constraint
if (urlTeams.length === 0) return; // no constraint
var validPids = new Set();
pending.teams.forEach(function(tid) {
urlTeams.forEach(function(tid) {
if (teamToProjects[tid]) {
teamToProjects[tid].forEach(function(pid) { validPids.add(pid); });
}
@ -828,7 +784,9 @@ document.addEventListener('DOMContentLoaded', function() {
var sel = document.getElementById('popoverProjects');
Array.from(sel.options).forEach(function(opt) {
var pid = parseInt(opt.value, 10);
opt.disabled = !validPids.has(pid) && !pending.projects.includes(pid);
// Always leave currently-URL-selected items enabled so they
// remain visible as removable chips — user can unpick them.
opt.disabled = !validPids.has(pid) && !urlProjects.includes(pid);
});
projectsChoices.destroy();
projectsChoices = new Choices(sel, {
@ -837,9 +795,9 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
if (justOpened === 'teams' && teamsChoices) {
if (pending.projects.length === 0) return;
if (urlProjects.length === 0) return;
var validTids = new Set();
pending.projects.forEach(function(pid) {
urlProjects.forEach(function(pid) {
if (projectToTeams[pid]) {
projectToTeams[pid].forEach(function(tid) { validTids.add(tid); });
}
@ -847,7 +805,7 @@ document.addEventListener('DOMContentLoaded', function() {
var selT = document.getElementById('popoverTeams');
Array.from(selT.options).forEach(function(opt) {
var tid = parseInt(opt.value, 10);
opt.disabled = !validTids.has(tid) && !pending.teams.includes(tid);
opt.disabled = !validTids.has(tid) && !urlTeams.includes(tid);
});
teamsChoices.destroy();
teamsChoices = new Choices(selT, {
@ -857,109 +815,14 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// === DIRTY STATE ===
function isDateDirty() {
return pending.dateMode !== urlDate.mode ||
pending.fromMonth !== urlDate.fromMonth ||
pending.toMonth !== urlDate.toMonth ||
pending.startDate !== urlDate.startDate ||
pending.endDate !== urlDate.endDate;
}
function isProjectsDirty() {
return !sameIntSet(pending.projects, urlProjects);
}
function isTeamsDirty() {
return !sameIntSet(pending.teams, urlTeams);
}
function sameIntSet(a, b) {
if (a.length !== b.length) return false;
var sA = new Set(a), sB = new Set(b);
for (var v of sA) { if (!sB.has(v)) return false; }
return true;
}
function updateAllPillsDirty() {
pills.date.classList.toggle('filter-pill--dirty', isDateDirty());
pills.projects.classList.toggle('filter-pill--dirty', isProjectsDirty());
pills.teams.classList.toggle('filter-pill--dirty', isTeamsDirty());
var anyDirty = isDateDirty() || isProjectsDirty() || isTeamsDirty();
applyGroup.hidden = !anyDirty;
}
// === APPLY: rebuild querystring + navigate ===
applyBtn.addEventListener('click', function() {
var params = new URLSearchParams();
if (pending.dateMode === 'month') {
if (pending.fromMonth) params.append('from_month', pending.fromMonth);
if (pending.toMonth) params.append('to_month', pending.toMonth);
} else {
if (pending.startDate) params.append('start_date', pending.startDate);
if (pending.endDate) params.append('end_date', pending.endDate);
// --- Helper: rebuild a Choices.js widget's selection from a list of IDs ---
function rebuildChoicesSelection(instance, ids) {
instance.removeActiveItems();
var idStrs = ids.map(String);
if (idStrs.length > 0) {
instance.setChoiceByValue(idStrs);
}
pending.projects.forEach(function(pid) { params.append('project', pid); });
pending.teams.forEach(function(tid) { params.append('team', tid); });
window.location = window.location.pathname + '?' + params.toString();
});
// === RESET: revert to URL state ===
resetBtn.addEventListener('click', function() {
pending.dateMode = urlDate.mode;
pending.fromMonth = urlDate.fromMonth;
pending.toMonth = urlDate.toMonth;
pending.startDate = urlDate.startDate;
pending.endDate = urlDate.endDate;
pending.projects = urlProjects.slice();
pending.teams = urlTeams.slice();
revertDateInputs();
if (projectsChoices) rebuildChoicesSelection(projectsChoices, pending.projects);
if (teamsChoices) rebuildChoicesSelection(teamsChoices, pending.teams);
if (pending.dateMode === 'month' && pending.fromMonth && pending.toMonth) {
labels.date.textContent = humanMonth(pending.fromMonth) + ' ' + humanMonth(pending.toMonth);
} else if (pending.dateMode === 'custom' && pending.startDate && pending.endDate) {
labels.date.textContent = humanDate(pending.startDate) + ' ' + humanDate(pending.endDate);
}
updatePillLabel('projects', 'All Projects', document.getElementById('popoverProjects'));
updatePillLabel('teams', 'All Teams', document.getElementById('popoverTeams'));
updateAllPillsDirty();
});
// === TOAST ===
function showToast(message) {
var toast = document.createElement('div');
toast.className = 'filter-toast';
toast.textContent = message; // textContent: XSS-safe
toastContainer.appendChild(toast);
setTimeout(function() {
toast.classList.add('filter-toast--exiting');
setTimeout(function() {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 220);
}, 4000);
}
// === LABEL HELPERS ===
function humanMonth(ym) {
if (!ym || ym.length < 7) return ym || '';
var parts = ym.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
function humanDate(ymd) {
if (!ymd || ymd.length < 10) return ymd || '';
var parts = ymd.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return parseInt(parts[2], 10) + ' ' + months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
function projectNameById(pid) {
var opt = document.getElementById('popoverProjects').querySelector('option[value="' + pid + '"]');
return opt ? opt.textContent : 'Project ' + pid;
}
function teamNameById(tid) {
var opt = document.getElementById('popoverTeams').querySelector('option[value="' + tid + '"]');
return opt ? opt.textContent : 'Team ' + tid;
}
// --- Initial render ---
updateAllPillsDirty();
});
</script>
{% endif %}

View File

@ -1737,12 +1737,16 @@ body, .card, .modal-content, .form-control, .form-select,
/* === Inline Filters (pill-as-dropdown) on the report page === */
/*
Layered on top of the existing .filter-pill rules (lines ~14961524).
Five components:
1. .filter-pill--editable: pointer cursor, hover tint, chevron
2. .filter-pill--dirty: accent outline + small pulsing dot when uncommitted
3. .filter-popover: absolute-positioned dropdown beneath the pill
4. .apply-filters-group: slide-in Apply/Reset buttons when any pill dirty
5. .filter-toast-container: cross-filter auto-removal notices
Three components:
1. .filter-pill--editable: pointer cursor, hover tint, rotating chevron
2. .filter-popover: absolute-positioned dropdown anchored under the pill
3. .filter-popover__footer: sticky bottom bar so the OK button stays
visible even when Choices.js expands its dropdown list over the body
There is intentionally NO dirty-state indicator and NO global Apply button
each popover's OK commits and reloads the page immediately. Simpler model,
less state to reason about. (Earlier revision had both; removed 2026-04-23
after UX feedback.)
*/
/* --- Wrapper keeps the popover anchored to its pill --- */
@ -1775,27 +1779,9 @@ body, .card, .modal-content, .form-control, .form-select,
transform: rotate(180deg);
}
/* --- Dirty state: pulsing accent dot + outline --- */
.filter-pill--dirty {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(232, 133, 26, 0.18);
}
.filter-pill--dirty::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
margin-right: 0.4rem;
animation: filter-pill-pulse 1.4s ease-in-out infinite;
}
@keyframes filter-pill-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
/* --- Popover positioned under the pill --- */
/* max-height + flex column keeps the sticky footer visible even when the
popover body has to scroll (Choices.js can render a long list of options). */
.filter-popover {
position: absolute;
top: calc(100% + 6px);
@ -1803,18 +1789,26 @@ body, .card, .modal-content, .form-control, .form-select,
z-index: 1040; /* below Bootstrap modal (1055) but above everything else */
min-width: 300px;
max-width: 420px;
max-height: min(70vh, 520px);
display: flex;
flex-direction: column;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
padding: 0;
overflow: hidden; /* clip Choices.js dropdown so the sticky footer wins */
}
.filter-popover[hidden] {
display: none;
}
.filter-popover__body {
padding: 1rem;
overflow-y: auto; /* body scrolls when content exceeds max-height */
flex: 1 1 auto;
}
/* Footer is sticky at the bottom of the popover so the OK button is always
reachable fixes the "Choices.js dropdown hides the OK button" complaint. */
.filter-popover__footer {
display: flex;
justify-content: flex-end;
@ -1823,6 +1817,10 @@ body, .card, .modal-content, .form-control, .form-select,
border-top: 1px solid var(--border-default);
background: var(--bg-inset);
border-radius: 0 0 0.5rem 0.5rem;
flex: 0 0 auto;
position: sticky;
bottom: 0;
z-index: 2;
}
/* --- Mobile: popovers stretch full-width below the pill strip --- */
@ -1835,56 +1833,8 @@ body, .card, .modal-content, .form-control, .form-select,
right: 0;
width: 100vw;
max-width: 100vw;
max-height: 80vh;
border-radius: 0.5rem 0.5rem 0 0;
z-index: 1050;
}
}
/* --- Apply / Reset button group --- */
.apply-filters-group {
display: flex;
align-items: center;
animation: apply-slide-in 180ms ease-out;
}
.apply-filters-group[hidden] {
display: none;
}
@keyframes apply-slide-in {
from { opacity: 0; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
/* --- Toast container for cross-filter auto-remove notices --- */
.filter-toast-container {
position: fixed;
top: 4.5rem; /* below topbar */
right: 1rem;
z-index: 1060;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
max-width: 320px;
}
.filter-toast {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--accent);
border-left: 4px solid var(--accent);
border-radius: 0.375rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
animation: filter-toast-in 200ms ease-out;
pointer-events: auto;
}
.filter-toast--exiting {
animation: filter-toast-out 200ms ease-in forwards;
}
@keyframes filter-toast-in {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes filter-toast-out {
to { opacity: 0; transform: translateX(12px); }
}