Usage Gap Analysis Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add a collapsible "Usage Gap Analysis" section to the Flow Meter page that visualizes which days have no Dustloc usage logs for operational monitoring.
Architecture: Create a new component folder UsageGapAnalysis/ under src/features/flow-meter/components/ with four components: main wrapper, calendar heatmap, gap timeline chart, and no-data days table. Data processing uses a utility function that derives gap information from existing DailySummary[] data.
Tech Stack: React, TypeScript, Recharts (existing), date-fns (existing), Tailwind CSS
Task 1: Create GapData Types
Files:
- Modify:
src/features/flow-meter/types.ts
Step 1: Add gap analysis types to existing types file
Add these types at the end of the file:
// Usage Gap Analysis Types
export interface Gap {
startDate: Date;
endDate: Date;
duration: number; // Number of days
}
export interface GapData {
allDays: Date[];
daysWithData: Set<string>; // YYYY-MM-DD format
daysWithoutData: Date[];
gaps: Gap[];
}
export type GapPosition = "start" | "middle" | "end" | "single";
export interface DayStatus {
date: Date;
hasData: boolean;
litresUsed?: number;
eventCount?: number;
isWeekend: boolean;
gapInfo?: {
position: GapPosition;
gapDuration: number;
};
}Step 2: Verify no TypeScript errors
Run: pnpm build:check
Expected: Build succeeds without errors
Step 3: Commit
git add src/features/flow-meter/types.ts
git commit -m "feat(flow-meter): add gap analysis types"Task 2: Create Gap Calculation Utility
Files:
- Create:
src/features/flow-meter/services/gapAnalysis.ts - Create:
src/features/flow-meter/services/gapAnalysis.test.ts
Step 1: Write the failing test
Create src/features/flow-meter/services/gapAnalysis.test.ts:
import { describe, it, expect } from "vitest";
import { calculateGapData, getDayStatus } from "./gapAnalysis";
import type { DailySummary } from "../types";
describe("gapAnalysis", () => {
describe("calculateGapData", () => {
it("identifies days without data as gaps", () => {
const dateRange = {
start: new Date("2026-01-01"),
end: new Date("2026-01-05"),
};
const dailySummaries: DailySummary[] = [
{ date: "2026-01-01", totalLitres: 100, recordCount: 2 },
{ date: "2026-01-03", totalLitres: 150, recordCount: 3 },
{ date: "2026-01-05", totalLitres: 200, recordCount: 1 },
];
const result = calculateGapData(dateRange, dailySummaries);
expect(result.daysWithoutData).toHaveLength(2);
expect(result.gaps).toHaveLength(1);
expect(result.gaps[0].duration).toBe(2);
});
it("returns empty gaps when all days have data", () => {
const dateRange = {
start: new Date("2026-01-01"),
end: new Date("2026-01-03"),
};
const dailySummaries: DailySummary[] = [
{ date: "2026-01-01", totalLitres: 100, recordCount: 2 },
{ date: "2026-01-02", totalLitres: 150, recordCount: 3 },
{ date: "2026-01-03", totalLitres: 200, recordCount: 1 },
];
const result = calculateGapData(dateRange, dailySummaries);
expect(result.daysWithoutData).toHaveLength(0);
expect(result.gaps).toHaveLength(0);
});
it("groups consecutive no-data days into a single gap", () => {
const dateRange = {
start: new Date("2026-01-01"),
end: new Date("2026-01-10"),
};
const dailySummaries: DailySummary[] = [
{ date: "2026-01-01", totalLitres: 100, recordCount: 2 },
{ date: "2026-01-05", totalLitres: 150, recordCount: 3 },
{ date: "2026-01-10", totalLitres: 200, recordCount: 1 },
];
const result = calculateGapData(dateRange, dailySummaries);
// Days 2,3,4 and 6,7,8,9 are gaps
expect(result.gaps).toHaveLength(2);
expect(result.gaps[0].duration).toBe(3); // Jan 2,3,4
expect(result.gaps[1].duration).toBe(4); // Jan 6,7,8,9
});
});
describe("getDayStatus", () => {
it("returns correct status for a day with data", () => {
const date = new Date("2026-01-06"); // Monday
const dailySummaries: DailySummary[] = [
{ date: "2026-01-06", totalLitres: 100, recordCount: 2 },
];
const result = getDayStatus(date, dailySummaries, []);
expect(result.hasData).toBe(true);
expect(result.litresUsed).toBe(100);
expect(result.eventCount).toBe(2);
expect(result.isWeekend).toBe(false);
});
it("returns correct status for a weekend day", () => {
const date = new Date("2026-01-04"); // Saturday
const dailySummaries: DailySummary[] = [];
const result = getDayStatus(date, dailySummaries, []);
expect(result.hasData).toBe(false);
expect(result.isWeekend).toBe(true);
});
it("returns gap position info when day is part of a gap", () => {
const date = new Date("2026-01-02");
const dailySummaries: DailySummary[] = [];
const gaps = [
{
startDate: new Date("2026-01-02"),
endDate: new Date("2026-01-04"),
duration: 3,
},
];
const result = getDayStatus(date, dailySummaries, gaps);
expect(result.gapInfo).toBeDefined();
expect(result.gapInfo?.position).toBe("start");
expect(result.gapInfo?.gapDuration).toBe(3);
});
});
});Step 2: Run test to verify it fails
Run: pnpm test:unit src/features/flow-meter/services/gapAnalysis.test.ts
Expected: FAIL with "Cannot find module './gapAnalysis'"
Step 3: Write minimal implementation
Create src/features/flow-meter/services/gapAnalysis.ts:
import {
eachDayOfInterval,
format,
isWeekend,
isSameDay,
isWithinInterval,
} from "date-fns";
import type { DailySummary, Gap, GapData, DayStatus, GapPosition } from "../types";
/**
* Calculate gap data from daily summaries within a date range.
* Identifies days without usage data and groups consecutive no-data days into gaps.
*/
export function calculateGapData(
dateRange: { start: Date; end: Date },
dailySummaries: DailySummary[]
): GapData {
// 1. Generate all days in the date range
const allDays = eachDayOfInterval({
start: dateRange.start,
end: dateRange.end,
});
// 2. Create set of days that have data from dailySummaries
const daysWithData = new Set<string>(
dailySummaries
.filter((d) => d.recordCount > 0)
.map((d) => d.date)
);
// 3. Find days without data
const daysWithoutData = allDays.filter(
(day) => !daysWithData.has(format(day, "yyyy-MM-dd"))
);
// 4. Group consecutive no-data days into gaps
const gaps: Gap[] = [];
let currentGapStart: Date | null = null;
let currentGapDays: Date[] = [];
for (const day of allDays) {
const dayStr = format(day, "yyyy-MM-dd");
const hasData = daysWithData.has(dayStr);
if (!hasData) {
if (currentGapStart === null) {
currentGapStart = day;
currentGapDays = [day];
} else {
currentGapDays.push(day);
}
} else {
if (currentGapStart !== null && currentGapDays.length > 0) {
gaps.push({
startDate: currentGapStart,
endDate: currentGapDays[currentGapDays.length - 1],
duration: currentGapDays.length,
});
currentGapStart = null;
currentGapDays = [];
}
}
}
// Don't forget the last gap if we ended on no-data days
if (currentGapStart !== null && currentGapDays.length > 0) {
gaps.push({
startDate: currentGapStart,
endDate: currentGapDays[currentGapDays.length - 1],
duration: currentGapDays.length,
});
}
return {
allDays,
daysWithData,
daysWithoutData,
gaps,
};
}
/**
* Get the status of a specific day including usage data and gap information.
*/
export function getDayStatus(
date: Date,
dailySummaries: DailySummary[],
gaps: Gap[]
): DayStatus {
const dateStr = format(date, "yyyy-MM-dd");
const summary = dailySummaries.find((d) => d.date === dateStr);
const hasData = summary !== undefined && summary.recordCount > 0;
// Find if this day is part of a gap
let gapInfo: DayStatus["gapInfo"] = undefined;
for (const gap of gaps) {
if (
isWithinInterval(date, { start: gap.startDate, end: gap.endDate })
) {
let position: GapPosition;
if (gap.duration === 1) {
position = "single";
} else if (isSameDay(date, gap.startDate)) {
position = "start";
} else if (isSameDay(date, gap.endDate)) {
position = "end";
} else {
position = "middle";
}
gapInfo = {
position,
gapDuration: gap.duration,
};
break;
}
}
return {
date,
hasData,
litresUsed: summary?.totalLitres ?? undefined,
eventCount: summary?.recordCount ?? undefined,
isWeekend: isWeekend(date),
gapInfo,
};
}Step 4: Run test to verify it passes
Run: pnpm test:unit src/features/flow-meter/services/gapAnalysis.test.ts
Expected: All tests PASS
Step 5: Commit
git add src/features/flow-meter/services/gapAnalysis.ts src/features/flow-meter/services/gapAnalysis.test.ts
git commit -m "feat(flow-meter): add gap analysis utility functions"Task 3: Create NoDataDaysTable Component
Files:
- Create:
src/features/flow-meter/components/UsageGapAnalysis/NoDataDaysTable.tsx
Step 1: Create the component
Create directory and file src/features/flow-meter/components/UsageGapAnalysis/NoDataDaysTable.tsx:
import { useMemo, useState } from "react";
import { Icon } from "@iconify/react";
import { format, isWeekend } from "date-fns";
import type { Gap, DayStatus } from "../../types";
import { getDayStatus } from "../../services/gapAnalysis";
import type { DailySummary } from "../../types";
interface NoDataDaysTableProps {
daysWithoutData: Date[];
dailySummaries: DailySummary[];
gaps: Gap[];
}
const PAGE_SIZE = 10;
export function NoDataDaysTable({
daysWithoutData,
dailySummaries,
gaps,
}: NoDataDaysTableProps) {
const [currentPage, setCurrentPage] = useState(1);
const dayStatuses = useMemo(() => {
return daysWithoutData
.map((date) => getDayStatus(date, dailySummaries, gaps))
.sort((a, b) => b.date.getTime() - a.date.getTime()); // Newest first
}, [daysWithoutData, dailySummaries, gaps]);
const totalPages = Math.ceil(dayStatuses.length / PAGE_SIZE);
const paginatedDays = dayStatuses.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
);
if (daysWithoutData.length === 0) {
return (
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-700 p-6">
<div className="flex items-center gap-3">
<Icon
className="w-6 h-6 text-green-600 dark:text-green-400"
icon="solar:check-circle-bold"
/>
<p className="text-green-800 dark:text-green-200 font-medium">
No gaps found - all days have usage data
</p>
</div>
</div>
);
}
const formatGapDuration = (status: DayStatus): string => {
if (!status.gapInfo) return "-";
const { position, gapDuration } = status.gapInfo;
if (position === "single") return `1 day gap`;
if (position === "start") return `Start of ${gapDuration}-day gap`;
if (position === "end") return `End of gap`;
return "-";
};
return (
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<h4 className="text-sm font-semibold text-slate-800 dark:text-slate-200">
Days Without Usage Data
</h4>
<p className="text-xs text-slate-600 dark:text-slate-400 mt-1">
{daysWithoutData.length} day{daysWithoutData.length !== 1 ? "s" : ""}{" "}
with no recorded usage
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead className="bg-slate-50 dark:bg-slate-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-600 dark:text-slate-400">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-600 dark:text-slate-400">
Day of Week
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-600 dark:text-slate-400">
Gap Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-600 dark:text-slate-400">
Notes
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700 bg-white dark:bg-slate-800">
{paginatedDays.map((status) => (
<tr
key={format(status.date, "yyyy-MM-dd")}
className={
status.isWeekend
? "bg-slate-50/50 dark:bg-slate-900/30"
: ""
}
>
<td className="whitespace-nowrap px-6 py-4 text-sm text-slate-900 dark:text-slate-100">
{format(status.date, "dd MMM yyyy")}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-slate-600 dark:text-slate-400">
{format(status.date, "EEEE")}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-slate-600 dark:text-slate-400">
{formatGapDuration(status)}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm">
{status.isWeekend && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-xs">
<Icon className="w-3 h-3" icon="solar:calendar-bold" />
Weekend
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-slate-50 dark:bg-slate-900 px-6 py-4 flex items-center justify-between border-t border-slate-200 dark:border-slate-700">
<div className="text-sm text-slate-600 dark:text-slate-400">
Showing {(currentPage - 1) * PAGE_SIZE + 1} to{" "}
{Math.min(currentPage * PAGE_SIZE, dayStatuses.length)} of{" "}
{dayStatuses.length} days
</div>
<div className="flex items-center gap-2">
<button
className="inline-flex items-center gap-1 px-3 py-1 rounded bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={currentPage === 1}
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
>
<Icon
className="h-4 w-4"
icon="solar:alt-arrow-left-bold-duotone"
/>
Previous
</button>
<span className="text-sm text-slate-600 dark:text-slate-400">
Page {currentPage} of {totalPages}
</span>
<button
className="inline-flex items-center gap-1 px-3 py-1 rounded bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
>
Next
<Icon
className="h-4 w-4"
icon="solar:alt-arrow-right-bold-duotone"
/>
</button>
</div>
</div>
)}
</div>
);
}Step 2: Verify no TypeScript errors
Run: pnpm build:check
Expected: Build succeeds without errors
Step 3: Commit
git add src/features/flow-meter/components/UsageGapAnalysis/NoDataDaysTable.tsx
git commit -m "feat(flow-meter): add NoDataDaysTable component"Task 4: Create UsageGapChart Component
Files:
- Create:
src/features/flow-meter/components/UsageGapAnalysis/UsageGapChart.tsx
Step 1: Create the timeline gap chart component
Create src/features/flow-meter/components/UsageGapAnalysis/UsageGapChart.tsx:
import { useMemo } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Cell,
ResponsiveContainer,
} from "recharts";
import { format, differenceInDays } from "date-fns";
import type { Gap } from "../../types";
interface UsageGapChartProps {
dateRange: { start: Date; end: Date };
gaps: Gap[];
daysWithData: Set<string>;
}
interface ChartSegment {
date: string;
displayDate: string;
value: number;
hasData: boolean;
gapDuration?: number;
gapStart?: string;
gapEnd?: string;
}
export function UsageGapChart({
dateRange,
gaps,
daysWithData,
}: UsageGapChartProps) {
const totalDays = differenceInDays(dateRange.end, dateRange.start) + 1;
const daysWithDataCount = daysWithData.size;
const daysWithoutDataCount = totalDays - daysWithDataCount;
const longestGap = gaps.reduce(
(max, gap) => Math.max(max, gap.duration),
0
);
// Create chart data - each segment represents a day
const chartData = useMemo(() => {
const data: ChartSegment[] = [];
const currentDate = new Date(dateRange.start);
while (currentDate <= dateRange.end) {
const dateStr = format(currentDate, "yyyy-MM-dd");
const hasData = daysWithData.has(dateStr);
// Find if this day is part of a gap
const gap = gaps.find(
(g) => currentDate >= g.startDate && currentDate <= g.endDate
);
data.push({
date: dateStr,
displayDate: format(currentDate, "dd MMM"),
value: 1,
hasData,
gapDuration: gap?.duration,
gapStart: gap ? format(gap.startDate, "dd MMM") : undefined,
gapEnd: gap ? format(gap.endDate, "dd MMM") : undefined,
});
currentDate.setDate(currentDate.getDate() + 1);
}
return data;
}, [dateRange, gaps, daysWithData]);
return (
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
{/* Summary Stats */}
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-semibold text-slate-800 dark:text-slate-200">
Gap Timeline
</h4>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-2">
<span className="text-slate-600 dark:text-slate-400">
Total Days:
</span>
<span className="font-semibold text-slate-800 dark:text-slate-200">
{totalDays}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm bg-green-500" />
<span className="text-slate-600 dark:text-slate-400">
With Data:
</span>
<span className="font-semibold text-green-600 dark:text-green-400">
{daysWithDataCount}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm bg-red-400" />
<span className="text-slate-600 dark:text-slate-400">Gaps:</span>
<span className="font-semibold text-red-600 dark:text-red-400">
{daysWithoutDataCount}
</span>
</div>
{longestGap > 0 && (
<div className="flex items-center gap-2">
<span className="text-slate-600 dark:text-slate-400">
Longest Gap:
</span>
<span className="font-semibold text-amber-600 dark:text-amber-400">
{longestGap} day{longestGap !== 1 ? "s" : ""}
</span>
</div>
)}
</div>
</div>
{/* Timeline Chart */}
<ResponsiveContainer width="100%" height={60}>
<BarChart data={chartData} layout="horizontal" barGap={0} barCategoryGap={0}>
<XAxis
dataKey="displayDate"
tick={{ fontSize: 10 }}
interval="preserveStartEnd"
tickLine={false}
axisLine={{ stroke: "#e5e7eb" }}
/>
<YAxis hide domain={[0, 1]} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload as ChartSegment;
return (
<div className="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg px-3 py-2 shadow-lg">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
{data.displayDate}
</p>
{data.hasData ? (
<p className="text-xs text-green-600 dark:text-green-400">
✓ Has usage data
</p>
) : (
<div>
<p className="text-xs text-red-600 dark:text-red-400">
✗ No usage data
</p>
{data.gapDuration && (
<p className="text-xs text-slate-600 dark:text-slate-400 mt-1">
Part of {data.gapDuration}-day gap
<br />({data.gapStart} - {data.gapEnd})
</p>
)}
</div>
)}
</div>
);
}}
/>
<Bar dataKey="value" radius={[2, 2, 2, 2]}>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.hasData ? "#22c55e" : "#f87171"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
{/* Legend */}
<div className="flex items-center justify-center gap-6 mt-3 text-xs text-slate-600 dark:text-slate-400">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-green-500" />
<span>Days with usage</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-red-400" />
<span>No usage (gap)</span>
</div>
</div>
</div>
);
}Step 2: Verify no TypeScript errors
Run: pnpm build:check
Expected: Build succeeds without errors
Step 3: Commit
git add src/features/flow-meter/components/UsageGapAnalysis/UsageGapChart.tsx
git commit -m "feat(flow-meter): add UsageGapChart timeline component"Task 5: Create UsageCalendarHeatmap Component
Files:
- Create:
src/features/flow-meter/components/UsageGapAnalysis/UsageCalendarHeatmap.tsx
Step 1: Create the calendar heatmap component
Create src/features/flow-meter/components/UsageGapAnalysis/UsageCalendarHeatmap.tsx:
import { useMemo } from "react";
import {
format,
startOfMonth,
endOfMonth,
eachDayOfInterval,
getDay,
isSameMonth,
isAfter,
} from "date-fns";
import type { DailySummary } from "../../types";
interface UsageCalendarHeatmapProps {
dateRange: { start: Date; end: Date };
dailySummaries: DailySummary[];
}
interface CalendarDay {
date: Date;
dayOfMonth: number;
isInRange: boolean;
isFuture: boolean;
hasData: boolean;
litresUsed: number | null;
eventCount: number;
}
interface MonthData {
month: Date;
label: string;
weeks: CalendarDay[][];
}
const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export function UsageCalendarHeatmap({
dateRange,
dailySummaries,
}: UsageCalendarHeatmapProps) {
const today = new Date();
// Create lookup map for daily summaries
const summaryMap = useMemo(() => {
const map = new Map<string, DailySummary>();
dailySummaries.forEach((s) => map.set(s.date, s));
return map;
}, [dailySummaries]);
// Generate months to display
const months = useMemo(() => {
const result: MonthData[] = [];
let current = startOfMonth(dateRange.start);
const rangeEnd = endOfMonth(dateRange.end);
while (current <= rangeEnd) {
const monthStart = startOfMonth(current);
const monthEnd = endOfMonth(current);
// Get all days in the month
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd });
// Organize into weeks (Monday = 0, Sunday = 6)
const weeks: CalendarDay[][] = [];
let currentWeek: CalendarDay[] = [];
// Add empty cells for days before the first day of the month
const firstDayOfWeek = (getDay(monthStart) + 6) % 7; // Monday = 0
for (let i = 0; i < firstDayOfWeek; i++) {
currentWeek.push({
date: new Date(0),
dayOfMonth: 0,
isInRange: false,
isFuture: false,
hasData: false,
litresUsed: null,
eventCount: 0,
});
}
// Add actual days
for (const day of daysInMonth) {
const dateStr = format(day, "yyyy-MM-dd");
const summary = summaryMap.get(dateStr);
const isInRange = day >= dateRange.start && day <= dateRange.end;
const isFuture = isAfter(day, today);
currentWeek.push({
date: day,
dayOfMonth: day.getDate(),
isInRange,
isFuture,
hasData: summary !== undefined && summary.recordCount > 0,
litresUsed: summary?.totalLitres ?? null,
eventCount: summary?.recordCount ?? 0,
});
if (currentWeek.length === 7) {
weeks.push(currentWeek);
currentWeek = [];
}
}
// Add remaining days to last week
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push({
date: new Date(0),
dayOfMonth: 0,
isInRange: false,
isFuture: false,
hasData: false,
litresUsed: null,
eventCount: 0,
});
}
weeks.push(currentWeek);
}
result.push({
month: current,
label: format(current, "MMMM yyyy"),
weeks,
});
// Move to next month
current = new Date(current.getFullYear(), current.getMonth() + 1, 1);
}
return result;
}, [dateRange, summaryMap, today]);
const getCellColor = (day: CalendarDay): string => {
if (!day.dayOfMonth || !day.isInRange) {
return "bg-slate-100 dark:bg-slate-800";
}
if (day.isFuture) {
return "bg-slate-200 dark:bg-slate-700";
}
if (day.hasData) {
return "bg-green-500 dark:bg-green-600 hover:bg-green-600 dark:hover:bg-green-500";
}
return "bg-red-400 dark:bg-red-500 hover:bg-red-500 dark:hover:bg-red-400";
};
return (
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
<h4 className="text-sm font-semibold text-slate-800 dark:text-slate-200 mb-4">
Usage Calendar
</h4>
<div className="flex gap-6 overflow-x-auto pb-2">
{months.map((monthData) => (
<div key={monthData.label} className="flex-shrink-0">
{/* Month Label */}
<div className="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2 text-center">
{monthData.label}
</div>
{/* Weekday Headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAY_LABELS.map((day) => (
<div
key={day}
className="w-7 h-5 flex items-center justify-center text-[10px] font-medium text-slate-500 dark:text-slate-500"
>
{day[0]}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="space-y-1">
{monthData.weeks.map((week, weekIdx) => (
<div key={weekIdx} className="grid grid-cols-7 gap-1">
{week.map((day, dayIdx) => (
<div
key={dayIdx}
className={`w-7 h-7 rounded-md flex items-center justify-center text-[10px] font-medium transition-colors cursor-default ${getCellColor(day)} ${
day.hasData && day.isInRange && !day.isFuture
? "text-white"
: day.dayOfMonth && day.isInRange && !day.isFuture
? "text-white"
: "text-slate-400 dark:text-slate-600"
}`}
title={
day.dayOfMonth && day.isInRange && !day.isFuture
? `${format(day.date, "dd MMM yyyy")}\n${
day.hasData
? `${day.litresUsed?.toLocaleString() ?? 0} L (${day.eventCount} events)`
: "No usage data"
}`
: undefined
}
>
{day.dayOfMonth || ""}
</div>
))}
</div>
))}
</div>
</div>
))}
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-6 mt-4 pt-4 border-t border-slate-200 dark:border-slate-700 text-xs text-slate-600 dark:text-slate-400">
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded bg-green-500" />
<span>Has usage data</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded bg-red-400" />
<span>No usage data</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded bg-slate-200 dark:bg-slate-700" />
<span>Future / Out of range</span>
</div>
</div>
</div>
);
}Step 2: Verify no TypeScript errors
Run: pnpm build:check
Expected: Build succeeds without errors
Step 3: Commit
git add src/features/flow-meter/components/UsageGapAnalysis/UsageCalendarHeatmap.tsx
git commit -m "feat(flow-meter): add UsageCalendarHeatmap component"Task 6: Create UsageGapAnalysis Main Wrapper
Files:
- Create:
src/features/flow-meter/components/UsageGapAnalysis/index.tsx
Step 1: Create the main wrapper component
Create src/features/flow-meter/components/UsageGapAnalysis/index.tsx:
import { useState, useEffect, useMemo } from "react";
import { Icon } from "@iconify/react";
import { calculateGapData } from "../../services/gapAnalysis";
import { UsageCalendarHeatmap } from "./UsageCalendarHeatmap";
import { UsageGapChart } from "./UsageGapChart";
import { NoDataDaysTable } from "./NoDataDaysTable";
import type { DailySummary } from "../../types";
interface UsageGapAnalysisProps {
dateRange: { start: Date; end: Date };
dailySummaries: DailySummary[];
siteName: string;
}
const STORAGE_KEY = "flow-meter-usage-gap-analysis-collapsed";
export function UsageGapAnalysis({
dateRange,
dailySummaries,
siteName,
}: UsageGapAnalysisProps) {
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved === "true";
});
// Persist collapsed state
useEffect(() => {
localStorage.setItem(STORAGE_KEY, String(isCollapsed));
}, [isCollapsed]);
const gapData = useMemo(() => {
return calculateGapData(dateRange, dailySummaries);
}, [dateRange, dailySummaries]);
return (
<div className="mb-6">
{/* Section Header */}
<button
className="w-full flex items-center justify-between px-6 py-4 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className="flex items-center gap-3">
<Icon
className="w-5 h-5 text-amber-600 dark:text-amber-400"
icon="solar:calendar-search-bold-duotone"
/>
<div className="text-left">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Usage Gap Analysis
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
{gapData.daysWithoutData.length === 0
? "All days have usage data"
: `${gapData.daysWithoutData.length} day${gapData.daysWithoutData.length !== 1 ? "s" : ""} without data, ${gapData.gaps.length} gap${gapData.gaps.length !== 1 ? "s" : ""} found`}
</p>
</div>
</div>
<Icon
icon="solar:alt-arrow-down-linear"
className={`w-5 h-5 text-slate-600 dark:text-slate-400 transition-transform ${
isCollapsed ? "" : "rotate-180"
}`}
/>
</button>
{/* Collapsible Content */}
{!isCollapsed && (
<div className="mt-4 space-y-4">
{/* Calendar Heatmap and Gap Chart Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<UsageCalendarHeatmap
dateRange={dateRange}
dailySummaries={dailySummaries}
/>
<UsageGapChart
dateRange={dateRange}
gaps={gapData.gaps}
daysWithData={gapData.daysWithData}
/>
</div>
{/* No Data Days Table */}
<NoDataDaysTable
daysWithoutData={gapData.daysWithoutData}
dailySummaries={dailySummaries}
gaps={gapData.gaps}
/>
</div>
)}
</div>
);
}Step 2: Verify no TypeScript errors
Run: pnpm build:check
Expected: Build succeeds without errors
Step 3: Commit
git add src/features/flow-meter/components/UsageGapAnalysis/index.tsx
git commit -m "feat(flow-meter): add UsageGapAnalysis main wrapper component"Task 7: Integrate UsageGapAnalysis into Flow Meter Page
Files:
- Modify:
src/app/(admin)/(pages)/flow-meter/index.tsx
Step 1: Add import at top of file
Find the import section (around lines 1-40) and add after the existing flow-meter component imports:
import { UsageGapAnalysis } from "@/features/flow-meter/components/UsageGapAnalysis";Step 2: Add UsageGapAnalysis component after Records Table section
Find the closing </> of the {selectedSiteSummary && selectedSiteSummary.records.length > 0 && (...)} block (around line 1093). Add the UsageGapAnalysis component right before the {/* No Data Message for Selected Site */} comment (around line 1095):
{/* Usage Gap Analysis Section */}
{selectedSiteSummary && (
<UsageGapAnalysis
dateRange={dateRange}
dailySummaries={selectedSiteSummary.dailySummaries}
siteName={selectedSite || ""}
/>
)}
{/* No Data Message for Selected Site */}Step 3: Verify no TypeScript errors
Run: pnpm build:check
Expected: Build succeeds without errors
Step 4: Verify the feature works
Run: pnpm dev
Then open http://localhost:3000/flow-meter in a browser, select a site, and verify:
- The "Usage Gap Analysis" collapsible section appears below the Records Table
- Calendar heatmap shows days with green (has data) and red (no data)
- Gap timeline chart displays correctly
- No-data days table lists days without data
- Section collapses/expands and remembers state after page reload
Step 5: Commit
git add src/app/(admin)/(pages)/flow-meter/index.tsx
git commit -m "feat(flow-meter): integrate UsageGapAnalysis into Flow Meter page"Task 8: Final Verification and Cleanup
Step 1: Run all tests
Run: pnpm test:unit
Expected: All tests pass
Step 2: Run linter
Run: pnpm lint
Expected: No new errors (warnings under 500 limit)
Step 3: Run full build
Run: pnpm build
Expected: Build succeeds
Step 4: Final commit
git add -A
git commit -m "chore: finalize usage gap analysis feature"Summary
This implementation plan creates the Usage Gap Analysis feature with:
- Types (
types.ts) - Gap, GapData, DayStatus interfaces - Utility (
gapAnalysis.ts) - calculateGapData and getDayStatus functions with tests - NoDataDaysTable - Paginated table listing days without data
- UsageGapChart - Timeline visualization with Recharts
- UsageCalendarHeatmap - Month-view calendar with color-coded days
- UsageGapAnalysis - Main collapsible wrapper component
- Integration - Added to Flow Meter page below Records Table
All components follow existing patterns in the codebase:
- Tailwind CSS for styling with dark mode support
- Iconify for icons (solar icon set)
- Recharts for charts
- date-fns for date manipulation
- localStorage for persisting UI state