- No changes{" "}
- {selectedDay
- ? "recorded for this date."
- : "in the selected range."}
-
-
- )}
+ {/* Skip the generic message only when UnitFilterBar renders its own
+ unit-scoped empty state (unitFilterIsCause). Invariant: this
+ relies on UnitFilterBar receiving the same typeFilteredEvents
+ used to compute totalVisible (plus preUnitFilteredCount for the
+ cause check), and on groupAndSortEvents not dropping events
+ (totalVisible === typeFilteredEvents.length) — if either ever
+ diverges, restore a fallback message here. */}
+ {!isError &&
+ !isLoading &&
+ totalVisible === 0 &&
+ !unitFilterIsCause && (
+
+
+ No changes{" "}
+ {selectedDay
+ ? "recorded for this date."
+ : "in the selected range."}
+
+
+ )}
{notable.length > 0 && (
diff --git a/client/app/rosterhistory/components/TodayView.jsx b/client/app/rosterhistory/components/TodayView.jsx
index c0c834e..9279f66 100644
--- a/client/app/rosterhistory/components/TodayView.jsx
+++ b/client/app/rosterhistory/components/TodayView.jsx
@@ -92,7 +92,11 @@ export function TodayView() {
return s;
}, [diff]);
- const typeFilteredEvents = useMemo(
+ // Events with every filter EXCEPT the unit predicate applied. Used to
+ // decide whether an active unit filter is the actual cause of an empty
+ // view (vs. event-type/record-type/roster-type filters having emptied it
+ // already).
+ const preUnitFilteredEvents = useMemo(
() =>
(diff?.events ?? []).filter((e) => {
if (!activeFilters.has(e.event_type)) return false;
@@ -103,19 +107,26 @@ export function TodayView() {
return false;
if (e.roster_type && !activeRosterTypes.has(e.roster_type))
return false;
- if (unitFilter.battalion) {
- const u = parseUnit(e.position_title || "");
- if (!u || u.battalion !== unitFilter.battalion) return false;
- if (unitFilter.company) {
- const co = u.company ?? "HQ";
- if (co !== unitFilter.company) return false;
- }
- if (unitFilter.platoon && u.platoon !== unitFilter.platoon)
- return false;
+ return true;
+ }),
+ [diff, activeFilters, excludedRecordTypes, activeRosterTypes],
+ );
+
+ const typeFilteredEvents = useMemo(
+ () =>
+ preUnitFilteredEvents.filter((e) => {
+ if (!unitFilter.battalion) return true;
+ const u = parseUnit(e.position_title || "");
+ if (!u || u.battalion !== unitFilter.battalion) return false;
+ if (unitFilter.company) {
+ const co = u.company ?? "HQ";
+ if (co !== unitFilter.company) return false;
}
+ if (unitFilter.platoon && u.platoon !== unitFilter.platoon)
+ return false;
return true;
}),
- [diff, activeFilters, excludedRecordTypes, activeRosterTypes, unitFilter],
+ [preUnitFilteredEvents, unitFilter],
);
const recordTypeCounts = useMemo(() => {
@@ -154,6 +165,14 @@ export function TodayView() {
const totalVisible =
notable.length + recordGroups.reduce((n, g) => n + g.records.length, 0);
+ // True when an active unit filter is the genuine cause of an empty view:
+ // nothing is visible, yet events survive every other filter — so the unit
+ // predicate is what emptied it.
+ const unitFilterIsCause =
+ totalVisible === 0 &&
+ Boolean(unitFilter.battalion) &&
+ preUnitFilteredEvents.length > 0;
+
function toggleFilter(type) {
setActiveFilters((prev) => {
const next = new Set(prev);
@@ -306,12 +325,15 @@ export function TodayView() {
presentRosterTypes={presentRosterTypes}
/>
-
+ {!diffLoading && !listError && !diffError && (
+
+ )}
{diff?.counts && (
)}
- {diff && totalVisible === 0 && diff.events.length > 0 && (
-
- All event types filtered out.
-
- )}
+ {/* Skip this message only when UnitFilterBar renders its own
+ unit-scoped empty state (unitFilterIsCause). Invariant: this
+ relies on UnitFilterBar receiving the same typeFilteredEvents
+ used to compute totalVisible (plus preUnitFilteredCount for the
+ cause check), and on groupAndSortEvents not dropping events
+ (totalVisible === typeFilteredEvents.length) — if either ever
+ diverges, restore a fallback message here. */}
+ {diff &&
+ totalVisible === 0 &&
+ diff.events.length > 0 &&
+ !unitFilterIsCause && (
+
+ All event types filtered out.
+
+ )}
{diff?.events?.length === 0 && (
diff --git a/client/app/rosterhistory/components/UnitFilterBar.jsx b/client/app/rosterhistory/components/UnitFilterBar.jsx
index b2f9039..1383726 100644
--- a/client/app/rosterhistory/components/UnitFilterBar.jsx
+++ b/client/app/rosterhistory/components/UnitFilterBar.jsx
@@ -38,7 +38,13 @@ function UnitChip({ label, active, onClick, onClear }) {
);
}
-export function UnitFilterBar({ events, unitFilter, onSelect, onClear }) {
+export function UnitFilterBar({
+ events,
+ unitFilter,
+ onSelect,
+ onClear,
+ preUnitFilteredCount,
+}) {
const unitMap = useMemo(() => {
const battalions = new Map();
@@ -66,7 +72,50 @@ export function UnitFilterBar({ events, unitFilter, onSelect, onClear }) {
return battalions;
}, [events]);
- if (unitMap.size === 0) return null;
+ const hasActiveFilter = Boolean(unitFilter.battalion);
+
+ if (unitMap.size === 0) {
+ // No units in view. If a unit filter is active, no events in the current
+ // data match it — explain that instead of silently disappearing.
+ if (!hasActiveFilter) return null;
+
+ // Only blame the unit filter when it is genuinely the cause: if the
+ // events were already empty before the unit predicate was applied
+ // (e.g. all event types toggled off), the parent's generic message is
+ // the accurate one — render nothing here so it can show.
+ if (preUnitFilteredCount === 0) return null;
+
+ const filterLabel = [
+ unitFilter.battalion,
+ unitFilter.company &&
+ (unitFilter.company === "HQ" ? "HQ" : `Co. ${unitFilter.company}`),
+ unitFilter.platoon && `Plt. ${unitFilter.platoon}`,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+
+ return (
+
+
+ No recorded changes for{" "}
+ {filterLabel} in
+ the current view.
+
+
+ Only units with recorded changes in the current view are listed.
+
+
+
+ );
+ }
const sortedBattalions = [...unitMap.keys()].sort((a, b) => {
const ai = LINE_BATTALION_ORDER.indexOf(a);
@@ -165,6 +214,10 @@ export function UnitFilterBar({ events, unitFilter, onSelect, onClear }) {
>
)}
+
+
+ Only units with recorded changes in the current view are listed.
+