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

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

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

bash
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

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

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

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

tsx
// 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.

tsx
// 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.

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

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

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

  1. Import PdfOrientation, TimezoneOption, TIMEZONE_OPTIONS from @/features/exports/types
  2. Re-export them so existing imports don't break
  3. Add orientation?: PdfOrientation to ExportConfig
typescript
// 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

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

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

typescript
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 vs checkPageBreak(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

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

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

typescript
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

  1. Navigate to Daily Dust Levels, select a site, click Export Report
  2. Verify all new controls appear: PDF Layout, Load All Saved, GPT-5.2 model option
  3. Verify existing functionality still works: chart selection, AI generation, Export PDF
  4. Test landscape export produces correct PDF

Step 6: Commit

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

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

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

tsx
<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

  1. Navigate to Raw Dust Levels Data, select filters, click Export Report
  2. Verify all existing functionality still works identically
  3. Verify PDF Layout, Timezone, Load Saved, AI generation all work
  4. Test both portrait and landscape exports

Step 7: Commit

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

typescript
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

bash
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

bash
git add -A
git commit -m "chore: lint fixes and final cleanup"