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
// 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
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"
- Card 1 (orange): "Total Dustloc Used" —
- 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:
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:
- Replace
import type { ExportConfig } from "../components/ExportModal"with the localFlowMeterExportConfiginterface above - Replace
import type { SiteSummary } from "../types"withimport type { SiteSummary } from "../types"(same import, different feature path) - In
drawHeader(): Change"DUST LEVEL MONITORING REPORT"to"DUSTLOC FLOW METER REPORT" - In landscape cover page title section: Change
"Dust Level Monitoring"to"Dustloc Flow Meter"and site name subtitle stays the same - In portrait cover page title section: Change
"Dust Level"+"Monitoring"to"Dustloc Flow"+"Meter Report" - Replace all 4 summary card calls (both landscape
drawCardLandscapeand portraitdrawCard) 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"
- Card 1 (orange): title="Total Dustloc Used", value=
- Remove
getLegacyCharts()method entirely — only usegetChartElements()with[data-export-chart]discovery - Change filename:
flow-meter-report-${config.siteName.replace(/\s+/g, "-")}-${Date.now()}.pdf - Remove the
displayTitlespecial case forchart.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
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).
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
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:
const FlowMeterExport = lazy(
() => import("@/app/(admin)/(pages)/flow-meter/export")
);Step 2: Add route entry
After the existing flow-meter route (line ~441), add:
{
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
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:
import { Link } from "react-router-dom";to:
import { Link, useNavigate } from "react-router-dom";Step 2: Add navigate hook
Inside FlowMeterPage(), after the existing state declarations (around line 40), add:
const navigate = useNavigate();Step 3: Replace the Export button
Replace the Export button (lines ~697-707) from:
<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:
<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
- Remove the
handleExportAllChartsfunction (lines ~157-178) - Remove the
useChartExporthook call (lines ~113-115):typescriptconst { exportChart } = useChartExport({ fileName: selectedSite ? `${selectedSite}_report` : "dustloc_report", }); - Remove the import of
useChartExport(line 24):typescriptimport { useChartExport } from "@/features/flow-meter/hooks/useChartExport"; - 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
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:
FlowMeterStatswithtotalLitres,totalRefilled,dailyAverage,refillCount,totalRecords,daysRecorded,siteName - Chart data interface:
{ dailySummaries, records, refills } - Chart keys:
dailySummary,waterUsageTimeline,calendarHeatmap,gapChart,noDataDays - Request body field:
flowMeterStatsinstead ofdustRangerStats - No
dataTypeparameter (no MC/NC distinction)
CHART_INFO for flow meter:
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 daywaterUsageTimeline: Total records, average litres per event, time spancalendarHeatmap: Days with data count, days without data count, coverage percentagegapChart: Number of gaps, longest gap duration, average gap durationnoDataDays: 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
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
git add -A
git commit -m "chore: lint fixes for flow meter export"Task 8: Manual verification checklist
Test the following manually:
- Navigate to Flow Meter page, select a site with data
- Click "Export PDF" button — should navigate to
/flow-meter/export - Export page shows: ReportNameEditor, AIControlsPanel, 5 chart previews with ChartWithDescription wrappers
- Charts render correctly: DailySummaryChart, WaterUsageTimelineChart, CalendarHeatmap, GapChart, NoDataDaysTable
- Chart checkboxes work (include/exclude)
- AI description generation works for a single chart (requires Edge Function deployment)
- "Generate All" generates descriptions for all selected charts
- PDF Layout (portrait/landscape) selector works
- Timezone selector works
- Export PDF generates a branded PDF with cover page, charts, descriptions, summary
- Progress modal shows during export
- Going back to Flow Meter page works
- Navigating to
/flow-meter/exportdirectly (without state) shows "No export context found" message
Task 9: Deploy Edge Function (production)
Step 1: Deploy the new Edge Function
# Add deployment script to package.json if needed, or deploy directly:
cd supabase && npx supabase functions deploy generate-flow-meter-descriptions --no-verify-jwtStep 2: Verify function secrets
Ensure the following secrets are set for the Edge Function:
AI_API_KEY— for DeepSeekCLAUDE_API_KEY— for Claude modelsOPENAI_API_KEY— for GPT modelsAI_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.