Export Page Refactor Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Refactor both PDF export pages to share common code via a new exports feature, and bring Daily Dust Levels export to full feature parity with Raw Dust Levels Data export.
Architecture: Extract shared state management into useExportPage hook, shared UI into reusable components under src/features/exports/. Both export pages consume these shared pieces while keeping feature-specific chart rendering and data logic. Add landscape PDF support to DustLevelExportService.
Tech Stack: React 19, TypeScript 5, jsPDF, Tailwind CSS, Supabase Edge Functions
Task 1: Create shared export types
Files:
- Create:
src/features/exports/types.ts
Step 1: Create the shared types file
// src/features/exports/types.ts
export type PdfOrientation = "portrait" | "landscape";
export type TimezoneOption =
| "Australia/Perth"
| "Australia/Sydney"
| "UTC"
| "America/Santiago"
| "Africa/Johannesburg";
export const TIMEZONE_OPTIONS: {
value: TimezoneOption;
label: string;
offset: string;
}[] = [
{ value: "Australia/Perth", label: "Perth (AWST)", offset: "UTC+8" },
{ value: "Australia/Sydney", label: "Sydney (AEST/AEDT)", offset: "UTC+10/11" },
{ value: "UTC", label: "UTC", offset: "UTC+0" },
{ value: "America/Santiago", label: "Chile Summer Time (CLST)", offset: "UTC-3/-4" },
{ value: "Africa/Johannesburg", label: "Johannesburg (South Africa)", offset: "UTC+2" },
];
export const AI_MODEL_OPTIONS = [
{ value: "deepseek-ai/DeepSeek-V3.2", label: "DeepSeek V3.2" },
{ value: "gpt-5.2", label: "GPT-5.2" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
];
export const DESCRIPTION_STYLE_OPTIONS = [
{ value: "concise", label: "Concise (1-2 sentences)" },
{ value: "moderate", label: "Moderate (2-3 sentences)" },
{ value: "detailed", label: "Detailed (4-6 sentences)" },
];
export interface ChartOption {
key: string;
label: string;
defaultSelected: boolean;
}
export interface BaseExportConfig {
includeCharts: Record<string, boolean>;
chartDescriptions: Record<string, string>;
name: string;
dateRange?: string;
model?: string;
style?: string;
orientation?: PdfOrientation;
timezone?: TimezoneOption;
}
export type GenerateDescriptionsResponse = {
descriptions?: Partial<Record<string, string>>;
errors?: string[];
};
export type GenerateDescriptionsInvokeResult = {
data: GenerateDescriptionsResponse | null;
error: Error | null;
};Step 2: Verify no TypeScript errors
Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -20 Expected: No errors related to the new file
Step 3: Commit
git add src/features/exports/types.ts
git commit -m "feat(exports): add shared export types"Task 2: Create shared export utilities
Files:
- Create:
src/features/exports/utils.ts
Step 1: Create the utilities file
// src/features/exports/utils.ts
export const formatExportDate = (date: Date): string =>
date.toLocaleDateString("en-AU", {
day: "numeric",
month: "short",
year: "numeric",
});
export const formatDateRange = (range?: { start: Date; end: Date }): string => {
if (!range) return "All available data";
return `${formatExportDate(range.start)} - ${formatExportDate(range.end)}`;
};Step 2: Commit
git add src/features/exports/utils.ts
git commit -m "feat(exports): add shared export utilities"Task 3: Create useExportPage shared hook
Files:
- Create:
src/features/exports/hooks/useExportPage.ts
Step 1: Create the hook
This hook encapsulates all shared state and callbacks that both export pages duplicate. Feature-specific logic is injected via options.
Key responsibilities:
- Chart selection state (
selectedCharts) - Description management (
chartDescriptions) - AI model/style/timezone/orientation state
- Editable name state
- AI generation orchestration (single + all)
- localStorage auto-save with 1.5s debounce
- Load saved descriptions (single + all)
- Export triggering
// src/features/exports/hooks/useExportPage.ts
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { supabase } from "@/lib/supabase";
import type {
BaseExportConfig,
ChartOption,
GenerateDescriptionsInvokeResult,
GenerateDescriptionsResponse,
PdfOrientation,
TimezoneOption,
} from "../types";
export interface UseExportPageOptions {
/** Unique key for localStorage description persistence */
storageKey: string;
/** Chart options for this export */
chartOptions: ChartOption[];
/** Whether we have valid data to work with */
hasData: boolean;
/** Initial name for the report */
initialName: string;
/** Edge function name for AI generation */
edgeFunctionName: string;
/** Build the request body for AI generation (feature-specific) */
buildAIRequestBody: (chartKey: string, model: string, style: string) => Record<string, unknown>;
/** Execute the actual PDF export (feature-specific) */
onExport: (config: BaseExportConfig) => Promise<void>;
/** Optional initial descriptions for specific charts */
initialDescriptions?: Record<string, string>;
/** Optional date range string for the config */
dateRangeString?: string;
}
export function useExportPage(options: UseExportPageOptions) {
const {
storageKey,
chartOptions,
hasData,
initialName,
edgeFunctionName,
buildAIRequestBody,
onExport,
initialDescriptions,
dateRangeString,
} = options;
// Core state
const [selectedCharts, setSelectedCharts] = useState<Record<string, boolean>>({});
const [chartDescriptions, setChartDescriptions] = useState<Record<string, string>>({});
const [selectedModel, setSelectedModel] = useState<string>("claude-opus-4-5-20251101");
const [selectedStyle, setSelectedStyle] = useState<string>("moderate");
const [selectedTimezone, setSelectedTimezone] = useState<TimezoneOption>("Australia/Perth");
const [selectedOrientation, setSelectedOrientation] = useState<PdfOrientation>("portrait");
const [editableName, setEditableName] = useState("");
const [exporting, setExporting] = useState(false);
const [generatingAll, setGeneratingAll] = useState(false);
const [generatingCharts, setGeneratingCharts] = useState<Set<string>>(new Set());
// Saved descriptions from localStorage
const [savedDescriptions, setSavedDescriptions] = useState<Record<string, string>>({});
// localStorage key for saving descriptions
const localStorageKey = `export-descriptions-${storageKey}`;
// Load saved descriptions from localStorage on mount
useEffect(() => {
try {
const saved = localStorage.getItem(localStorageKey);
if (saved) {
setSavedDescriptions(JSON.parse(saved) as Record<string, string>);
}
} catch (err) {
console.error("Failed to load saved descriptions:", err);
}
}, [localStorageKey]);
// Save descriptions to localStorage with debounce (1.5 seconds)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const hasContent = Object.values(chartDescriptions).some((desc) => desc.trim() !== "");
if (!hasContent) return;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
try {
localStorage.setItem(localStorageKey, JSON.stringify(chartDescriptions));
setSavedDescriptions(chartDescriptions);
} catch (err) {
console.error("Failed to save descriptions:", err);
}
}, 1500);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [chartDescriptions, localStorageKey]);
// Initialize selections/descriptions once data is present
useEffect(() => {
if (!hasData) return;
setEditableName(initialName);
if (Object.keys(selectedCharts).length > 0) return;
const initialSelected: Record<string, boolean> = {};
const initialDescs: Record<string, string> = {};
chartOptions.forEach((option) => {
initialSelected[option.key] = option.defaultSelected;
initialDescs[option.key] = "";
});
// Apply any feature-specific initial descriptions
if (initialDescriptions) {
Object.assign(initialDescs, initialDescriptions);
}
setSelectedCharts(initialSelected);
setChartDescriptions(initialDescs);
}, [chartOptions, hasData, initialDescriptions, initialName, selectedCharts]);
const hasAnySavedDescriptions = useMemo(() => {
return Object.values(savedDescriptions).some((desc) => desc?.trim());
}, [savedDescriptions]);
// Actions
const handleToggleChart = useCallback((key: string) => {
setSelectedCharts((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const handleUpdateDescription = useCallback((key: string, value: string) => {
setChartDescriptions((prev) => ({ ...prev, [key]: value }));
}, []);
const handleLoadSavedDescription = useCallback(
(chartKey: string) => {
const saved = savedDescriptions[chartKey];
if (saved) {
setChartDescriptions((prev) => ({ ...prev, [chartKey]: saved }));
}
},
[savedDescriptions]
);
const handleLoadAllSavedDescriptions = useCallback(() => {
const hasAnySaved = Object.values(savedDescriptions).some((desc) => desc?.trim());
if (!hasAnySaved) return;
setChartDescriptions((prev) => {
const updated = { ...prev };
for (const [key, value] of Object.entries(savedDescriptions)) {
if (value?.trim()) {
updated[key] = value;
}
}
return updated;
});
}, [savedDescriptions]);
const generateSingleDescription = useCallback(
async (chartKey: string) => {
if (!hasData) return;
setGeneratingCharts((prev) => new Set(prev).add(chartKey));
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error("No active session");
const response = (await supabase.functions.invoke<GenerateDescriptionsResponse>(
edgeFunctionName,
{ body: buildAIRequestBody(chartKey, selectedModel, selectedStyle) }
)) as GenerateDescriptionsInvokeResult;
const { data, error } = response;
if (error) throw error;
const description = data?.descriptions?.[chartKey];
if (description) {
setChartDescriptions((prev) => ({ ...prev, [chartKey]: description }));
}
if (data?.errors && data.errors.length > 0) {
alert(`AI generation warning:\n\n${data.errors.join("\n")}\n\nFallback description was used.`);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error(`Failed to generate description for ${chartKey}:`, err);
alert(`Failed to generate description: ${message}`);
} finally {
setGeneratingCharts((prev) => {
const next = new Set(prev);
next.delete(chartKey);
return next;
});
}
},
[buildAIRequestBody, edgeFunctionName, hasData, selectedModel, selectedStyle]
);
const handleGenerateAll = useCallback(async () => {
const selectedKeys = Object.entries(selectedCharts)
.filter(([, isSelected]) => isSelected)
.map(([key]) => key);
if (!hasData || selectedKeys.length === 0) return;
setGeneratingAll(true);
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error("No active session");
const errors: string[] = [];
for (const chartKey of selectedKeys) {
setGeneratingCharts((prev) => new Set(prev).add(chartKey));
try {
const response = (await supabase.functions.invoke<GenerateDescriptionsResponse>(
edgeFunctionName,
{ body: buildAIRequestBody(chartKey, selectedModel, selectedStyle) }
)) as GenerateDescriptionsInvokeResult;
const { data, error } = response;
if (error) throw error;
const description = data?.descriptions?.[chartKey];
if (description) {
setChartDescriptions((prev) => ({ ...prev, [chartKey]: description }));
}
if (data?.errors && data.errors.length > 0) {
errors.push(...data.errors);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
errors.push(`${chartKey}: ${message}`);
} finally {
setGeneratingCharts((prev) => {
const next = new Set(prev);
next.delete(chartKey);
return next;
});
}
}
if (errors.length > 0) {
alert(`AI generation completed with warnings:\n\n${errors.join("\n")}\n\nSome descriptions may not have been generated.`);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
alert(`Failed to generate AI descriptions: ${message}`);
} finally {
setGeneratingAll(false);
setGeneratingCharts(new Set());
}
}, [buildAIRequestBody, edgeFunctionName, hasData, selectedCharts, selectedModel, selectedStyle]);
const handleExport = useCallback(async () => {
if (!hasData) return;
const config: BaseExportConfig = {
includeCharts: selectedCharts,
chartDescriptions,
name: editableName || initialName,
dateRange: dateRangeString,
model: selectedModel,
style: selectedStyle,
orientation: selectedOrientation,
timezone: selectedTimezone,
};
try {
setExporting(true);
await onExport(config);
} catch (error) {
console.error("Export failed:", error);
alert("Failed to export report. Please try again.");
} finally {
setExporting(false);
}
}, [
chartDescriptions, dateRangeString, editableName, hasData,
initialName, onExport, selectedCharts, selectedModel,
selectedOrientation, selectedStyle, selectedTimezone,
]);
return {
// State
selectedCharts,
chartDescriptions,
selectedModel,
selectedStyle,
selectedTimezone,
selectedOrientation,
editableName,
exporting,
generatingAll,
generatingCharts,
savedDescriptions,
hasAnySavedDescriptions,
// Setters
setSelectedModel,
setSelectedStyle,
setSelectedTimezone,
setSelectedOrientation,
setEditableName,
// Actions
handleToggleChart,
handleUpdateDescription,
generateSingleDescription,
handleGenerateAll,
handleExport,
handleLoadSavedDescription,
handleLoadAllSavedDescriptions,
};
}Step 2: Verify no TypeScript errors
Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -20
Step 3: Commit
git add src/features/exports/hooks/useExportPage.ts
git commit -m "feat(exports): add useExportPage shared hook"Task 4: Create shared UI components
Files:
- Create:
src/features/exports/components/ExportPageHeader.tsx - Create:
src/features/exports/components/ReportNameEditor.tsx - Create:
src/features/exports/components/AIControlsPanel.tsx - Create:
src/features/exports/components/ExportSummarySection.tsx
Step 1: Create ExportPageHeader
Top bar with badges + back button. Badges are passed as children for flexibility.
// src/features/exports/components/ExportPageHeader.tsx
import { useNavigate } from "react-router-dom";
import { Icon } from "@iconify/react";
interface ExportPageHeaderProps {
backLabel: string;
backPath: string;
children: React.ReactNode; // Badge elements
}
export function ExportPageHeader({ backLabel, backPath, children }: ExportPageHeaderProps) {
const navigate = useNavigate();
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-4">
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
{children}
</div>
<div className="flex gap-2">
<button
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 transition-colors"
type="button"
onClick={() => { void navigate(backPath); }}
>
<Icon className="w-4 h-4" icon="solar:arrow-left-linear" />
{backLabel}
</button>
</div>
</div>
);
}Step 2: Create ReportNameEditor
Name input with reset + back/export buttons.
// src/features/exports/components/ReportNameEditor.tsx
import { useNavigate } from "react-router-dom";
import { Icon } from "@iconify/react";
interface ReportNameEditorProps {
label: string;
value: string;
originalValue: string;
onChange: (value: string) => void;
onExport: () => void;
backPath: string;
exporting: boolean;
canExport: boolean;
}
export function ReportNameEditor({
label,
value,
originalValue,
onChange,
onExport,
backPath,
exporting,
canExport,
}: ReportNameEditorProps) {
const navigate = useNavigate();
return (
<section className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{label}
</h2>
<p className="text-xs text-slate-600 dark:text-slate-400">
This name will appear in the PDF report header. Original: {originalValue}
</p>
</div>
<div className="flex gap-2">
<button
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 transition-colors"
type="button"
onClick={() => { void navigate(backPath); }}
>
<Icon className="w-4 h-4" icon="solar:arrow-left-linear" />
Back
</button>
<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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={exporting || !canExport}
type="button"
onClick={onExport}
>
{exporting ? (
<>
<Icon className="w-4 h-4 animate-spin" icon="svg-spinners:ring-resize" />
Exporting...
</>
) : (
<>
<Icon className="w-4 h-4" icon="solar:export-bold" />
Export PDF
</>
)}
</button>
</div>
</div>
<div className="flex items-center gap-3">
<input
className="flex-1 px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter name for the report..."
type="text"
value={value}
onChange={(e) => { onChange(e.target.value); }}
/>
{value !== originalValue && (
<button
className="px-3 py-2 text-xs font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
title="Reset to original name"
type="button"
onClick={() => { onChange(originalValue); }}
>
<Icon className="w-4 h-4" icon="solar:restart-bold" />
</button>
)}
</div>
</section>
);
}Step 3: Create AIControlsPanel
Model, style, orientation, timezone selectors + Generate All + Load All Saved. Accepts extraControls slot for feature-specific controls.
// src/features/exports/components/AIControlsPanel.tsx
import { Icon } from "@iconify/react";
import {
AI_MODEL_OPTIONS,
DESCRIPTION_STYLE_OPTIONS,
TIMEZONE_OPTIONS,
type PdfOrientation,
type TimezoneOption,
} from "../types";
interface AIControlsPanelProps {
selectedModel: string;
selectedStyle: string;
selectedTimezone: TimezoneOption;
selectedOrientation: PdfOrientation;
onModelChange: (model: string) => void;
onStyleChange: (style: string) => void;
onTimezoneChange: (tz: TimezoneOption) => void;
onOrientationChange: (orientation: PdfOrientation) => void;
onGenerateAll: () => void;
onLoadAllSaved?: () => void;
hasAnySavedDescriptions?: boolean;
generatingAll: boolean;
generatingChartsCount: number;
selectedChartsCount: number;
/** Feature-specific model options override */
modelOptions?: { value: string; label: string }[];
/** Feature-specific style options override */
styleOptions?: { value: string; label: string }[];
/** Slot for feature-specific controls (Ranger Type, Data Type, etc.) */
extraControls?: React.ReactNode;
}
export function AIControlsPanel({
selectedModel,
selectedStyle,
selectedTimezone,
selectedOrientation,
onModelChange,
onStyleChange,
onTimezoneChange,
onOrientationChange,
onGenerateAll,
onLoadAllSaved,
hasAnySavedDescriptions,
generatingAll,
generatingChartsCount,
selectedChartsCount,
modelOptions = AI_MODEL_OPTIONS,
styleOptions = DESCRIPTION_STYLE_OPTIONS,
extraControls,
}: AIControlsPanelProps) {
const selectClass =
"w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent";
return (
<section className="space-y-3">
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-4">
{/* AI Model */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">AI Model</p>
<p className="text-xs text-slate-600 dark:text-slate-400">Choose the model used to generate descriptions</p>
</div>
<div className="flex flex-col gap-2 md:w-80">
<select className={selectClass} value={selectedModel} onChange={(e) => { onModelChange(e.target.value); }}>
{modelOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Description Style */}
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">Description Style</p>
<p className="text-xs text-slate-600 dark:text-slate-400">Choose the level of detail for generated descriptions</p>
</div>
<div className="flex flex-col gap-2 md:w-80">
<select className={selectClass} value={selectedStyle} onChange={(e) => { onStyleChange(e.target.value); }}>
{styleOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* PDF Layout */}
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">PDF Layout</p>
<p className="text-xs text-slate-600 dark:text-slate-400">Choose the page orientation for the exported PDF</p>
</div>
<div className="flex flex-col gap-2 md:w-80">
<select
className={selectClass}
value={selectedOrientation}
onChange={(e) => { onOrientationChange(e.target.value as PdfOrientation); }}
>
<option value="portrait">Portrait (Vertical)</option>
<option value="landscape">Landscape (Horizontal) - Side-by-side layout</option>
</select>
</div>
</div>
{/* Timezone */}
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">Timezone</p>
<p className="text-xs text-slate-600 dark:text-slate-400">Choose the timezone for date/time display in the report</p>
</div>
<div className="flex flex-col gap-2 md:w-80">
<select
className={selectClass}
value={selectedTimezone}
onChange={(e) => { onTimezoneChange(e.target.value as TimezoneOption); }}
>
{TIMEZONE_OPTIONS.map((tz) => (
<option key={tz.value} value={tz.value}>{tz.label} ({tz.offset})</option>
))}
</select>
</div>
</div>
{/* Feature-specific extra controls */}
{extraControls}
{/* AI-Powered Descriptions + Load All Saved */}
<div className="mt-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">AI-Powered Descriptions</p>
<p className="text-xs text-slate-600 dark:text-slate-400">Generate descriptions for all selected charts using AI</p>
</div>
<div className="flex items-center gap-2">
{onLoadAllSaved && (
<button
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-blue-600 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
disabled={!hasAnySavedDescriptions}
type="button"
title={hasAnySavedDescriptions ? "Load all previously saved descriptions" : "No saved descriptions available"}
onClick={onLoadAllSaved}
>
<Icon className="w-5 h-5" icon="solar:history-bold" />
Load All Saved
</button>
)}
<button
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-gradient-to-r from-purple-600 to-blue-600 text-white hover:from-purple-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md"
type="button"
disabled={generatingAll || generatingChartsCount > 0 || selectedChartsCount === 0}
onClick={onGenerateAll}
>
{generatingAll ? (
<>
<Icon className="w-5 h-5 animate-spin" icon="svg-spinners:ring-resize" />
Generating...
</>
) : (
<>
<Icon className="w-5 h-5" icon="solar:magic-stick-bold" />
Generate All
</>
)}
</button>
</div>
</div>
</div>
</section>
);
}Step 4: Create ExportSummarySection
// src/features/exports/components/ExportSummarySection.tsx
interface ExportSummarySectionProps {
value: string;
onChange: (value: string) => void;
}
export function ExportSummarySection({ value, onChange }: ExportSummarySectionProps) {
return (
<section className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Summary</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Add a summary or conclusion for the report (optional)
</p>
</div>
</div>
<textarea
className="w-full min-h-[120px] px-4 py-3 text-sm bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y placeholder:text-slate-400 dark:placeholder:text-slate-500"
placeholder="Enter your summary or conclusions here..."
value={value}
onChange={(e) => { onChange(e.target.value); }}
/>
</section>
);
}Step 5: Verify no TypeScript errors
Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -20
Step 6: Commit
git add src/features/exports/components/
git commit -m "feat(exports): add shared UI components"Task 5: Update ExportConfig and ExportModal types
Files:
- Modify:
src/features/dust-levels/components/ExportModal.tsx
Step 1: Update ExportConfig to add orientation and re-export from shared types
In ExportModal.tsx, update the ExportConfig interface to include orientation, and update the TimezoneOption / TIMEZONE_OPTIONS exports to re-export from the shared types. This keeps backward compatibility for existing imports.
Changes to make:
- Import
PdfOrientation,TimezoneOption,TIMEZONE_OPTIONSfrom@/features/exports/types - Re-export them so existing imports don't break
- Add
orientation?: PdfOrientationtoExportConfig
// At the top of ExportModal.tsx, replace the TimezoneOption type and TIMEZONE_OPTIONS const with:
import {
type PdfOrientation,
type TimezoneOption as SharedTimezoneOption,
TIMEZONE_OPTIONS as SHARED_TIMEZONE_OPTIONS,
} from "@/features/exports/types";
// Re-export for backward compatibility
export type TimezoneOption = SharedTimezoneOption;
// eslint-disable-next-line react-refresh/only-export-components
export const TIMEZONE_OPTIONS = SHARED_TIMEZONE_OPTIONS;
export type { PdfOrientation };
// Update ExportConfig to add orientation:
export interface ExportConfig {
includeCharts: Record<string, boolean>;
chartDescriptions: Record<string, string>;
siteName: string;
description?: string;
dateRange?: string;
selectedGeofence?: string;
availableGeofences?: string[];
model?: string;
timezone?: TimezoneOption;
orientation?: PdfOrientation;
}Step 2: Verify no TypeScript errors
Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -30 Expected: No errors — existing imports of TimezoneOption, TIMEZONE_OPTIONS, ExportConfig from ExportModal still work.
Step 3: Commit
git add src/features/dust-levels/components/ExportModal.tsx
git commit -m "feat(dust-levels): add orientation to ExportConfig, re-export shared types"Task 6: Add landscape support to DustLevelExportService
Files:
- Modify:
src/features/dust-levels/services/exportService.ts
This is the largest single task. Port the landscape logic from DustRangerExportService into DustLevelExportService.
Step 1: Add orientation-aware dimensions
At the top of exportReport(), replace the hardcoded portrait setup with dynamic dimensions:
// Replace:
// const pdf = new jsPDF({ orientation: "p", unit: "mm", format: "a4", compress: true });
// const pageWidth = 210;
// const pageHeight = 297;
// const margin = 25;
// With:
const isLandscape = config.orientation === "landscape";
const pdf = new jsPDF({
orientation: isLandscape ? "l" : "p",
unit: "mm",
format: "a4",
compress: true,
});
const pageWidth = isLandscape ? 297 : 210;
const pageHeight = isLandscape ? 210 : 297;
const margin = isLandscape ? 20 : 25;Step 2: Update drawHeader for landscape
Update the drawHeader function's headerRightMargin to be orientation-aware:
const headerRightMargin = isLandscape ? 70 : 55;Step 3: Update drawBackgroundShapes for landscape
Port the landscape branch from DustRangerExportService (lines 379-417). When isLandscape:
- Circles positioned at x=260 instead of x=184 (wider page)
- No bottom-left circle for landscape
- Smaller radii (70, 55, 40 instead of 79, 66, 53)
When portrait: keep existing positions unchanged.
Step 4: Update cover page layout for landscape
Port the landscape cover page layout from DustRangerExportService (lines 442-640):
- Logo width: 45mm (landscape) vs 50mm (portrait)
- yPos increment: 25 (landscape) vs 30 (portrait)
- Title on single line: "Dust Level Monitoring" (landscape) vs two lines "Dust Level" + "Monitoring" (portrait)
- Dates section: 3 columns (Monitoring Period, Report Date, Timezone) on same row for landscape
- Key Summary cards: 4 cards in 1 row (landscape) vs 2×2 grid (portrait)
- Card values use dust-levels specific data: avgDustConcentration, maxDustConcentration, daysRecorded, corrugationsAverage
Step 5: Update chart rendering for landscape
Port the landscape chart rendering from DustRangerExportService (lines 945-1114):
checkPageBreak(130)for landscape vscheckPageBreak(120)for portrait- Full-width chart with description below (landscape)
- Description box with rounded corners and "KEY INSIGHTS" title (landscape)
- Fallback: add description even if chart capture fails
Step 6: Update footer and text width calculations
All pageWidth and margin references are already dynamic from Step 1, so the footer, summary section, and drawGenericKeyInsights should work correctly.
Step 7: Verify no TypeScript errors
Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -30
Step 8: Commit
git add src/features/dust-levels/services/exportService.ts
git commit -m "feat(dust-levels): add landscape PDF support to export service"Task 7: Refactor Daily Dust Levels export page
Files:
- Modify:
src/app/(admin)/(pages)/dust-levels/export.tsx
Refactor to use the shared useExportPage hook and shared UI components. This is where Daily Dust Levels gains all the new features.
Step 1: Replace imports
Remove duplicated type imports and add shared imports:
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 { DustLevelTempChart } from "@/features/dust-levels/components/charts/DustLevelTempChart";
import { AverageDustLevelsChart } from "@/features/dust-levels/components/charts/AverageDustLevelsChart";
import { DustlocUsageWithDateRange } from "@/features/dust-levels/components/charts/DustlocUsageWithDateRange";
import { DustLevelExportService } from "@/features/dust-levels/services/exportService";
import type { ExportConfig } from "@/features/dust-levels/components/ExportModal";
import type {
DailySummary, DustlocUsageData, MonthlySummary,
SiteSummary, SpikeDustData, WeeklySummary,
} from "@/features/dust-levels/types";
import type { DateRange } from "@/components/ui/DateRangeSelector";
// Shared exports
import { useExportPage } from "@/features/exports/hooks/useExportPage";
import { ExportPageHeader } from "@/features/exports/components/ExportPageHeader";
import { ReportNameEditor } from "@/features/exports/components/ReportNameEditor";
import { AIControlsPanel } from "@/features/exports/components/AIControlsPanel";
import { ExportSummarySection } from "@/features/exports/components/ExportSummarySection";
import { formatDateRange } from "@/features/exports/utils";
import type { BaseExportConfig, ChartOption } from "@/features/exports/types";Step 2: Replace state management with useExportPage
Remove all the individual useState calls for selectedCharts, chartDescriptions, selectedModel, selectedStyle, selectedTimezone, editableSiteName, exporting, generatingAll, generatingCharts. Replace with:
const chartOptions = useMemo<ChartOption[]>(() => {
// ... same logic as before, building options from geofences
}, [availableGeofences, selectedGeofence]);
const buildAIRequestBody = useCallback(
(chartKey: string, model: string, style: string) => {
const chartSpecificData = getChartDataForKey(chartKey);
return {
model,
style,
siteSummary,
singleChart: chartKey,
chartData: chartSpecificData,
};
},
[getChartDataForKey, siteSummary]
);
const handleExportPdf = useCallback(
async (config: BaseExportConfig) => {
if (!siteSummary) return;
const exportConfig: ExportConfig = {
includeCharts: config.includeCharts,
chartDescriptions: config.chartDescriptions,
siteName: config.name,
description: "",
dateRange: config.dateRange,
selectedGeofence: selectedGeofence ?? undefined,
availableGeofences,
model: config.model,
timezone: config.timezone,
orientation: config.orientation,
};
await DustLevelExportService.exportReport(exportConfig, siteSummary);
},
[availableGeofences, selectedGeofence, siteSummary]
);
const exportPage = useExportPage({
storageKey: `dust-levels-${siteSummary?.siteName ?? "unknown"}`,
chartOptions,
hasData: Boolean(siteSummary),
initialName: siteSummary?.siteName ?? "",
edgeFunctionName: "generate-chart-descriptions",
buildAIRequestBody,
onExport: handleExportPdf,
initialDescriptions: {
dustDistribution: "Observation: The logarithmic scale histogram...", // existing default
},
dateRangeString: selectedDateRange
? formatDateRange(selectedDateRange)
: undefined,
});Step 3: Replace UI sections with shared components
Replace the header bar JSX with <ExportPageHeader>, the report name editor with <ReportNameEditor>, the AI controls section with <AIControlsPanel>, and the summary section with <ExportSummarySection>.
The charts preview section stays as-is (feature-specific), but each <ChartWithDescription> gains the new props:
hasSavedDescription={Boolean(exportPage.savedDescriptions[chartKey])}onLoadSaved={() => { exportPage.handleLoadSavedDescription(chartKey); }}
Step 4: Verify no TypeScript errors
Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -30
Step 5: Manual test
- Navigate to Daily Dust Levels, select a site, click Export Report
- Verify all new controls appear: PDF Layout, Load All Saved, GPT-5.2 model option
- Verify existing functionality still works: chart selection, AI generation, Export PDF
- Test landscape export produces correct PDF
Step 6: Commit
git add src/app/(admin)/(pages)/dust-levels/export.tsx
git commit -m "refactor(dust-levels): use shared export hook and components"Task 8: Refactor Raw Dust Levels Data export page
Files:
- Modify:
src/app/(admin)/(pages)/dust-ranger-data/export.tsx
Refactor to use the shared useExportPage hook and shared UI components. This page already has all the features — we're just reducing code duplication.
Step 1: Replace imports
Remove duplicated type imports. Add shared imports:
import { useExportPage } from "@/features/exports/hooks/useExportPage";
import { ExportPageHeader } from "@/features/exports/components/ExportPageHeader";
import { ReportNameEditor } from "@/features/exports/components/ReportNameEditor";
import { AIControlsPanel } from "@/features/exports/components/AIControlsPanel";
import { ExportSummarySection } from "@/features/exports/components/ExportSummarySection";
import { formatDateRange } from "@/features/exports/utils";
import type { BaseExportConfig, ChartOption } from "@/features/exports/types";Remove imports of TimezoneOption, TIMEZONE_OPTIONS, PdfOrientation from dustRangerExportService — use from shared types instead. Keep DustRangerExportConfig and DustRangerExportService imports.
Step 2: Replace state management with useExportPage
Same pattern as Task 7. Remove all individual useState calls that are now in the hook. Keep feature-specific state:
dataType/setDataType(MC/NC switching)isStaticRanger/setIsStaticRanger(Ranger Type)statistics,chartData,reportStatistics,zeroReadings(data states)loadingData(data refetch loading)
Wire up useExportPage:
const buildAIRequestBody = useCallback(
(chartKey: string, model: string, style: string) => ({
model,
style,
dustRangerStats: statistics,
singleChart: chartKey,
dataType,
chartData,
}),
[chartData, dataType, statistics]
);
const handleExportPdf = useCallback(
async (config: BaseExportConfig) => {
if (!statistics) return;
const exportConfig: DustRangerExportConfig = {
includeCharts: config.includeCharts,
chartDescriptions: config.chartDescriptions,
name: config.name,
dateRange: config.dateRange,
model: config.model,
orientation: config.orientation,
timezone: config.timezone,
isStaticRanger,
};
await DustRangerExportService.exportReport(exportConfig, statistics);
},
[isStaticRanger, statistics]
);
const exportPage = useExportPage({
storageKey: `dust-ranger-${siteName}-${dataType}`,
chartOptions,
hasData: Boolean(statistics),
initialName: siteName,
edgeFunctionName: "generate-dust-ranger-descriptions",
buildAIRequestBody,
onExport: handleExportPdf,
dateRangeString: dateRange ? formatDateRange(dateRange) : undefined,
});Step 3: Replace UI sections with shared components
Same as Task 7. Replace header, name editor, AI controls, summary with shared components.
For AIControlsPanel, pass feature-specific controls via extraControls:
<AIControlsPanel
{...exportPage props}
extraControls={
<>
{/* Ranger Type */}
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">Ranger Type</p>
<p className="text-xs text-slate-600 dark:text-slate-400">
Choose whether the Dust Ranger is static or mounted on a vehicle
</p>
</div>
<div className="flex flex-col gap-2 md:w-80">
<select
className="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={isStaticRanger ? "static" : "vehicle"}
onChange={(e) => { setIsStaticRanger(e.target.value === "static"); }}
>
<option value="vehicle">Mounted Vehicle</option>
<option value="static">Static Ranger</option>
</select>
</div>
</div>
</>
}
/>Step 4: Keep feature-specific chart rendering and timezone transformations
The hourlyAveragesWithTimezone and dailyAveragesWithTimezone useMemo hooks stay, but reference exportPage.selectedTimezone instead of local state.
The charts preview section stays as-is with all the chart components, but each <ChartWithDescription> uses exportPage.* for state and callbacks.
Step 5: Verify no TypeScript errors
Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -30
Step 6: Manual test
- Navigate to Raw Dust Levels Data, select filters, click Export Report
- Verify all existing functionality still works identically
- Verify PDF Layout, Timezone, Load Saved, AI generation all work
- Test both portrait and landscape exports
Step 7: Commit
git add src/app/(admin)/(pages)/dust-ranger-data/export.tsx
git commit -m "refactor(dust-ranger): use shared export hook and components"Task 9: Update DustRangerExportService to use shared types
Files:
- Modify:
src/features/dust-ranger/services/dustRangerExportService.ts
Step 1: Import shared types instead of defining locally
Replace the local PdfOrientation, TimezoneOption, TIMEZONE_OPTIONS definitions with imports from shared types:
import {
type PdfOrientation,
type TimezoneOption,
TIMEZONE_OPTIONS,
} from "@/features/exports/types";Remove the local type definitions (lines 167-198 of the original file). Keep DustRangerExportConfig and DustRangerExportService as-is — they reference the shared types now.
Step 2: Verify no TypeScript errors
Run: pnpm exec tsc --noEmit --pretty 2>&1 | head -30
Step 3: Commit
git add src/features/dust-ranger/services/dustRangerExportService.ts
git commit -m "refactor(dust-ranger): use shared export types"Task 10: Final verification 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: Run lint fix if needed
Run: pnpm lint:fix
Step 4: Verify both export pages render correctly
Manual checklist:
- [ ] Daily Dust Levels export page loads with all controls
- [ ] Raw Dust Levels Data export page loads with all controls
- [ ] PDF Layout selector appears on both pages
- [ ] Timezone selector works on both pages
- [ ] AI Model includes GPT-5.2 on both pages
- [ ] Load All Saved button appears on both pages
- [ ] Per-chart Load Saved button appears on both pages
- [ ] Generate All works on both pages
- [ ] Export PDF (portrait) works on both pages
- [ ] Export PDF (landscape) works on both pages
Step 5: Final commit
git add -A
git commit -m "chore: lint fixes and final cleanup"