Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 90 additions & 51 deletions client/app/rosterhistory/components/HistoryView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -369,24 +388,31 @@ export function HistoryView() {
: "day"}{" "}
to drill down
</p>
<div className="flex flex-wrap gap-1">
{heatmapData.map((entry) => {
const count = totalCount(entry);
const isSelected = entry.date === selectedDay;
return (
<button
key={entry.date}
title={`${entry.label}: ${count} change${count !== 1 ? "s" : ""}`}
onClick={() => handleDotClick(entry)}
className={cn(
"h-4 w-4 rounded-sm transition-transform hover:scale-125 hover:ring-2 hover:ring-white/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
heatIntensity(count, maxCount),
isSelected && "ring-2 ring-primary scale-125",
)}
/>
);
})}
</div>
<TooltipProvider delayDuration={100}>
<div className="flex flex-wrap gap-1">
{heatmapData.map((entry) => {
const count = totalCount(entry);
const isSelected = entry.date === selectedDay;
const cellLabel = `${entry.label}: ${count} change${count !== 1 ? "s" : ""}`;
return (
<Tooltip key={entry.date}>
<TooltipTrigger asChild>
<button
onClick={() => handleDotClick(entry)}
aria-label={cellLabel}
className={cn(
"h-4 w-4 rounded-sm transition-transform hover:scale-125 hover:ring-2 hover:ring-white/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
heatIntensity(count, maxCount),
isSelected && "ring-2 ring-primary scale-125",
)}
/>
</TooltipTrigger>
<TooltipContent>{cellLabel}</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
<div className="flex items-center gap-1.5 mt-2 text-xs text-muted-foreground">
<span>Less</span>
{[
Expand Down Expand Up @@ -446,12 +472,15 @@ export function HistoryView() {
presentRosterTypes={presentRosterTypes}
/>

<UnitFilterBar
events={typeFilteredEvents}
unitFilter={unitFilter}
onSelect={handleUnitSelect}
onClear={handleUnitClear}
/>
{!isLoading && !isError && (
<UnitFilterBar
events={typeFilteredEvents}
unitFilter={unitFilter}
onSelect={handleUnitSelect}
onClear={handleUnitClear}
preUnitFilteredCount={preUnitFilteredEvents.length}
/>
)}

{activeData?.counts && (
<SummaryBar
Expand Down Expand Up @@ -491,16 +520,26 @@ export function HistoryView() {
<p className="text-muted-foreground text-sm">Loading…</p>
)}

{!isError && !isLoading && totalVisible === 0 && (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-muted-foreground">
<p>
No changes{" "}
{selectedDay
? "recorded for this date."
: "in the selected range."}
</p>
</div>
)}
{/* 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 && (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-muted-foreground">
<p>
No changes{" "}
{selectedDay
? "recorded for this date."
: "in the selected range."}
</p>
</div>
)}

{notable.length > 0 && (
<div className="space-y-2">
Expand Down
76 changes: 54 additions & 22 deletions client/app/rosterhistory/components/TodayView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -306,12 +325,15 @@ export function TodayView() {
presentRosterTypes={presentRosterTypes}
/>

<UnitFilterBar
events={typeFilteredEvents}
unitFilter={unitFilter}
onSelect={handleUnitSelect}
onClear={handleUnitClear}
/>
{!diffLoading && !listError && !diffError && (
<UnitFilterBar
events={typeFilteredEvents}
unitFilter={unitFilter}
onSelect={handleUnitSelect}
onClear={handleUnitClear}
preUnitFilteredCount={preUnitFilteredEvents.length}
/>
)}

{diff?.counts && (
<SummaryBar
Expand Down Expand Up @@ -366,11 +388,21 @@ export function TodayView() {
</div>
)}

{diff && totalVisible === 0 && diff.events.length > 0 && (
<p className="text-muted-foreground text-sm">
All event types filtered out.
</p>
)}
{/* 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 && (
<p className="text-muted-foreground text-sm">
All event types filtered out.
</p>
)}

{diff?.events?.length === 0 && (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-muted-foreground">
Expand Down
57 changes: 55 additions & 2 deletions client/app/rosterhistory/components/UnitFilterBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 (
<div className="rounded-lg border border-dashed border-border p-6 text-center text-muted-foreground">
<p className="text-sm">
No recorded changes for{" "}
<span className="font-medium text-foreground">{filterLabel}</span> in
the current view.
</p>
<p className="text-xs mt-1 text-muted-foreground/70">
Only units with recorded changes in the current view are listed.
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => onClear("battalion")}
>
<X size={12} />
Clear unit filter
</Button>
</div>
);
}

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

<p className="text-[11px] text-muted-foreground/60">
Only units with recorded changes in the current view are listed.
</p>
</div>
);
}
Loading