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

PDF Export Service Refactoring Plan

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

Goal: Extract ~2,100 lines of duplicated PDF layout code from 3 feature services into a shared BasePdfExportService, reducing total from ~3,575 lines to ~900 lines.

Architecture: Create a configuration-driven base service in src/features/exports/services/. Each feature defines a config object (report metadata, card data, chart capture strategy) and delegates to the base. The dust-ranger captureChartElement and waitForChartRender functions move to a shared chartCapture.ts utility.

Tech Stack: jsPDF, modern-screenshot (domToJpeg), TypeScript


Current State

FileLinesClass
src/features/dust-levels/services/exportService.ts1,173DustLevelExportService
src/features/dust-ranger/services/dustRangerExportService.ts1,362DustRangerExportService
src/features/flow-meter/services/flowMeterPdfExportService.ts1,040FlowMeterPdfExportService
Total3,575

Target State

FileEst. LinesPurpose
src/features/exports/services/basePdfExportService.ts~700All shared PDF layout logic
src/features/exports/services/chartCapture.ts~200Chart capture utilities (SVG/Canvas/domToJpeg)
src/features/exports/services/pdfTypes.ts~60Shared interfaces for PDF report config
src/features/dust-levels/services/exportService.ts~80Config + call base
src/features/dust-ranger/services/dustRangerExportService.ts~80Config + call base
src/features/flow-meter/services/flowMeterPdfExportService.ts~60Config + call base
Total~1,180

Shared Config Interface

typescript
// pdfTypes.ts
export interface PdfReportDefinition {
  // Report metadata
  headerText: string;                    // e.g. "DUST LEVEL MONITORING REPORT"
  landscapeTitle: string;                // e.g. "Dust Level Monitoring"
  portraitTitleLines: string[];           // e.g. ["Dust Level", "Monitoring"]
  entityName: string;                    // site name or ranger name
  filenamePrefix: string;                // e.g. "dust-report"

  // Config passthrough
  includeCharts: Record<string, boolean>;
  chartDescriptions: Record<string, string>;
  dateRange?: string;
  orientation?: "portrait" | "landscape";
  timezone?: string;

  // Summary cards (4 cards)
  cards: CardDefinition[];

  // Chart capture strategy
  captureChart: (element: HTMLElement, scale: number) => Promise<string>;
  imageFormat: "JPEG" | "PNG";

  // Per-chart customization
  getChartScale?: (chartId: string) => { quality: number; scale: number };
  formatChartTitle?: (title: string, chartId?: string) => string;

  // Chart discovery
  getChartElements: (includeCharts: Record<string, boolean>) => ChartElement[];

  // Optional layout overrides
  showPortraitTimezone?: boolean;        // dust-ranger shows timezone row in portrait
  beforeChartCapture?: (element: HTMLElement) => Promise<void>; // dust-ranger waitForChartRender
}

export interface CardDefinition {
  title: string;
  value: string;
  unit: string;
  style: "default" | "orange" | "dark";
}

export interface ChartElement {
  element?: HTMLElement;
  id?: string;
  descriptionKey?: string;
  title: string;
}

Tasks

Task 1: Create shared types (pdfTypes.ts)

Files:

  • Create: src/features/exports/services/pdfTypes.ts

Define PdfReportDefinition, CardDefinition, ChartElement interfaces.


Task 2: Extract chart capture utilities (chartCapture.ts)

Files:

  • Create: src/features/exports/services/chartCapture.ts

Move from dustRangerExportService.ts:

  • captureChartElement() function (~170 lines) — handles ECharts canvas, ApexCharts SVG, generic SVG
  • waitForChartRender() function (~40 lines) — polls for chart render completion

Add a new wrapper:

  • captureDomToJpeg() — wraps domToJpeg from modern-screenshot with the standard filter/quality/scale options (used by dust-levels and flow-meter)

Export both strategies so each feature can pick which one to use.


Task 3: Create BasePdfExportService

Files:

  • Create: src/features/exports/services/basePdfExportService.ts

Extract all shared logic from the 3 services into a single static class:

typescript
export class BasePdfExportService {
  static async exportReport(
    definition: PdfReportDefinition,
    onProgress?: OnExportProgress
  ): Promise<void>;

  // Private helpers (all identical across 3 services):
  private static loadImageAsBase64(path: string): Promise<string>;
  // All other layout logic is inline in exportReport
}

Shared layout blocks to extract:

  1. PDF initialization (jsPDF constructor, page dimensions, margin)
  2. loadImageAsBase64() — identical
  3. checkPageBreak() — identical
  4. formatSiteName() — identical
  5. drawHeader() — parameterized by headerText
  6. Color constants — identical
  7. drawBackgroundShapes() — identical
  8. Cover page top bar + logo — identical
  9. Landscape cover layout — parameterized by title, entity name, cards
  10. Portrait cover layout — parameterized by title lines, entity name, cards, showPortraitTimezone
  11. drawCard() / drawCardLandscape() — identical (receive card data)
  12. drawGenericKeyInsights() — identical
  13. Chart capture loop — parameterized by captureChart, imageFormat, getChartScale, formatChartTitle, beforeChartCapture
  14. Summary section — identical
  15. Footer (page numbers + copyright) — identical
  16. Save PDF — parameterized by filenamePrefix + entityName

Task 4: Refactor DustLevelExportService to use base

Files:

  • Modify: src/features/dust-levels/services/exportService.ts

Replace the ~1,173 line class with:

  1. Build PdfReportDefinition from ExportConfig + SiteSummary
  2. Call BasePdfExportService.exportReport(definition, onProgress)
  3. Keep getLegacyCharts() as a local fallback in getChartElements
  4. Keep ExportConfig type (imported by ExportModal component)

Feature-specific details:

  • headerText: "DUST LEVEL MONITORING REPORT"
  • landscapeTitle: "Dust Level Monitoring"
  • portraitTitleLines: ["Dust Level", "Monitoring"]
  • Cards: Average Dust (mg/m³, orange), Maximum Dust (mg/m³, dark), Recorded Duration (Days), Corrugations Average
  • captureChart: captureDomToJpeg from chartCapture.ts
  • imageFormat: "JPEG"
  • getChartScale: higher quality for dust-level-temp-chart
  • formatChartTitle: appends at ${siteName} for dust-level-temp-chart
  • getChartElements: DOM discovery with getLegacyCharts fallback

Task 5: Refactor DustRangerExportService to use base

Files:

  • Modify: src/features/dust-ranger/services/dustRangerExportService.ts

Replace the ~1,362 line class with:

  1. Build PdfReportDefinition from DustRangerExportConfig + DustRangerStats
  2. Call BasePdfExportService.exportReport(definition, onProgress)
  3. Keep DustRangerExportConfig type (re-exports from exports/types)

Feature-specific details:

  • headerText: "DUST RANGER MONITORING REPORT"
  • landscapeTitle: "Dust Ranger Monitoring"
  • portraitTitleLines: ["Dust Ranger", "Monitoring"]
  • Cards: Total Records (orange), Unique Vehicles/Static Ranger (dark), Average PM10 (µg/m³), Unique Sites
  • captureChart: captureChartElement from chartCapture.ts
  • imageFormat: "PNG"
  • getChartScale: higher scale for time-series charts
  • formatChartTitle: always appends at ${name}
  • showPortraitTimezone: true
  • beforeChartCapture: waitForChartRender from chartCapture.ts
  • getChartElements: DOM discovery with h4 fallback

Task 6: Refactor FlowMeterPdfExportService to use base

Files:

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

Replace the ~1,040 line class with:

  1. Build PdfReportDefinition from FlowMeterExportConfig + SiteSummary
  2. Call BasePdfExportService.exportReport(definition, onProgress)

Feature-specific details:

  • headerText: "DUSTLOC USAGE REPORT"
  • landscapeTitle: "Dustloc Usage"
  • portraitTitleLines: ["Dustloc Usage", "Report"]
  • Cards: Total Dustloc Used (Litres, orange), Total Refilled (Litres, dark), Days Recorded (Days), Daily Average (L/day)
  • captureChart: captureDomToJpeg from chartCapture.ts
  • imageFormat: "JPEG"
  • getChartScale: fixed quality 0.8 / scale 2 for all
  • getChartElements: DOM discovery only (no legacy fallback)

Task 7: Verify build and existing behavior

Steps:

  1. Run pnpm exec tsc --noEmit — no new errors
  2. Run pnpm build — build succeeds
  3. Verify all 3 export pages still import correctly
  4. Check that the public API of each feature service is unchanged (same class name, same exportReport signature)

Key Design Decisions

  1. Static class, not inheritanceBasePdfExportService is a utility with a static method, not a base class. Feature services compose it via config objects. This avoids class hierarchy complexity.

  2. Config object, not callbacks everywhere — Most customization is data (strings, card arrays). Only chart capture and a few hooks are functions.

  3. Backward-compatible public API — Each feature service keeps its existing class name and exportReport(config, data, onProgress) signature. Callers don't change.

  4. Chart capture as strategy — dust-levels/flow-meter use domToJpeg, dust-ranger uses custom SVG/Canvas capture. Both are in chartCapture.ts, each feature picks its strategy.