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
| File | Lines | Class |
|---|---|---|
src/features/dust-levels/services/exportService.ts | 1,173 | DustLevelExportService |
src/features/dust-ranger/services/dustRangerExportService.ts | 1,362 | DustRangerExportService |
src/features/flow-meter/services/flowMeterPdfExportService.ts | 1,040 | FlowMeterPdfExportService |
| Total | 3,575 |
Target State
| File | Est. Lines | Purpose |
|---|---|---|
src/features/exports/services/basePdfExportService.ts | ~700 | All shared PDF layout logic |
src/features/exports/services/chartCapture.ts | ~200 | Chart capture utilities (SVG/Canvas/domToJpeg) |
src/features/exports/services/pdfTypes.ts | ~60 | Shared interfaces for PDF report config |
src/features/dust-levels/services/exportService.ts | ~80 | Config + call base |
src/features/dust-ranger/services/dustRangerExportService.ts | ~80 | Config + call base |
src/features/flow-meter/services/flowMeterPdfExportService.ts | ~60 | Config + call base |
| Total | ~1,180 |
Shared Config Interface
// 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 SVGwaitForChartRender()function (~40 lines) — polls for chart render completion
Add a new wrapper:
captureDomToJpeg()— wrapsdomToJpegfrom 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:
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:
- PDF initialization (jsPDF constructor, page dimensions, margin)
loadImageAsBase64()— identicalcheckPageBreak()— identicalformatSiteName()— identicaldrawHeader()— parameterized byheaderText- Color constants — identical
drawBackgroundShapes()— identical- Cover page top bar + logo — identical
- Landscape cover layout — parameterized by title, entity name, cards
- Portrait cover layout — parameterized by title lines, entity name, cards,
showPortraitTimezone drawCard()/drawCardLandscape()— identical (receive card data)drawGenericKeyInsights()— identical- Chart capture loop — parameterized by
captureChart,imageFormat,getChartScale,formatChartTitle,beforeChartCapture - Summary section — identical
- Footer (page numbers + copyright) — identical
- 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:
- Build
PdfReportDefinitionfromExportConfig+SiteSummary - Call
BasePdfExportService.exportReport(definition, onProgress) - Keep
getLegacyCharts()as a local fallback ingetChartElements - Keep
ExportConfigtype (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:captureDomToJpegfrom chartCapture.tsimageFormat:"JPEG"getChartScale: higher quality fordust-level-temp-chartformatChartTitle: appendsat ${siteName}fordust-level-temp-chartgetChartElements: DOM discovery withgetLegacyChartsfallback
Task 5: Refactor DustRangerExportService to use base
Files:
- Modify:
src/features/dust-ranger/services/dustRangerExportService.ts
Replace the ~1,362 line class with:
- Build
PdfReportDefinitionfromDustRangerExportConfig+DustRangerStats - Call
BasePdfExportService.exportReport(definition, onProgress) - Keep
DustRangerExportConfigtype (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:captureChartElementfrom chartCapture.tsimageFormat:"PNG"getChartScale: higher scale for time-series chartsformatChartTitle: always appendsat ${name}showPortraitTimezone:truebeforeChartCapture:waitForChartRenderfrom chartCapture.tsgetChartElements: DOM discovery withh4fallback
Task 6: Refactor FlowMeterPdfExportService to use base
Files:
- Modify:
src/features/flow-meter/services/flowMeterPdfExportService.ts
Replace the ~1,040 line class with:
- Build
PdfReportDefinitionfromFlowMeterExportConfig+SiteSummary - 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:captureDomToJpegfrom chartCapture.tsimageFormat:"JPEG"getChartScale: fixed quality 0.8 / scale 2 for allgetChartElements: DOM discovery only (no legacy fallback)
Task 7: Verify build and existing behavior
Steps:
- Run
pnpm exec tsc --noEmit— no new errors - Run
pnpm build— build succeeds - Verify all 3 export pages still import correctly
- Check that the public API of each feature service is unchanged (same class name, same
exportReportsignature)
Key Design Decisions
Static class, not inheritance —
BasePdfExportServiceis a utility with a static method, not a base class. Feature services compose it via config objects. This avoids class hierarchy complexity.Config object, not callbacks everywhere — Most customization is data (strings, card arrays). Only chart capture and a few hooks are functions.
Backward-compatible public API — Each feature service keeps its existing class name and
exportReport(config, data, onProgress)signature. Callers don't change.Chart capture as strategy — dust-levels/flow-meter use
domToJpeg, dust-ranger uses custom SVG/Canvas capture. Both are inchartCapture.ts, each feature picks its strategy.