From a04af5ed512a91838f8a6c20837dedc95de86a10 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:49:53 -0400 Subject: [PATCH 1/4] Replace native title tooltips with Radix Tooltip on history heatmap Native title attributes have an unconfigurable ~1s browser hover delay, making heatmap tooltips feel sluggish. Add the shadcn Tooltip primitive (@radix-ui/react-tooltip) to components/ui and use it for heatmap cells with a 100ms delayDuration. One shared TooltipProvider wraps all cells. Tooltip text is unchanged, and keyboard focus now also reveals it. Fixes #137 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rosterhistory/components/HistoryView.jsx | 49 +++++--- client/components/ui/tooltip.jsx | 31 +++++ client/package-lock.json | 115 ++++++++++++++++++ client/package.json | 1 + 4 files changed, 178 insertions(+), 18 deletions(-) create mode 100644 client/components/ui/tooltip.jsx diff --git a/client/app/rosterhistory/components/HistoryView.jsx b/client/app/rosterhistory/components/HistoryView.jsx index dbbe68d..c1fe567 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"; @@ -369,24 +375,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; + return ( + + +
+
Less {[ 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", From d84504d6ef2a2cca966c813112d76d19231baf32 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:54:32 -0400 Subject: [PATCH 2/4] Explain why a unit is absent from roster history instead of hiding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit filter bar derives chips only from events in the current view, so units without recorded changes never appear and an active filter matching nothing made the bar (and content) vanish silently — read as a bug by users. - Add muted helper text to the filter bar stating only units with recorded changes are listed - When an active unit filter matches zero events, render the existing dashed-border empty-state pattern naming the filtered unit, with a "Clear unit filter" button - Suppress the generic "no changes" / "all event types filtered out" messages when the unit-filter empty state already explains the gap - Hide the bar while data loads to avoid flashing the empty state - Applied identically in Today and History views Closes #136 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rosterhistory/components/HistoryView.jsx | 39 ++++++++++------- .../rosterhistory/components/TodayView.jsx | 31 +++++++------ .../components/UnitFilterBar.jsx | 43 ++++++++++++++++++- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/client/app/rosterhistory/components/HistoryView.jsx b/client/app/rosterhistory/components/HistoryView.jsx index c1fe567..93d14b1 100644 --- a/client/app/rosterhistory/components/HistoryView.jsx +++ b/client/app/rosterhistory/components/HistoryView.jsx @@ -459,12 +459,14 @@ export function HistoryView() { presentRosterTypes={presentRosterTypes} /> - + {!isLoading && ( + + )} {activeData?.counts && ( Loading…

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

- No changes{" "} - {selectedDay - ? "recorded for this date." - : "in the selected range."} -

-
- )} + {/* When a unit filter matched nothing, UnitFilterBar renders its own + explanatory empty state — skip the generic message. */} + {!isError && + !isLoading && + totalVisible === 0 && + !unitFilter.battalion && ( +
+

+ 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..45f5883 100644 --- a/client/app/rosterhistory/components/TodayView.jsx +++ b/client/app/rosterhistory/components/TodayView.jsx @@ -306,12 +306,14 @@ export function TodayView() { presentRosterTypes={presentRosterTypes} /> - + {!diffLoading && ( + + )} {diff?.counts && ( )} - {diff && totalVisible === 0 && diff.events.length > 0 && ( -

- All event types filtered out. -

- )} + {/* When a unit filter matched nothing, UnitFilterBar renders its own + explanatory empty state — skip the generic messages. */} + {diff && + totalVisible === 0 && + diff.events.length > 0 && + !unitFilter.battalion && ( +

+ All event types filtered out. +

+ )} - {diff?.events?.length === 0 && ( + {diff?.events?.length === 0 && !unitFilter.battalion && (

No changes recorded for this date.

diff --git a/client/app/rosterhistory/components/UnitFilterBar.jsx b/client/app/rosterhistory/components/UnitFilterBar.jsx index b2f9039..172b2bc 100644 --- a/client/app/rosterhistory/components/UnitFilterBar.jsx +++ b/client/app/rosterhistory/components/UnitFilterBar.jsx @@ -66,7 +66,44 @@ 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 it filtered everything + // out — explain that instead of silently disappearing. + if (!hasActiveFilter) 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 selection. +

+

+ Units only appear here when they have recorded changes. +

+ +
+ ); + } const sortedBattalions = [...unitMap.keys()].sort((a, b) => { const ai = LINE_BATTALION_ORDER.indexOf(a); @@ -165,6 +202,10 @@ export function UnitFilterBar({ events, unitFilter, onSelect, onClear }) {
)} + +

+ Only units with recorded changes in the current view are listed. +

); } From 173690b3fdb894d81cfa3d88cd0fb36ce71ec9ab Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:08:12 -0400 Subject: [PATCH 3/4] Address review findings on rosterhistory unit filter bar (#136/#137 context) Important fixes: - Gate UnitFilterBar on not-loading AND not-error in HistoryView and TodayView so a failed fetch no longer shows a false "No recorded changes for X" empty state above the error alert (restores pre-existing no-bar-on-error behavior). - Only show the unit-blaming empty state when the unit filter is genuinely the cause: parents now compute preUnitFilteredEvents (all filters except the unit predicate) and pass preUnitFilteredCount to UnitFilterBar; when that list is also empty the bar renders nothing and the parent's accurate generic message (e.g. "All event types filtered out") shows instead. Parents suppress their generic empty messages via unitFilterIsCause rather than mere unit-filter presence. - Restore accessible names on heatmap cell buttons lost in the Radix Tooltip migration by adding aria-label with the tooltip string. Suggestions: - Unify copy to "in the current view" in the new UnitFilterBar strings. - Use identical wording for the units-listed rule in both UnitFilterBar locations. - Reword the UnitFilterBar empty-state comment to describe observable state instead of overclaiming causality. - Strengthen suppression-contract comments in HistoryView/TodayView to name the invariant (UnitFilterBar must receive the same typeFilteredEvents used for totalVisible) and reflect the new gating. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rosterhistory/components/HistoryView.jsx | 62 ++++++++++++------- .../rosterhistory/components/TodayView.jsx | 52 +++++++++++----- .../components/UnitFilterBar.jsx | 22 +++++-- 3 files changed, 91 insertions(+), 45 deletions(-) diff --git a/client/app/rosterhistory/components/HistoryView.jsx b/client/app/rosterhistory/components/HistoryView.jsx index 93d14b1..8e85fac 100644 --- a/client/app/rosterhistory/components/HistoryView.jsx +++ b/client/app/rosterhistory/components/HistoryView.jsx @@ -108,7 +108,10 @@ 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/roster-type filters having emptied it already). + const preUnitFilteredEvents = useMemo( () => (activeData?.events ?? []).filter((e) => { if (!activeFilters.has(e.event_type)) return false; @@ -119,25 +122,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(() => { @@ -176,6 +180,11 @@ 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 emptiness: + // events survive every other filter, but none match the selected unit. + const unitFilterIsCause = + 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(() => { @@ -380,11 +389,13 @@ export function HistoryView() { {heatmapData.map((entry) => { const count = totalCount(entry); const isSelected = entry.date === selectedDay; + const cellLabel = `${entry.label}: ${count} change${count !== 1 ? "s" : ""}`; return (