diff --git a/core/templates/core/report.html b/core/templates/core/report.html
index d12b0f9..e4fa618 100644
--- a/core/templates/core/report.html
+++ b/core/templates/core/report.html
@@ -61,14 +61,24 @@
Custom Dates
+ {# --- 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. #}
-
+
-
+
+
+ Leave blank for a single month
+
@@ -157,22 +167,14 @@
- {# --- Apply / Cancel buttons (shown only when dirty) --- #}
-
-
-
-
+ {# No global Apply button — each popover's OK commits + reloads directly. #}
-{# --- Toast container (for cross-filter auto-removal notices) --- #}
-
-
{# --- 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 %}
{% endif %}
diff --git a/static/css/custom.css b/static/css/custom.css
index 71f3840..ff72085 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -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 ~1496–1524).
- 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); }
-}