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

Flow Meter PDF Export Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add PDF export functionality to the Flow Meter feature using the existing shared export page infrastructure, including AI-generated chart descriptions via a new Edge Function.

Architecture: Create a dedicated /flow-meter/export route that reuses useExportPage hook and shared UI components. A new FlowMeterPdfExportService handles PDF generation (jsPDF + modern-screenshot). A new generate-flow-meter-descriptions Edge Function provides AI chart descriptions. The main Flow Meter page's Export button navigates to the export page instead of doing PNG screenshot.

Tech Stack: React 19, TypeScript 5, jsPDF, modern-screenshot, Supabase Edge Functions (Deno), Tailwind CSS


Task 1: Create Flow Meter chart config

Files:

  • Create: src/features/flow-meter/config/chartConfig.ts

Step 1: Create the chart config file

typescript
// src/features/flow-meter/config/chartConfig.ts

export interface FlowMeterChartConfig {
  key: string;
  label: string;
  icon: string;
  defaultSelected: boolean;
}

export const FLOW_METER_CHART_CONFIGS: FlowMeterChartConfig[] = [
  {
    key: "dailySummary",
    label: "Daily Dustloc Usage Summary",
    icon: "solar:chart-2-linear",
    defaultSelected: true,
  },
  {
    key: "waterUsageTimeline",
    label: "Dustloc Dispensing Timeline",
    icon: "solar:graph-linear",
    defaultSelected: true,
  },
  {
    key: "calendarHeatmap",
    label: "Usage Gap Calendar Heatmap",
    icon: "solar:calendar-linear",
    defaultSelected: true,
  },
  {
    key: "gapChart",
    label: "Gap Analysis Chart",
    icon: "solar:chart-square-linear",
    defaultSelected: false,
  },
  {
    key: "noDataDays",
    label: "No Data Days Table",
    icon: "solar:document-text-linear",
    defaultSelected: false,
  },
];

Step 2: Verify no TypeScript errors

Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -20

Step 3: Commit

bash
git add src/features/flow-meter/config/chartConfig.ts
git commit -m "feat(flow-meter): add chart config for PDF export"

Task 2: Create FlowMeterPdfExportService

Files:

  • Create: src/features/flow-meter/services/flowMeterPdfExportService.ts

This service follows the exact same pattern as DustLevelExportService in src/features/dust-levels/services/exportService.ts. It uses jsPDF + modern-screenshot to generate branded PDFs.

Step 1: Create the export service

The service is a static class with exportReport(config, siteSummary, onProgress?). Key differences from DustLevelExportService:

  • Header text: "DUSTLOC FLOW METER REPORT" instead of "DUST LEVEL MONITORING REPORT"
  • Cover page title: "Dustloc Flow Meter" instead of "Dust Level"
  • Cover page subtitle: "Usage Report" instead of "Monitoring"
  • Summary cards show flow meter metrics instead of dust metrics:
    • Card 1 (orange): "Total Dustloc Used" — siteSummary.totalLitres.toLocaleString() — "Litres"
    • Card 2 (dark): "Total Refilled" — (siteSummary.totalRefilled ?? 0).toLocaleString() — "Litres"
    • Card 3: "Days Recorded" — siteSummary.dailySummaries.length.toString() — "Days"
    • Card 4: "Daily Average" — (siteSummary.totalLitres / Math.max(siteSummary.dailySummaries.length, 1)).toFixed(0) — "L/day"
  • PDF filename: flow-meter-report-{siteName}-{timestamp}.pdf

Everything else (background shapes, header, chart capture via [data-export-chart], KEY INSIGHTS boxes, summary section, footer) is identical to DustLevelExportService.

The config interface:

typescript
export interface FlowMeterExportConfig {
  includeCharts: Record<string, boolean>;
  chartDescriptions: Record<string, string>;
  siteName: string;
  description?: string;
  dateRange?: string;
  model?: string;
  timezone?: string;
  orientation?: "portrait" | "landscape";
}

Copy the full DustLevelExportService class from src/features/dust-levels/services/exportService.ts, rename to FlowMeterPdfExportService, and make these specific changes:

  1. Replace import type { ExportConfig } from "../components/ExportModal" with the local FlowMeterExportConfig interface above
  2. Replace import type { SiteSummary } from "../types" with import type { SiteSummary } from "../types" (same import, different feature path)
  3. In drawHeader(): Change "DUST LEVEL MONITORING REPORT" to "DUSTLOC FLOW METER REPORT"
  4. In landscape cover page title section: Change "Dust Level Monitoring" to "Dustloc Flow Meter" and site name subtitle stays the same
  5. In portrait cover page title section: Change "Dust Level" + "Monitoring" to "Dustloc Flow" + "Meter Report"
  6. Replace all 4 summary card calls (both landscape drawCardLandscape and portrait drawCard) with flow meter metrics:
    • Card 1 (orange): title="Total Dustloc Used", value=siteSummary.totalLitres.toLocaleString(), unit="Litres"
    • Card 2 (dark): title="Total Refilled", value=(siteSummary.totalRefilled ?? 0).toLocaleString(), unit="Litres"
    • Card 3 (default): title="Days Recorded", value=siteSummary.dailySummaries.length.toString(), unit="Days"
    • Card 4 (default): title="Daily Average", value=(siteSummary.totalLitres / Math.max(siteSummary.dailySummaries.length, 1)).toFixed(0), unit="L/day"
  7. Remove getLegacyCharts() method entirely — only use getChartElements() with [data-export-chart] discovery
  8. Change filename: flow-meter-report-${config.siteName.replace(/\s+/g, "-")}-${Date.now()}.pdf
  9. Remove the displayTitle special case for chart.id === "dust-level-temp-chart" — not needed for flow meter

Step 2: Verify no TypeScript errors

Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -20

Step 3: Commit

bash
git add src/features/flow-meter/services/flowMeterPdfExportService.ts
git commit -m "feat(flow-meter): add PDF export service"

Task 3: Create the Flow Meter export page

Files:

  • Create: src/app/(admin)/(pages)/flow-meter/export.tsx

Step 1: Create the export page component

This follows the exact pattern of src/app/(admin)/(pages)/dust-levels/export.tsx but is much simpler (no geofence logic).

typescript
import { useCallback, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Icon } from "@iconify/react";
import PageMeta from "@/components/PageMeta";
import { ChartWithDescription } from "@/components/shared/ChartWithDescription";
import { DailySummaryChart } from "@/features/flow-meter/components/charts/DailySummaryChart";
import { WaterUsageTimelineChart } from "@/features/flow-meter/components/charts/WaterUsageTimelineChart";
import { UsageCalendarHeatmap } from "@/features/flow-meter/components/UsageGapAnalysis/UsageCalendarHeatmap";
import { UsageGapChart } from "@/features/flow-meter/components/UsageGapAnalysis/UsageGapChart";
import { NoDataDaysTable } from "@/features/flow-meter/components/UsageGapAnalysis/NoDataDaysTable";
import { calculateGapData } from "@/features/flow-meter/services/gapAnalysis";
import { FlowMeterPdfExportService } from "@/features/flow-meter/services/flowMeterPdfExportService";
import type { FlowMeterExportConfig } from "@/features/flow-meter/services/flowMeterPdfExportService";
import type { SiteSummary, DailySummary, FlowMeterRecord, DustLocRefill } from "@/features/flow-meter/types";
import { useExportPage } from "@/features/exports/hooks/useExportPage";
import { ReportNameEditor } from "@/features/exports/components/ReportNameEditor";
import { AIControlsPanel } from "@/features/exports/components/AIControlsPanel";
import { ExportSummarySection } from "@/features/exports/components/ExportSummarySection";
import { ExportProgressModal } from "@/components/shared/ExportModal/ExportProgressModal";
import { formatExportDate } from "@/features/exports/utils";
import type { BaseExportConfig, ChartOption, OnExportProgress } from "@/features/exports/types";
import { FLOW_METER_CHART_CONFIGS } from "@/features/flow-meter/config/chartConfig";
import { usePageHeaderActions } from "@/hooks/usePageHeaderActions";

type ExportPageState = {
  siteSummary: SiteSummary;
  selectedSite: string;
  dateRange: { start: Date; end: Date };
  chartType: "line" | "scatter";
};

export default function FlowMeterExportPage() {
  const navigate = useNavigate();
  const location = useLocation();
  const state = (location.state ?? null) as ExportPageState | null;

  const hasState = Boolean(state?.siteSummary);
  const siteSummary = state?.siteSummary ?? null;
  const selectedSite = state?.selectedSite ?? siteSummary?.site ?? "";
  const dateRange = state?.dateRange;
  const chartType = state?.chartType ?? "line";

  // Calculate gap data for UsageGapAnalysis sub-charts
  const gapData = useMemo(() => {
    if (!siteSummary || !dateRange) return null;
    return calculateGapData(dateRange, siteSummary.dailySummaries);
  }, [siteSummary, dateRange]);

  // Active (non-ignored) records for timeline chart
  const activeRecords = useMemo(() => {
    if (!siteSummary) return [];
    return siteSummary.records.filter((r) => !r.isIgnored);
  }, [siteSummary]);

  const chartOptions = useMemo<ChartOption[]>(() => {
    return FLOW_METER_CHART_CONFIGS.map((cfg) => ({
      key: cfg.key,
      label: cfg.label,
      defaultSelected: cfg.defaultSelected,
    }));
  }, []);

  const buildAIRequestBody = useCallback(
    (chartKey: string, model: string, style: string) => {
      return {
        model,
        style,
        flowMeterStats: {
          totalLitres: siteSummary?.totalLitres ?? 0,
          totalRefilled: siteSummary?.totalRefilled ?? 0,
          dailyAverage: siteSummary
            ? siteSummary.totalLitres / Math.max(siteSummary.dailySummaries.length, 1)
            : 0,
          refillCount: siteSummary?.refills?.length ?? 0,
          totalRecords: activeRecords.length,
          daysRecorded: siteSummary?.dailySummaries.length ?? 0,
          siteName: selectedSite,
        },
        singleChart: chartKey,
        chartData: {
          dailySummaries: siteSummary?.dailySummaries ?? [],
          records: activeRecords.slice(0, 500), // Limit to avoid payload size issues
          refills: siteSummary?.refills ?? [],
        },
      };
    },
    [activeRecords, selectedSite, siteSummary]
  );

  const handleExportPdf = useCallback(
    async (config: BaseExportConfig, onProgress?: OnExportProgress) => {
      if (!siteSummary) return;
      const exportConfig: FlowMeterExportConfig = {
        includeCharts: config.includeCharts,
        chartDescriptions: config.chartDescriptions,
        siteName: config.name,
        description: "",
        dateRange: config.dateRange,
        model: config.model,
        timezone: config.timezone,
        orientation: config.orientation,
      };
      await FlowMeterPdfExportService.exportReport(exportConfig, siteSummary, onProgress);
    },
    [siteSummary]
  );

  const ep = useExportPage({
    storageKey: `flow-meter-${selectedSite || "unknown"}`,
    chartOptions,
    hasData: Boolean(siteSummary),
    initialName: selectedSite,
    edgeFunctionName: "generate-flow-meter-descriptions",
    buildAIRequestBody,
    onExport: handleExportPdf,
    dateRangeString: dateRange
      ? `${formatExportDate(dateRange.start)} - ${formatExportDate(dateRange.end)}`
      : undefined,
  });

  // Register Back + Export PDF buttons in the page header
  const headerAction = useMemo(
    () => (
      <div className="flex items-center gap-2">
        <button
          className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 transition-colors"
          type="button"
          onClick={() => { void navigate("/flow-meter"); }}
        >
          <Icon className="w-3.5 h-3.5" icon="solar:arrow-left-linear" />
          Back
        </button>
        <button
          className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
          disabled={ep.exporting}
          type="button"
          onClick={() => { void ep.handleExport(); }}
        >
          {ep.exporting ? (
            <>
              <Icon className="w-3.5 h-3.5 animate-spin" icon="svg-spinners:ring-resize" />
              Exporting...
            </>
          ) : (
            <>
              <Icon className="w-3.5 h-3.5" icon="solar:export-bold" />
              Export PDF
            </>
          )}
        </button>
      </div>
    ),
    [ep.exporting, ep.handleExport, navigate]
  );
  usePageHeaderActions(headerAction);

  if (!hasState || !siteSummary) {
    return (
      <>
        <PageMeta title="Export Report" />
        <main>
          <div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-8 text-center">
            <Icon className="mx-auto mb-3 h-10 w-10 text-slate-400" icon="solar:info-circle-bold" />
            <h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
              No export context found
            </h2>
            <p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
              Please choose a site in Flow Meter and click Export PDF again.
            </p>
            <button
              className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
              type="button"
              onClick={() => navigate("/flow-meter")}
            >
              <Icon className="w-4 h-4" icon="solar:arrow-left-bold" />
              Back to Flow Meter
            </button>
          </div>
        </main>
      </>
    );
  }

  return (
    <>
      <PageMeta title={`Export Report - ${selectedSite}`} />
      <main>
        <div className="space-y-8">
          <ReportNameEditor
            label="Report Site Name"
            value={ep.editableName}
            originalValue={selectedSite}
            onChange={ep.setEditableName}
            onExport={ep.handleExport}
            backPath="/flow-meter"
            exporting={ep.exporting}
            canExport={Boolean(siteSummary)}
          />

          <AIControlsPanel
            selectedModel={ep.selectedModel}
            selectedStyle={ep.selectedStyle}
            selectedTimezone={ep.selectedTimezone}
            selectedOrientation={ep.selectedOrientation}
            onModelChange={ep.setSelectedModel}
            onStyleChange={ep.setSelectedStyle}
            onTimezoneChange={ep.setSelectedTimezone}
            onOrientationChange={ep.setSelectedOrientation}
            onGenerateAll={ep.handleGenerateAll}
            onLoadAllSaved={ep.handleLoadAllSavedDescriptions}
            hasAnySavedDescriptions={ep.hasAnySavedDescriptions}
            generatingAll={ep.generatingAll}
            generatingChartsCount={ep.generatingCharts.size}
            selectedChartsCount={Object.values(ep.selectedCharts).filter(Boolean).length}
          />

          <section className="space-y-4">
            <div className="flex items-center justify-between">
              <div>
                <h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
                  Charts Preview
                </h2>
                <p className="text-sm text-slate-600 dark:text-slate-400">
                  Review the charts while editing descriptions for the export.
                </p>
              </div>
              <span className="text-xs text-slate-500 dark:text-slate-400">
                Using current selections from Flow Meter
              </span>
            </div>

            <div className="space-y-6">
              {/* Daily Summary Chart */}
              <ChartWithDescription
                chartKey="dailySummary"
                title="Daily Dustloc Usage Summary"
                siteName={selectedSite}
                included={ep.selectedCharts["dailySummary"] ?? false}
                description={ep.chartDescriptions["dailySummary"] ?? ""}
                generating={ep.generatingCharts.has("dailySummary")}
                generatingAll={ep.generatingAll}
                hasSavedDescription={Boolean(ep.savedDescriptions["dailySummary"])}
                onToggle={() => { ep.handleToggleChart("dailySummary"); }}
                onDescriptionChange={(value) => { ep.handleUpdateDescription("dailySummary", value); }}
                onGenerate={() => ep.generateSingleDescription("dailySummary")}
                onLoadSaved={() => { ep.handleLoadSavedDescription("dailySummary"); }}
              >
                <DailySummaryChart data={siteSummary.dailySummaries} hideSummary />
              </ChartWithDescription>

              {/* Water Usage Timeline Chart */}
              <ChartWithDescription
                chartKey="waterUsageTimeline"
                title="Dustloc Dispensing Timeline"
                siteName={selectedSite}
                included={ep.selectedCharts["waterUsageTimeline"] ?? false}
                description={ep.chartDescriptions["waterUsageTimeline"] ?? ""}
                generating={ep.generatingCharts.has("waterUsageTimeline")}
                generatingAll={ep.generatingAll}
                hasSavedDescription={Boolean(ep.savedDescriptions["waterUsageTimeline"])}
                onToggle={() => { ep.handleToggleChart("waterUsageTimeline"); }}
                onDescriptionChange={(value) => { ep.handleUpdateDescription("waterUsageTimeline", value); }}
                onGenerate={() => ep.generateSingleDescription("waterUsageTimeline")}
                onLoadSaved={() => { ep.handleLoadSavedDescription("waterUsageTimeline"); }}
              >
                <WaterUsageTimelineChart data={activeRecords} chartType={chartType} />
              </ChartWithDescription>

              {/* Calendar Heatmap */}
              {dateRange && gapData && (
                <ChartWithDescription
                  chartKey="calendarHeatmap"
                  title="Usage Gap Calendar Heatmap"
                  siteName={selectedSite}
                  included={ep.selectedCharts["calendarHeatmap"] ?? false}
                  description={ep.chartDescriptions["calendarHeatmap"] ?? ""}
                  generating={ep.generatingCharts.has("calendarHeatmap")}
                  generatingAll={ep.generatingAll}
                  hasSavedDescription={Boolean(ep.savedDescriptions["calendarHeatmap"])}
                  onToggle={() => { ep.handleToggleChart("calendarHeatmap"); }}
                  onDescriptionChange={(value) => { ep.handleUpdateDescription("calendarHeatmap", value); }}
                  onGenerate={() => ep.generateSingleDescription("calendarHeatmap")}
                  onLoadSaved={() => { ep.handleLoadSavedDescription("calendarHeatmap"); }}
                >
                  <UsageCalendarHeatmap dateRange={dateRange} dailySummaries={siteSummary.dailySummaries} />
                </ChartWithDescription>
              )}

              {/* Gap Chart */}
              {dateRange && gapData && (
                <ChartWithDescription
                  chartKey="gapChart"
                  title="Gap Analysis Chart"
                  siteName={selectedSite}
                  included={ep.selectedCharts["gapChart"] ?? false}
                  description={ep.chartDescriptions["gapChart"] ?? ""}
                  generating={ep.generatingCharts.has("gapChart")}
                  generatingAll={ep.generatingAll}
                  hasSavedDescription={Boolean(ep.savedDescriptions["gapChart"])}
                  onToggle={() => { ep.handleToggleChart("gapChart"); }}
                  onDescriptionChange={(value) => { ep.handleUpdateDescription("gapChart", value); }}
                  onGenerate={() => ep.generateSingleDescription("gapChart")}
                  onLoadSaved={() => { ep.handleLoadSavedDescription("gapChart"); }}
                >
                  <UsageGapChart dateRange={dateRange} gaps={gapData.gaps} daysWithData={gapData.daysWithData} />
                </ChartWithDescription>
              )}

              {/* No Data Days Table */}
              {dateRange && gapData && (
                <ChartWithDescription
                  chartKey="noDataDays"
                  title="No Data Days Table"
                  siteName={selectedSite}
                  included={ep.selectedCharts["noDataDays"] ?? false}
                  description={ep.chartDescriptions["noDataDays"] ?? ""}
                  generating={ep.generatingCharts.has("noDataDays")}
                  generatingAll={ep.generatingAll}
                  hasSavedDescription={Boolean(ep.savedDescriptions["noDataDays"])}
                  onToggle={() => { ep.handleToggleChart("noDataDays"); }}
                  onDescriptionChange={(value) => { ep.handleUpdateDescription("noDataDays", value); }}
                  onGenerate={() => ep.generateSingleDescription("noDataDays")}
                  onLoadSaved={() => { ep.handleLoadSavedDescription("noDataDays"); }}
                >
                  <NoDataDaysTable
                    daysWithoutData={gapData.daysWithoutData}
                    dayStatuses={gapData.allDays.map((date) => ({
                      date,
                      hasData: gapData.daysWithData.has(date.toISOString().split("T")[0]!),
                      isWeekend: date.getDay() === 0 || date.getDay() === 6,
                    }))}
                  />
                </ChartWithDescription>
              )}
            </div>
          </section>

          <ExportSummarySection
            value={ep.chartDescriptions["summary"] ?? ""}
            onChange={(value) => { ep.handleUpdateDescription("summary", value); }}
          />
        </div>
      </main>

      <ExportProgressModal
        isOpen={ep.exportProgress.isOpen}
        progress={ep.exportProgress.progress}
        currentStep={ep.exportProgress.currentStep}
        currentDetail={ep.exportProgress.currentDetail}
        error={ep.exportProgress.error}
        onRetry={() => { ep.exportProgress.dismiss(); void ep.handleExport(); }}
        onCancel={ep.exportProgress.dismiss}
      />
    </>
  );
}

NOTE: The NoDataDaysTable props may need adjustment based on the actual component interface. Check src/features/flow-meter/components/UsageGapAnalysis/NoDataDaysTable.tsx for exact props and adjust accordingly.

Step 2: Verify no TypeScript errors

Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -30 Fix any type mismatches (especially NoDataDaysTable props and UsageGapChart props).

Step 3: Commit

bash
git add src/app/(admin)/(pages)/flow-meter/export.tsx
git commit -m "feat(flow-meter): add PDF export page"

Task 4: Add route and lazy import for Flow Meter export page

Files:

  • Modify: src/routes/Routes.tsx

Step 1: Add lazy import

After line 134 (const FlowMeter = lazy(...)), add:

typescript
const FlowMeterExport = lazy(
  () => import("@/app/(admin)/(pages)/flow-meter/export")
);

Step 2: Add route entry

After the existing flow-meter route (line ~441), add:

typescript
{
  path: "/flow-meter/export",
  name: "FlowMeterExport",
  element: <FlowMeterExport />,
  requiredModule: "flow_meter",
  showDatePicker: true,
  showSiteSelector: true,
},

Step 3: Verify no TypeScript errors

Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -20

Step 4: Commit

bash
git add src/routes/Routes.tsx
git commit -m "feat(flow-meter): add export page route"

Task 5: Update Flow Meter main page to navigate to export page

Files:

  • Modify: src/app/(admin)/(pages)/flow-meter/index.tsx

Step 1: Add useNavigate import

Add useNavigate to the existing react-router-dom import. Change:

typescript
import { Link } from "react-router-dom";

to:

typescript
import { Link, useNavigate } from "react-router-dom";

Step 2: Add navigate hook

Inside FlowMeterPage(), after the existing state declarations (around line 40), add:

typescript
const navigate = useNavigate();

Step 3: Replace the Export button

Replace the Export button (lines ~697-707) from:

tsx
<button
  className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
  type="button"
  disabled={
    !(selectedSiteSummary && selectedSiteSummary.records.length > 0)
  }
  onClick={() => void handleExportAllCharts()}
>
  <Icon className="w-4 h-4" icon="solar:download-bold-duotone" />
  Export
</button>

to:

tsx
<button
  className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
  type="button"
  disabled={
    !(selectedSiteSummary && selectedSiteSummary.records.length > 0)
  }
  onClick={() => {
    if (!selectedSiteSummary || !dateRange) return;
    void navigate("/flow-meter/export", {
      state: {
        siteSummary: selectedSiteSummary,
        selectedSite,
        dateRange,
        chartType,
      },
    });
  }}
>
  <Icon className="w-4 h-4" icon="solar:export-bold-duotone" />
  Export PDF
</button>

Step 4: Remove old PNG export code

  1. Remove the handleExportAllCharts function (lines ~157-178)
  2. Remove the useChartExport hook call (lines ~113-115):
    typescript
    const { exportChart } = useChartExport({
      fileName: selectedSite ? `${selectedSite}_report` : "dustloc_report",
    });
  3. Remove the import of useChartExport (line 24):
    typescript
    import { useChartExport } from "@/features/flow-meter/hooks/useChartExport";
  4. Remove the entire hidden export container div #all-charts-export-container (lines ~1193-1297)

Step 5: Verify no TypeScript errors

Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -20

Step 6: Commit

bash
git add src/app/(admin)/(pages)/flow-meter/index.tsx
git commit -m "feat(flow-meter): replace PNG export with navigate to PDF export page"

Task 6: Create the generate-flow-meter-descriptions Edge Function

Files:

  • Create: supabase/functions/generate-flow-meter-descriptions/index.ts

This follows the exact pattern of supabase/functions/generate-dust-ranger-descriptions/index.ts.

Step 1: Create the Edge Function

Copy the structure from generate-dust-ranger-descriptions/index.ts and adapt for flow meter data:

Key differences:

  • System prompt: "You are a Dustloc operational data analyst who produces concise, factual summaries for Dustloc flow meter charts in mining environmental reports. You understand water dispensing patterns, refill cycles, and operational efficiency of Dustloc dust suppression vehicles."
  • Stats interface: FlowMeterStats with totalLitres, totalRefilled, dailyAverage, refillCount, totalRecords, daysRecorded, siteName
  • Chart data interface: { dailySummaries, records, refills }
  • Chart keys: dailySummary, waterUsageTimeline, calendarHeatmap, gapChart, noDataDays
  • Request body field: flowMeterStats instead of dustRangerStats
  • No dataType parameter (no MC/NC distinction)

CHART_INFO for flow meter:

typescript
const CHART_INFO: Record<FlowMeterChartKey, { title: string; purpose: string; dataFocus: string }> = {
  dailySummary: {
    title: "Daily Dustloc Usage Summary",
    purpose: "Shows daily water dispensing volumes and refill amounts from Dustloc vehicles",
    dataFocus: "Daily usage trends, peak usage days, refill frequency, and net consumption patterns",
  },
  waterUsageTimeline: {
    title: "Dustloc Dispensing Timeline",
    purpose: "Shows individual dispensing events over time from Dustloc flow meters",
    dataFocus: "Dispensing event timing, volume per event, operational patterns throughout the day",
  },
  calendarHeatmap: {
    title: "Usage Gap Calendar Heatmap",
    purpose: "Calendar view showing which days had Dustloc activity and which had no data",
    dataFocus: "Operational coverage, days without activity, weekend vs weekday patterns",
  },
  gapChart: {
    title: "Gap Analysis Chart",
    purpose: "Visualizes gaps between Dustloc dispensing events as a timeline",
    dataFocus: "Duration and frequency of operational gaps, longest periods without activity",
  },
  noDataDays: {
    title: "No Data Days Summary",
    purpose: "Lists all days with no recorded Dustloc dispensing activity",
    dataFocus: "Specific dates without data, potential causes (maintenance, weather, holidays)",
  },
};

Data context per chart type:

  • dailySummary: Total litres, daily average, days recorded, trend from daily values, peak/lowest day
  • waterUsageTimeline: Total records, average litres per event, time span
  • calendarHeatmap: Days with data count, days without data count, coverage percentage
  • gapChart: Number of gaps, longest gap duration, average gap duration
  • noDataDays: Total no-data days, percentage of monitoring period

Prompt template (same structure as dust-ranger but with Dustloc context):

Write a description ({sentences}) for this Dustloc flow meter chart in a mining operational report:

Chart: {title}
Purpose: {purpose}
Data Focus: {dataFocus}

Site: {siteName}
Period: Based on {daysRecorded} days of data

Chart Data:
{dataContext}

CRITICAL DATE FORMAT NOTE:
- All dates use Australian format (DD/MM/YYYY)
- When writing dates in your description, also use Australian format

Writing guidelines:
- Write in a professional but approachable tone
- Focus on Dustloc operational efficiency and water usage patterns
- Include specific numbers (litres, days, percentages)
- Mention any notable trends, gaps, or anomalies
- For usage data, context is dust suppression vehicle operations
{styleGuidelines}

Description:

The AI provider dispatch logic (callAI, callClaudeAI, callOpenAI, key rotation) is identical — copy from generate-dust-ranger-descriptions/index.ts.

Step 2: Verify function syntax

Run: deno check supabase/functions/generate-flow-meter-descriptions/index.ts (if Deno is available locally, otherwise just review for syntax errors)

Step 3: Commit

bash
git add supabase/functions/generate-flow-meter-descriptions/
git commit -m "feat(edge-functions): add flow meter AI description generation"

Task 7: Verify build and lint

Step 1: Run TypeScript check

Run: pnpm build:check Expected: Build succeeds with no errors

Step 2: Run linter

Run: pnpm lint Expected: No new lint errors (max 500 warnings threshold)

Step 3: Fix lint issues if any

Run: pnpm lint:fix

Step 4: Commit any fixes

bash
git add -A
git commit -m "chore: lint fixes for flow meter export"

Task 8: Manual verification checklist

Test the following manually:

  1. Navigate to Flow Meter page, select a site with data
  2. Click "Export PDF" button — should navigate to /flow-meter/export
  3. Export page shows: ReportNameEditor, AIControlsPanel, 5 chart previews with ChartWithDescription wrappers
  4. Charts render correctly: DailySummaryChart, WaterUsageTimelineChart, CalendarHeatmap, GapChart, NoDataDaysTable
  5. Chart checkboxes work (include/exclude)
  6. AI description generation works for a single chart (requires Edge Function deployment)
  7. "Generate All" generates descriptions for all selected charts
  8. PDF Layout (portrait/landscape) selector works
  9. Timezone selector works
  10. Export PDF generates a branded PDF with cover page, charts, descriptions, summary
  11. Progress modal shows during export
  12. Going back to Flow Meter page works
  13. Navigating to /flow-meter/export directly (without state) shows "No export context found" message

Task 9: Deploy Edge Function (production)

Step 1: Deploy the new Edge Function

bash
# Add deployment script to package.json if needed, or deploy directly:
cd supabase && npx supabase functions deploy generate-flow-meter-descriptions --no-verify-jwt

Step 2: Verify function secrets

Ensure the following secrets are set for the Edge Function:

  • AI_API_KEY — for DeepSeek
  • CLAUDE_API_KEY — for Claude models
  • OPENAI_API_KEY — for GPT models
  • AI_API_URL (optional, defaults to OpenRouter)
  • CLAUDE_API_URL (optional, defaults to highteck.online)
  • OPENAI_API_URL (optional, defaults to highteck.online)

These should already be configured from the existing Edge Functions.