{{ theme.skipToContentLabel || 'Skip to content' }}

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:

typescript
// 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

bash
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:

typescript
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:

typescript
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

bash
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:

typescript
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

bash
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:

typescript
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

bash
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:

typescript
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

bash
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:

typescript
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

bash
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:

typescript
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):

tsx
        {/* 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:

  1. The "Usage Gap Analysis" collapsible section appears below the Records Table
  2. Calendar heatmap shows days with green (has data) and red (no data)
  3. Gap timeline chart displays correctly
  4. No-data days table lists days without data
  5. Section collapses/expands and remembers state after page reload

Step 5: Commit

bash
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

bash
git add -A
git commit -m "chore: finalize usage gap analysis feature"

Summary

This implementation plan creates the Usage Gap Analysis feature with:

  1. Types (types.ts) - Gap, GapData, DayStatus interfaces
  2. Utility (gapAnalysis.ts) - calculateGapData and getDayStatus functions with tests
  3. NoDataDaysTable - Paginated table listing days without data
  4. UsageGapChart - Timeline visualization with Recharts
  5. UsageCalendarHeatmap - Month-view calendar with color-coded days
  6. UsageGapAnalysis - Main collapsible wrapper component
  7. 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