Developer DocsData Model

Data Model

Every type the app persists is a plain TypeScript interface in src/types.ts, serialized to JSON in one localStorage key. There is no server and no database — the data model is the persisted object. This page documents that object faithfully against the source; for how it is loaded and seeded see Architecture, and for the constant catalog see Settings & Constants.

Persisted shape

src/storage.ts reads and writes a single key, signquote.v1, holding one PersistedState.

// src/types.ts
export interface PersistedState {
  settings: Settings
  quotes: Quote[]
  meta: { schemaVersion: number; nextQuoteNumber: number }
}
  • settings — the shop’s single global “rate card” (see Settings).
  • quotes — every saved quote, newest appended.
  • meta.schemaVersion — currently 1. On load, loadState() validates parsed.meta.schemaVersion === 1 (plus an array quotes and a present settings); anything else — corrupt JSON, a stale version, unavailable storage — falls through to a fresh reseed.
  • meta.nextQuoteNumber — the next human-facing quote number to hand out. The seed state ships with two quotes (1001, 1002) and nextQuoteNumber: 1003.

Storage never throws to the UI. saveState swallows write failures (private-mode quota, etc.), and loadState swallows parse/validation failures by reseeding. The store is therefore never empty on first run.

Quote

export interface Quote {
  id: string
  number: number // 1001, 1002, ...
  createdAt: string // ISO
  customer: Customer
  config: SignConfig
  pricing: PricingResult // snapshot; recomputed on every config change and on open
  pdfMode: 'itemized' | 'total_only'
}

A Quote bundles who (customer), what (config — the Table A snapshot), and how much (pricing). The id is a stable internal key (the seeds use seed-cafe-1001 / seed-law-1002); number is the 1001+ value shown to people.

pricing is a recomputed snapshot, not stored truth. It is the output of computePricing(config, settings) and is regenerated on every config change and on open — see the relations note below.

export interface Customer {
  name: string // required before PDF
  company?: string
  email?: string
  phone?: string
  jobAddress?: string
}

Only name is required, and only at the point of producing a PDF.

SignConfig

SignConfig is a discriminated union keyed on productCategory. Both variants share a BaseSignConfig, then add fields specific to their family.

export type SignConfig = ChannelLetterConfig | DimensionalLetterConfig
export type ProductCategory = 'channel_letter' | 'dimensional_letter'

Shared base fields

FieldTypeNotes
signTextLabelstringDisplay-only, never priced [A1]
letterCountnumberInteger 1–100
letterHeightnumberInches; CL 6–60, DL 6–24 [A2]
wallSurfaceWallSurfaceSee enum below
installHeightAccessInstallAccessSee enum below
permitRequiredboolean
engineeringRequiredboolean
markupMultipliernumber1.5–3.0, step 0.05; CL cost-plus path / DL dealer markup

Shared enums:

TypeAllowed values
WallSurface'drywall' | 'brick' | 'block' | 'EIFS' | 'stone' | 'metal' | 'concrete'
InstallAccess'ground_ladder' | 'bucket' | 'lift' | 'crane'

ChannelLetterConfig

export interface ChannelLetterConfig extends BaseSignConfig {
  productCategory: 'channel_letter'
  illuminationType: IlluminationType
  returnDepth: ReturnDepth
  faceMaterial: FaceMaterial
  ledProductId: string
  mountType: ClMountType
}
FieldTypeAllowed values / notes
illuminationTypeIlluminationType'front_lit' | 'halo_reverse'
returnDepthReturnDepth3 | 4 | 5 | 6 (inches)
faceMaterialFaceMaterial'white_acrylic_3_16' | 'colored_acrylic' — rendered only for front_lit; halo has no acrylic face
ledProductIdstringReferences an id in Settings.ledProducts
mountTypeClMountType'flush_stud' | 'raceway'

DimensionalLetterConfig

export interface DimensionalLetterConfig extends BaseSignConfig {
  productCategory: 'dimensional_letter'
  dimMaterial: DimMaterial
  dimFinish: DimFinish
  dimMount: DimMount
}

Dimensional letters are implicitly non-illuminated — there is no illumination field.

FieldTypeAllowed values
dimMaterialDimMaterial'routed_pvc_quarter' | 'flat_acrylic_quarter' | 'flat_aluminum_quarter' | 'cast_aluminum' | 'cast_bronze'
dimFinishDimFinish'standard_included' | 'painted_custom_color' | 'polished'
dimMountDimMount'flush_stud' | 'spacer_standoff' | 'rail'

PricingResult

pricing on a Quote is a PricingResult. The lineItems and total are common to both families; the rest are family-specific and several are owner-only — they must never render in the quote editor or the PDF (the role boundary, tested as AC-10).

export interface PricingResult {
  lineItems: LineItem[]
  total: number // full precision; round to whole dollars at display time [A18]
  // Channel letters only:
  fabPerInch?: number
  fabCostPlus?: number
  costSubtotal?: number // the "floor" — owner-only
  marginPct?: number
  badge?: MarginBadge
  derived?: ClDerived
  clBreakdown?: ClCostBreakdown // owner-only (Settings preview)
  // Dimensional letters only:
  dl?: DlBreakdown
}
 
export interface LineItem {
  key: string // fabrication | supplied | install | permit | engineering | design | minimum
  label: string
  amount: number
}
 
export type MarginBadge = 'green' | 'amber' | 'red'
FieldFamilyOwner-only?
lineItems, totalbothno — these drive the editor and PDF
fabPerInch, fabCostPlusCLno
marginPct, badgeCLno — the margin badge is the one cost-derived signal salespeople see
costSubtotalCLyes — the cost “floor”
clBreakdownCLyes — full decomposition, Settings preview only
derived (ClDerived)CLmixed — derived.installHours is owner-only
dl (DlBreakdown)DLmixed — dl.installHours is owner-only
⚠️

costSubtotal, clBreakdown, derived.installHours, and dl.installHours are explicitly flagged owner-only in src/types.ts. They live in the persisted snapshot but must only surface on the Settings preview panel — never in the editor or the PDF. See AC-10.

Channel-letter detail shapes

export interface ClDerived {
  modsPerLetter: number
  jobModules: number
  totalWatts: number
  psCount: number
  installHours: number // owner-facing only — never render in editor/PDF (AC-10)
}
 
export interface ClCostBreakdown {
  perLetter: {
    coil: number
    face: number
    backing: number
    trimCap: number
    leds: number
    wireStuds: number
    total: number
  }
  job: {
    lettersMaterial: number
    powerSupplies: number
    raceway: number
    wasteAllowance: number
    materialTotal: number
  }
  labor: {
    sizeFactor: number
    cncBendHours: number
    assemblyHours: number
    cncBendCost: number
    assemblyCost: number
    burden: number
    laborCost: number
  }
  markupMultiplier: number
}

Dimensional-letter detail shape

export interface DlBreakdown {
  gridBasePerLetter: number // lerp result before multipliers
  finishMult: number
  mountMult: number
  basePerLetter: number // after finish & mount multipliers
  wholesale: number
  heightClamped: boolean // height outside the 6–24" grid was clamped [A17]
  installHours: number // owner-facing only
  installCapApplied: boolean // the taxonomy's "2× wholesale" cap kicked in
}

Settings

Settings is the shop’s single global rate card — one per store, edited on the Settings screen.

export interface Settings {
  shop: ShopInfo
  constants: Constants
  ledProducts: LedProduct[]
  dimGrid: DimGridRow[]
}
  • shop (ShopInfo) — branding and quote boilerplate: name, address, phone, email, optional logoDataUrl, quoteValidityDays, and terms.
  • constants (Constants) — the Table B cost constants (per-inch rates, material costs, LED/power, labor, install, fees, margin thresholds, DL multipliers). The full catalog with seeded values is documented in Settings & Constants rather than repeated here.
  • ledProducts (LedProduct[]) — the LED catalog a CL ledProductId points into.
  • dimGrid (DimGridRow[]) — the per-letter price grid for dimensional letters at the 6/12/18/24-inch anchors.
export interface LedProduct {
  id: string
  label: string
  costPerModule: number
  wattsPerModule: number
  densityPerFt: number
  note: string
}
 
export interface DimGridRow {
  materialId: DimMaterial
  label: string
  thicknessNote: string // baked into the preset, read-only in the form [A4]
  baseFinishNote: string // the finish already included in the grid price [A5]
  prices: { h6: number; h12: number; h18: number; h24: number }
}

Seed quotes

buildSeedState() in src/data/defaults.ts ships two quotes that reproduce the taxonomy’s worked examples exactly (see Acceptance & Testing). Their pricing is computed at seed time with computePricing(config, settings), so the seed is self-checking.

#1001 — CAFE (channel letter, AC-1)

Customer Maria Torres, Cafe Luna; createdAt 2026-06-08T15:00:00.000Z; pdfMode: 'itemized'.

export const SEED_CAFE_CONFIG: ChannelLetterConfig = {
  productCategory: 'channel_letter',
  signTextLabel: 'CAFE',
  letterCount: 4,
  letterHeight: 18,
  wallSurface: 'brick',
  installHeightAccess: 'bucket',
  permitRequired: true,
  engineeringRequired: false,
  markupMultiplier: 2,
  illuminationType: 'front_lit',
  returnDepth: 5,
  faceMaterial: 'white_acrylic_3_16',
  ledProductId: 'sloan_prism12',
  mountType: 'flush_stud',
}

#1002 — LAW OFFICE (dimensional letter, AC-2)

Customer David Chen, Chen & Associates LLP; createdAt 2026-06-09T16:30:00.000Z; pdfMode: 'itemized'.

export const SEED_LAW_OFFICE_CONFIG: DimensionalLetterConfig = {
  productCategory: 'dimensional_letter',
  signTextLabel: 'LAW OFFICE',
  letterCount: 8,
  letterHeight: 12,
  wallSurface: 'drywall',
  installHeightAccess: 'ground_ladder',
  permitRequired: false,
  engineeringRequired: false,
  markupMultiplier: 1.5,
  dimMaterial: 'cast_aluminum',
  dimFinish: 'standard_included',
  dimMount: 'flush_stud',
}

Relations & snapshot semantics

The model has no foreign keys. A Quote embeds a full copy of its config and its pricing (both deep-cloned at seed time via structuredClone). The only catalog reference is the value config.ledProductId, matched by id against Settings.ledProducts at compute time.

The single global rate card is Settings.constants (with ledProducts and dimGrid). Because each quote stores a pricing snapshot, editing Settings does not retroactively change prices on already-saved quotes. The snapshot is recomputed on every config change and on open, so re-opening a quote re-prices it against the current settings — the saved snapshot is a cache, not the source of truth.