diff --git a/client/app/rosterhistory/components/HistoryView.jsx b/client/app/rosterhistory/components/HistoryView.jsx index dbbe68d..dba525f 100644 --- a/client/app/rosterhistory/components/HistoryView.jsx +++ b/client/app/rosterhistory/components/HistoryView.jsx @@ -11,6 +11,12 @@ import { import { Download, Search, X, ChevronLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useDiffList, useDiffRange, useDiffByDate, useRanks } from "../lib/api"; import { DiffEventCard } from "./DiffEventCard"; @@ -102,7 +108,11 @@ export function HistoryView() { return s; }, [activeData]); - 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( () => (activeData?.events ?? []).filter((e) => { if (!activeFilters.has(e.event_type)) return false; @@ -113,25 +123,26 @@ export function HistoryView() { 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; + }), + [activeData, 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; }), - [ - activeData, - activeFilters, - excludedRecordTypes, - activeRosterTypes, - unitFilter, - ], + [preUnitFilteredEvents, unitFilter], ); const recordTypeCounts = useMemo(() => { @@ -170,6 +181,14 @@ export function HistoryView() { 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; + // Aggregate snapshots into heatmap buckets. Bucket size scales with range: // ≤90d → daily, ≤365d → weekly, >365d or All → monthly const heatmapData = useMemo(() => { @@ -369,24 +388,31 @@ export function HistoryView() { : "day"}{" "} to drill down

-
- {heatmapData.map((entry) => { - const count = totalCount(entry); - const isSelected = entry.date === selectedDay; - return ( -
+ +
+ {heatmapData.map((entry) => { + const count = totalCount(entry); + const isSelected = entry.date === selectedDay; + const cellLabel = `${entry.label}: ${count} change${count !== 1 ? "s" : ""}`; + return ( + + +
+
Less {[ @@ -446,12 +472,15 @@ export function HistoryView() { presentRosterTypes={presentRosterTypes} /> - + {!isLoading && !isError && ( + + )} {activeData?.counts && ( Loading…

)} - {!isError && !isLoading && totalVisible === 0 && ( -
-

- 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. +

); } diff --git a/client/components/ui/tooltip.jsx b/client/components/ui/tooltip.jsx new file mode 100644 index 0000000..7292c7b --- /dev/null +++ b/client/components/ui/tooltip.jsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@/lib/utils"; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef( + ({ className, sideOffset = 4, ...props }, ref) => ( + + + + ), +); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/client/package-lock.json b/client/package-lock.json index 78a5375..c518893 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.28.0", "apexcharts": "^3.54.1", "class-variance-authority": "^0.7.1", @@ -559,6 +560,30 @@ } } }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -725,6 +750,58 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -2691,6 +2768,15 @@ "@radix-ui/react-use-layout-effect": "1.1.1" } }, + "@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + } + }, "@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -2773,6 +2859,35 @@ "@radix-ui/react-compose-refs": "1.1.2" } }, + "@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "dependencies": { + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, "@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/client/package.json b/client/package.json index b48fffc..8f57d10 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.28.0", "apexcharts": "^3.54.1", "class-variance-authority": "^0.7.1",