From aed764f9f4bd525aeded2a4c7def19c5efec5b0f Mon Sep 17 00:00:00 2001 From: jcotillo Date: Fri, 4 Sep 2026 11:57:35 -0400 Subject: [PATCH] feat(maintenance): amenity-driven maintenance plan catalog and planner (TURNWRK-630) Turnwrk stored amenities and assets on a property but nothing read them, so a property's maintenance load could not be derived. This adds the catalog and the pure planner behind it: - PropertyAmenity widened from seven classes to the real STR set (heated pool, hot tub, pickleball court, putting green, arcade, outdoor bar, fire pit, safety equipment, access hardware, ...) without changing the original seven. - src/maintenance/catalog.ts: per amenity class, what a tech checks on every turn, which preventive tasks recur (cadence, trade, minutes, checklist sections) and how often it breaks and who answers. Data, in the vertical pack style; every number is a tunable default. - src/maintenance/assets.ts: keyword classification of the free-text asset register (water heater, HVAC, pool pump, smart lock, ...) with the preventive work each implies. - src/maintenance/aliases.ts: the one table that turns Airbnb and operator labels into classes, with three outcomes (matched, ignored supply or policy labels, unmapped). Unknown labels are surfaced, never dropped; "Unavailable:" rows are absences. - src/maintenance/plan.ts: planForProperty folds amenities and assets into schedules to create, a composed per-turn inspection, and a monthly minutes load split by trade and by in-house versus specialty. Deterministic, no I/O, integers out. Tests: alias normalization, catalog invariants (unique keys, PM-expressible cadences, namespaced item ids), planner math and edges, and the Palmshine Hideaway fixture read from the live Airbnb amenities modal (all 62 labelled rows place; zero unmapped). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01S8xWFH8zVEkciKR59Tq3sm --- package.json | 5 + src/index.ts | 1 + src/maintenance/aliases.ts | 371 ++++++++ src/maintenance/assets.ts | 281 ++++++ src/maintenance/catalog.ts | 1037 +++++++++++++++++++++++ src/maintenance/index.ts | 5 + src/maintenance/plan.ts | 203 +++++ src/maintenance/types.ts | 185 ++++ src/types/property.ts | 56 +- tests/maintenance/aliases.test.ts | 71 ++ tests/maintenance/catalog.test.ts | 87 ++ tests/maintenance/fixtures/palmshine.ts | 107 +++ tests/maintenance/plan.test.ts | 177 ++++ 13 files changed, 2585 insertions(+), 1 deletion(-) create mode 100644 src/maintenance/aliases.ts create mode 100644 src/maintenance/assets.ts create mode 100644 src/maintenance/catalog.ts create mode 100644 src/maintenance/index.ts create mode 100644 src/maintenance/plan.ts create mode 100644 src/maintenance/types.ts create mode 100644 tests/maintenance/aliases.test.ts create mode 100644 tests/maintenance/catalog.test.ts create mode 100644 tests/maintenance/fixtures/palmshine.ts create mode 100644 tests/maintenance/plan.test.ts diff --git a/package.json b/package.json index a71695f..bc906ca 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,11 @@ "require": "./dist/verticals/index.js", "default": "./dist/verticals/index.js" }, + "./maintenance": { + "types": "./dist/maintenance/index.d.ts", + "require": "./dist/maintenance/index.js", + "default": "./dist/maintenance/index.js" + }, "./booking": { "types": "./dist/booking/index.d.ts", "require": "./dist/booking/index.js", diff --git a/src/index.ts b/src/index.ts index c0ebd3a..cfefa5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export * from './propertyAddress'; export * from './propertyStorage'; export * from './occupancy'; export * from './verticals'; +export * from './maintenance'; export * from './proof'; export * from './service'; export * from './crm'; diff --git a/src/maintenance/aliases.ts b/src/maintenance/aliases.ts new file mode 100644 index 0000000..50b6a00 --- /dev/null +++ b/src/maintenance/aliases.ts @@ -0,0 +1,371 @@ +/** + * Free-text amenity labels to catalog classes (TURNWRK-630). + * + * An Airbnb amenity list, a Vrbo feature list, or an operator's notes never + * arrive as `PropertyAmenity` values. This is the one table that turns them + * into classes, shared by every caller (dispatch, str-manager's scrape + * handoff, Hermes) so they cannot disagree about what "Private BBQ grill" is. + * + * Three outcomes, and the caller must keep all three: + * matched → a class the catalog can plan + * ignored → a real label with no maintenance load (supply, service, + * marketing copy); restock owns consumables, not this plan + * unmapped → we do not recognise it; surface it, never drop it + * + * Adding a label is a one-line change here. Adding a CLASS means a catalog + * entry too, or the plan will match it and then plan nothing for it. + */ +import type { PropertyAmenity } from '../types/property'; + +export type AmenityNormalization = + | { kind: 'matched'; amenity: PropertyAmenity; input: string } + | { kind: 'ignored'; input: string } + | { kind: 'unmapped'; input: string }; + +/** Lower-cased, punctuation-collapsed label → class. Exact match after `keyOf`. */ +const ALIASES: Readonly> = { + // water + pool: 'pool', + 'private pool': 'pool', + 'shared pool': 'pool', + 'outdoor pool': 'pool', + 'swimming pool': 'pool', + 'heated pool': 'heated-pool', + 'pool heater': 'heated-pool', + 'hot tub': 'hot-tub', + 'private hot tub': 'hot-tub', + spa: 'hot-tub', + jacuzzi: 'hot-tub', + 'screened lanai': 'screened-lanai', + 'screened porch': 'screened-lanai', + 'screened pool': 'screened-lanai', + 'pool cage': 'screened-lanai', + lanai: 'screened-lanai', + sauna: 'sauna', + 'cold plunge': 'cold-plunge', + 'boat dock': 'boat-dock', + dock: 'boat-dock', + 'boat slip': 'boat-dock', + kayak: 'watercraft', + kayaks: 'watercraft', + paddleboard: 'watercraft', + paddleboards: 'watercraft', + 'paddle boards': 'watercraft', + canoe: 'watercraft', + // play + 'pickleball court': 'pickleball-court', + pickleball: 'pickleball-court', + 'tennis court': 'sport-court', + 'sport court': 'sport-court', + 'sports court': 'sport-court', + 'basketball court': 'sport-court', + 'basketball hoop': 'basketball-hoop', + basketball: 'basketball-hoop', + 'putting green': 'putting-green', + 'mini golf': 'putting-green', + 'game room': 'game-room', + 'ping pong table': 'game-room', + 'ping pong': 'game-room', + 'pool table': 'game-room', + 'billiards table': 'game-room', + foosball: 'game-room', + 'foosball table': 'game-room', + 'air hockey': 'game-room', + 'air hockey table': 'game-room', + 'shuffleboard table': 'game-room', + darts: 'game-room', + 'arcade games': 'arcade', + 'arcade game': 'arcade', + arcade: 'arcade', + 'arcade machine': 'arcade', + 'life size games': 'yard-games', + 'lawn games': 'yard-games', + 'yard games': 'yard-games', + cornhole: 'yard-games', + 'outdoor playground': 'playground', + playground: 'playground', + 'swing set': 'playground', + trampoline: 'trampoline', + bikes: 'bikes', + bicycles: 'bikes', + 'golf cart': 'golf-cart', + // outdoor living + 'outdoor bar': 'outdoor-bar', + 'tiki bar': 'outdoor-bar', + speakeasy: 'outdoor-bar', + 'wet bar': 'outdoor-bar', + 'fire pit': 'fire-pit', + firepit: 'fire-pit', + 'bbq grill': 'outdoor-grill', + 'private bbq grill': 'outdoor-grill', + 'shared bbq grill': 'outdoor-grill', + grill: 'outdoor-grill', + 'gas grill': 'outdoor-grill', + 'outdoor kitchen': 'outdoor-grill', + 'outdoor furniture': 'outdoor-furniture', + 'outdoor dining area': 'outdoor-furniture', + 'outdoor dining': 'outdoor-furniture', + 'patio or balcony': 'outdoor-furniture', + 'patio': 'outdoor-furniture', + 'lounge chairs': 'outdoor-furniture', + hammock: 'outdoor-furniture', + 'beach essentials': 'beach-gear', + 'beach chairs': 'beach-gear', + 'beach items': 'beach-gear', + backyard: 'yard', + 'private backyard': 'yard', + 'fenced yard': 'yard', + garden: 'yard', + // systems + 'air conditioning': 'hvac', + 'central air conditioning': 'hvac', + ac: 'hvac', + heating: 'hvac', + 'central heating': 'hvac', + 'ceiling fan': 'ceiling-fan', + 'ceiling fans': 'ceiling-fan', + 'hot water': 'water-heater', + 'water heater': 'water-heater', + 'ev charger': 'ev-charger', + 'electric vehicle charger': 'ev-charger', + generator: 'generator', + 'backup generator': 'generator', + elevator: 'elevator', + 'smart lock': 'access-hardware', + 'keypad': 'access-hardware', + lockbox: 'access-hardware', + 'self check in': 'access-hardware', + 'exterior security cameras on property': 'security-camera', + 'security cameras': 'security-camera', + 'security camera': 'security-camera', + 'ring doorbell': 'security-camera', + 'noise decibel monitors on property': 'noise-monitor', + 'noise monitor': 'noise-monitor', + 'smoke alarm': 'safety-equipment', + 'smoke detector': 'safety-equipment', + 'carbon monoxide alarm': 'safety-equipment', + 'carbon monoxide detector': 'safety-equipment', + 'fire extinguisher': 'safety-equipment', + 'first aid kit': 'safety-equipment', + 'pool alarm': 'safety-equipment', + // interior + kitchen: 'kitchen', + 'full kitchen': 'kitchen', + refrigerator: 'kitchen', + fridge: 'kitchen', + freezer: 'kitchen', + microwave: 'kitchen', + dishwasher: 'kitchen', + stove: 'kitchen', + oven: 'kitchen', + 'coffee maker': 'kitchen', + toaster: 'kitchen', + 'garbage disposal': 'kitchen', + blender: 'kitchen', + washer: 'laundry', + dryer: 'laundry', + 'washer and dryer': 'laundry', + 'free washer in unit': 'laundry', + 'free dryer in unit': 'laundry', + 'laundry': 'laundry', + fireplace: 'fireplace', + 'indoor fireplace': 'fireplace', + gym: 'gym', + 'exercise equipment': 'gym', + 'home gym': 'gym', + bathtub: 'bathtub', + 'bath tub': 'bathtub', + 'soaking tub': 'bathtub', + tv: 'tv', + 'smart tv': 'tv', + hdtv: 'tv', + television: 'tv', + wifi: 'wifi', + 'wi fi': 'wifi', + internet: 'wifi', + 'fast wifi': 'wifi', + 'dedicated workspace': 'dedicated-workspace', + workspace: 'dedicated-workspace', + 'pack n play travel crib': 'family-kit', + 'pack n play': 'family-kit', + 'travel crib': 'family-kit', + crib: 'family-kit', + 'high chair': 'family-kit', + 'children s books and toys': 'family-kit', + 'baby bath': 'family-kit', + 'baby monitor': 'family-kit', + 'changing table': 'family-kit', +}; + +/** + * Labels we recognise and deliberately do not plan for: consumables and + * linens (restock's domain), Airbnb marketing or policy flags, and structural + * facts with no recurring work. Kept explicit so "unmapped" stays meaningful. + */ +const IGNORED: ReadonlySet = new Set([ + // bathroom consumables + 'hair dryer', + 'cleaning products', + 'shampoo', + 'conditioner', + 'body soap', + 'shower gel', + 'bidet', + 'essentials', + // bedroom and laundry supply + 'hangers', + 'bed linens', + 'extra pillows and blankets', + 'iron', + 'clothing storage', + 'room darkening shades', + 'safe', + 'drying rack for clothing', + // entertainment and family soft goods + 'books and reading material', + 'board games', + 'children s dinnerware', + 'babysitter recommendations', + 'sound system', + 'bluetooth sound system', + 'record player', + 'piano', + // kitchen supply + 'cooking basics', + 'dishes and silverware', + 'wine glasses', + 'dining table', + 'baking sheet', + 'barbecue utensils', + 'hot water kettle', + 'rice maker', + 'mini fridge', + // location, parking, services, policy + 'free parking on premises', + 'free street parking', + 'paid parking off premises', + 'paid parking on premises', + 'long term stays allowed', + 'luggage dropoff allowed', + 'private entrance', + 'private living room', + 'beach access', + 'beachfront', + 'waterfront', + 'lake access', + 'resort access', + 'ski in ski out', + 'single level home', + 'host greets you', + 'building staff', + 'cleaning available during stay', + 'breakfast', + 'pets allowed', + 'smoking allowed', + 'ethernet connection', + // views + 'bay view', + 'beach view', + 'canal view', + 'city skyline view', + 'courtyard view', + 'garden view', + 'golf course view', + 'harbor view', + 'lake view', + 'marina view', + 'mountain view', + 'ocean view', + 'park view', + 'pool view', + 'resort view', + 'river view', + 'sea view', + 'valley view', + 'water view', + 'mobile hotspot', + 'window ac unit', + 'portable fans', + 'window guards', + 'outlet covers', + 'stair gates', + 'table corner guards', + 'fireplace guards', +]); + +/** Lower-case, drop the Airbnb "Unavailable:" prefix, collapse punctuation to spaces. */ +export function amenityKey(label: string): string { + return label + .toLowerCase() + .replace(/^unavailable:\s*/, '') + .replace(/[’'"`]/g, ' ') + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +/** True for Airbnb's struck-through "Unavailable: X" rows; they describe an absence. */ +export function isUnavailableLabel(label: string): boolean { + return /^\s*unavailable:/i.test(label); +} + +/** The full class list, for validation and pickers. Derived from the alias table plus the original seven. */ +export const KNOWN_AMENITY_CLASSES: ReadonlySet = new Set([ + 'kitchen', + 'laundry', + 'pool', + 'hot-tub', + 'outdoor-grill', + 'fireplace', + 'gym', + ...Object.values(ALIASES), +]); + +export function isPropertyAmenity(value: string): value is PropertyAmenity { + return KNOWN_AMENITY_CLASSES.has(value); +} + +/** + * Normalize one label. A value that is already a class passes through. An + * "Unavailable:" row is ignored (it says the home lacks the thing). + */ +export function normalizeAmenity(label: string): AmenityNormalization { + const input = label; + const trimmed = label.trim(); + if (!trimmed) return { kind: 'ignored', input }; + if (isPropertyAmenity(trimmed)) return { kind: 'matched', amenity: trimmed, input }; + if (isUnavailableLabel(trimmed)) return { kind: 'ignored', input }; + const key = amenityKey(trimmed); + const hit = ALIASES[key]; + if (hit) return { kind: 'matched', amenity: hit, input }; + if (IGNORED.has(key)) return { kind: 'ignored', input }; + return { kind: 'unmapped', input }; +} + +export interface NormalizedAmenities { + /** Deduplicated, in first-seen order. */ + matched: PropertyAmenity[]; + ignored: string[]; + unmapped: string[]; +} + +export function normalizeAmenities(labels: readonly string[]): NormalizedAmenities { + const matched: PropertyAmenity[] = []; + const seen = new Set(); + const ignored: string[] = []; + const unmapped: string[] = []; + for (const label of labels) { + const n = normalizeAmenity(label); + if (n.kind === 'matched') { + if (!seen.has(n.amenity)) { + seen.add(n.amenity); + matched.push(n.amenity); + } + } else if (n.kind === 'ignored') { + ignored.push(n.input); + } else { + unmapped.push(n.input); + } + } + return { matched, ignored, unmapped }; +} diff --git a/src/maintenance/assets.ts b/src/maintenance/assets.ts new file mode 100644 index 0000000..b9b3794 --- /dev/null +++ b/src/maintenance/assets.ts @@ -0,0 +1,281 @@ +/** + * Asset register half of the catalog (TURNWRK-630). + * + * `PropertyMaintenance.assets` is free text a tech typed on an onboarding walk + * ("Rheem 50 gal water heater, garage"). This classifies those rows by keyword + * and attaches the preventive work an asset implies even when no amenity + * label mentions it. Matching is deliberately conservative: an asset that + * matches nothing comes back as unmapped, never guessed. + */ +import type { PropertyAsset } from '../types/property'; +import type { ChecklistTemplateItem } from '../types/checklist'; +import type { AssetMaintenanceSpec, MaintenanceAssetClass, PreventiveTaskSpec } from './types'; + +function check(id: string, label: string): ChecklistTemplateItem { + return { id, label, inputType: 'checkbox' }; +} + +function pm(spec: Omit & { items: ChecklistTemplateItem[] }): PreventiveTaskSpec { + const { items, ...rest } = spec; + return { ...rest, sections: [{ id: rest.key, title: rest.name, items }] }; +} + +export const ASSET_MAINTENANCE_CATALOG: readonly AssetMaintenanceSpec[] = [ + { + assetClass: 'hvac_unit', + label: 'HVAC unit', + keywords: ['hvac', 'air handler', 'condenser', 'heat pump', 'a/c', 'ac unit', 'air conditioner', 'mini split', 'furnace'], + preventive: [ + pm({ + key: 'asset_hvac_tune_up', + name: 'HVAC Tune Up (asset)', + description: 'Coil clean, refrigerant check, condensate line flush on the registered unit.', + category: 'HVAC', + trade: 'hvac', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 60, + priority: 'High', + items: [check('coils', 'Coils cleaned'), check('drain', 'Condensate line flushed')], + }), + ], + corrective: { callsPerYear: 2, trade: 'hvac', minutes: 90 }, + }, + { + assetClass: 'water_heater', + label: 'Water heater', + keywords: ['water heater', 'hot water heater', 'tankless', 'rheem', 'bradford white', 'a.o. smith', 'ao smith'], + preventive: [ + pm({ + key: 'asset_water_heater_flush', + name: 'Water Heater Flush (asset)', + description: 'Flush sediment, test relief valve, check anode rod.', + category: 'Plumbing', + trade: 'plumbing', + cadenceValue: 12, + cadenceUnit: 'months', + minutes: 60, + priority: 'Medium', + items: [check('flushed', 'Flushed'), check('tpr', 'Relief valve tested')], + }), + ], + corrective: { callsPerYear: 1, trade: 'plumbing', minutes: 90 }, + }, + { + assetClass: 'pool_pump', + label: 'Pool pump', + keywords: ['pool pump', 'variable speed pump', 'pentair', 'hayward', 'jandy', 'pool filter'], + preventive: [ + pm({ + key: 'asset_pool_pump_inspection', + name: 'Pool Pump Inspection (asset)', + description: 'Seals, basket, pressure, timer program.', + category: 'Pool', + trade: 'pool', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 30, + priority: 'Medium', + items: [check('seals', 'No seal leaks'), check('program', 'Timer program correct')], + }), + ], + corrective: { callsPerYear: 1, trade: 'pool', minutes: 90 }, + }, + { + assetClass: 'pool_heater', + label: 'Pool heater', + keywords: ['pool heater', 'pool heat pump', 'raypak', 'aquacal', 'heater for pool'], + preventive: [ + pm({ + key: 'asset_pool_heater_service', + name: 'Pool Heater Service (asset)', + description: 'Coils, condensate, ignition, error codes.', + category: 'Pool', + trade: 'pool', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 60, + priority: 'Medium', + items: [check('coils', 'Coils cleaned'), check('codes', 'No error codes')], + }), + ], + corrective: { callsPerYear: 2, trade: 'pool', minutes: 60 }, + }, + { + assetClass: 'garage_door', + label: 'Garage door', + keywords: ['garage door', 'garage opener', 'liftmaster', 'chamberlain', 'genie opener'], + preventive: [ + pm({ + key: 'asset_garage_door_service', + name: 'Garage Door Service (asset)', + description: 'Springs, rollers, sensors, lubrication, auto-reverse test.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 30, + priority: 'Medium', + items: [check('reverse', 'Auto-reverse tested'), check('lube', 'Rollers and hinges lubricated')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 90 }, + }, + { + assetClass: 'smart_lock', + label: 'Smart lock', + keywords: ['smart lock', 'schlage', 'yale', 'august lock', 'kwikset halo', 'keypad lock', 'deadbolt'], + preventive: [ + pm({ + key: 'asset_smart_lock_battery', + name: 'Smart Lock Battery (asset)', + description: 'Battery swap, firmware, strike alignment.', + category: 'Access', + trade: 'handyman', + cadenceValue: 60, + cadenceUnit: 'days', + minutes: 15, + priority: 'Medium', + items: [check('battery', 'Batteries replaced'), check('strike', 'Strike aligned')], + }), + ], + corrective: { callsPerYear: 1, trade: 'locksmith', minutes: 60 }, + }, + { + assetClass: 'washer_dryer', + label: 'Washer and dryer', + keywords: ['washer', 'dryer', 'laundry center', 'washing machine'], + preventive: [ + pm({ + key: 'asset_dryer_vent_clean', + name: 'Dryer Vent Clean (asset)', + description: 'Full vent run cleaned; washer hoses inspected.', + category: 'Appliances', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 60, + priority: 'High', + items: [check('vent', 'Vent run cleaned'), check('hoses', 'Hoses inspected')], + }), + ], + corrective: { callsPerYear: 2, trade: 'appliance', minutes: 60 }, + }, + { + assetClass: 'refrigerator', + label: 'Refrigerator', + keywords: ['refrigerator', 'fridge', 'ice maker'], + preventive: [ + pm({ + key: 'asset_fridge_service', + name: 'Refrigerator Coil and Ice Maker (asset)', + description: 'Coils vacuumed, water filter, ice maker, door seals.', + category: 'Appliances', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 30, + priority: 'Medium', + items: [check('coils', 'Coils vacuumed'), check('filter', 'Water filter replaced')], + }), + ], + corrective: { callsPerYear: 1, trade: 'appliance', minutes: 60 }, + }, + { + assetClass: 'dishwasher', + label: 'Dishwasher', + keywords: ['dishwasher'], + preventive: [ + pm({ + key: 'asset_dishwasher_service', + name: 'Dishwasher Filter and Seal (asset)', + description: 'Filter, spray arms, door seal, drain hose.', + category: 'Appliances', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 20, + priority: 'Low', + items: [check('filter', 'Filter cleaned'), check('seal', 'Door seal OK')], + }), + ], + corrective: { callsPerYear: 1, trade: 'appliance', minutes: 60 }, + }, + { + assetClass: 'irrigation', + label: 'Irrigation', + keywords: ['irrigation', 'sprinkler', 'rain bird', 'rainbird', 'hunter controller'], + preventive: [ + pm({ + key: 'asset_irrigation_check', + name: 'Irrigation Zone Check (asset)', + description: 'Run every zone, heads, controller schedule, rain sensor.', + category: 'Exterior', + trade: 'landscaping', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 30, + priority: 'Low', + items: [check('zones', 'All zones run'), check('schedule', 'Schedule matches season')], + }), + ], + corrective: { callsPerYear: 1, trade: 'landscaping', minutes: 60 }, + }, + { + assetClass: 'septic', + label: 'Septic', + keywords: ['septic', 'drain field', 'drainfield'], + preventive: [ + pm({ + key: 'asset_septic_pump', + name: 'Septic Pump Out (asset)', + description: 'Tank pumped and inspected; heavy guest load shortens the interval.', + category: 'Plumbing', + trade: 'plumbing', + cadenceValue: 24, + cadenceUnit: 'months', + minutes: 90, + priority: 'Medium', + items: [check('pumped', 'Pumped'), check('baffles', 'Baffles inspected')], + }), + ], + corrective: { callsPerYear: 1, trade: 'plumbing', minutes: 120 }, + }, +]; + +export const ASSET_SPEC_BY_CLASS: ReadonlyMap = new Map( + ASSET_MAINTENANCE_CATALOG.map((spec) => [spec.assetClass, spec] as const), +); + +/** + * Classify one asset by keyword over its name, brand, model and location. + * First catalog entry whose keyword appears wins; specific classes (pool + * heater) are listed before general ones they could collide with (pool pump + * matches "pool filter", never "pool heater"). + */ +export function classifyAsset(asset: PropertyAsset): MaintenanceAssetClass | null { + const haystack = [asset.name, asset.brand, asset.model, asset.location] + .filter((v): v is string => typeof v === 'string' && v.length > 0) + .join(' ') + .toLowerCase(); + if (!haystack) return null; + // Pool heater before pool pump: "Pentair pool heater" mentions a pump brand. + const ordered: readonly MaintenanceAssetClass[] = [ + 'pool_heater', + 'pool_pump', + 'water_heater', + 'hvac_unit', + 'garage_door', + 'smart_lock', + 'washer_dryer', + 'refrigerator', + 'dishwasher', + 'irrigation', + 'septic', + ]; + for (const cls of ordered) { + const spec = ASSET_SPEC_BY_CLASS.get(cls); + if (spec && spec.keywords.some((k) => haystack.includes(k))) return cls; + } + return null; +} diff --git a/src/maintenance/catalog.ts b/src/maintenance/catalog.ts new file mode 100644 index 0000000..3ee9a22 --- /dev/null +++ b/src/maintenance/catalog.ts @@ -0,0 +1,1037 @@ +/** + * The maintenance plan catalog (TURNWRK-630): what each amenity class costs + * to keep running in a rented home, as data. + * + * Three questions per amenity: + * 1. per turn — what the tech checks on every guest-experience walk + * 2. preventive — what recurs, how often, which trade, how long + * 3. corrective — how often it breaks and who answers + * + * Every number is an order-of-magnitude default for a Florida STR, written + * from the Tampa PM board (scripts/seed-pm-templates-tampa.ts in dispatch), + * the pool and handyman packs, and Breezy Keys' field practice. They are + * tunable constants, not measurements; an org overrides them in its own + * templates once it has history. Keep them integers (minutes) and keep the + * cadences expressible as a dispatch `PMSchedule`. + */ +import type { PropertyAmenity } from '../types/property'; +import type { ChecklistTemplateItem, ChecklistTemplateSection } from '../types/checklist'; +import type { AmenityMaintenanceSpec, PreventiveTaskSpec } from './types'; + +// ── small builders so the table below stays readable ──────────────────────── + +function check(id: string, label: string, extra: Partial = {}): ChecklistTemplateItem { + return { id, label, inputType: 'checkbox', ...extra }; +} + +function photo(id: string, label: string): ChecklistTemplateItem { + return { id, label, inputType: 'photo-required', photoRequired: true }; +} + +function reading( + id: string, + label: string, + suffix: string, + range?: { min: number; max: number }, +): ChecklistTemplateItem { + return { + id, + label, + inputType: 'number', + suffix, + ...(range ? { minValue: range.min, maxValue: range.max } : {}), + }; +} + +function section(id: string, title: string, items: ChecklistTemplateItem[]): ChecklistTemplateSection { + return { id, title, items }; +} + +/** + * Preventive task with the checklist as one section titled after the task. + * `key` doubles as the section id so a consumer can trace a WO item back here. + */ +function pm( + spec: Omit & { items: ChecklistTemplateItem[] }, +): PreventiveTaskSpec { + const { items, ...rest } = spec; + return { ...rest, sections: [section(rest.key, rest.name, items)] }; +} + +// ── the catalog ───────────────────────────────────────────────────────────── + +/** + * Ordered by how much a class usually costs to keep, water first. Order is + * the order schedules and inspection sections come out in, so a tech's walk + * starts at the pool and ends at the router. + */ +export const AMENITY_MAINTENANCE_CATALOG: readonly AmenityMaintenanceSpec[] = [ + // ── water ──────────────────────────────────────────────────────────────── + { + amenity: 'pool', + label: 'Pool', + perTurn: { + minutes: 10, + items: [ + check('pool.water_level', 'Water level at mid-skimmer'), + check('pool.baskets', 'Skimmer and pump baskets emptied'), + check('pool.equipment', 'Pump running, no leaks at equipment pad'), + check('pool.surface', 'Surface and floor free of debris and algae'), + reading('pool.chlorine', 'Free chlorine', 'ppm', { min: 1, max: 4 }), + reading('pool.ph', 'pH', '', { min: 7.2, max: 7.8 }), + check('pool.safety', 'Gate, alarms, and safety equipment in place'), + ], + }, + preventive: [ + pm({ + key: 'pool_service_verification', + name: 'Pool Service Verification', + description: 'Confirm the pool company came this week and the water is in range.', + category: 'Pool', + trade: 'pool', + cadenceValue: 7, + cadenceUnit: 'days', + minutes: 15, + priority: 'Medium', + verifiesVendorService: true, + items: [ + check('service_done', 'Pool service completed this week'), + check('chemistry', 'Chemistry log within range'), + check('equipment', 'Equipment pad dry, timer set'), + ], + }), + pm({ + key: 'pool_equipment_inspection', + name: 'Pool Equipment Inspection', + description: 'Pump, filter pressure, valves, timer, and lights; catch a failing pump before a guest does.', + category: 'Pool', + trade: 'handyman', + cadenceValue: 1, + cadenceUnit: 'months', + minutes: 20, + priority: 'Medium', + items: [ + reading('filter_psi', 'Filter pressure', 'psi'), + check('pump_noise', 'Pump quiet, no cavitation'), + check('lights', 'Pool lights working'), + photo('pad_photo', 'Photo of equipment pad'), + ], + }), + pm({ + key: 'pool_filter_clean', + name: 'Pool Filter Deep Clean', + description: 'Cartridge or DE filter elements cleaned.', + category: 'Pool', + trade: 'pool', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 60, + priority: 'Medium', + items: [check('cleaned', 'Filter elements cleaned'), reading('psi_after', 'Pressure after clean', 'psi')], + }), + ], + corrective: { callsPerYear: 6, trade: 'pool', minutes: 60 }, + }, + { + amenity: 'heated-pool', + label: 'Pool heater', + perTurn: { + minutes: 3, + items: [ + check('heated_pool.heater_mode', 'Heater set per this booking (on or off)'), + reading('heated_pool.temp', 'Water temperature', '°F'), + ], + }, + preventive: [ + pm({ + key: 'pool_heater_service', + name: 'Pool Heater Service', + description: 'Heat pump or gas heater: coils, condensate, ignition, error codes.', + category: 'Pool', + trade: 'pool', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 60, + priority: 'Medium', + items: [check('coils', 'Coils cleaned'), check('codes', 'No error codes'), check('flow', 'Flow switch OK')], + }), + ], + corrective: { callsPerYear: 3, trade: 'pool', minutes: 60 }, + }, + { + amenity: 'hot-tub', + label: 'Hot tub', + perTurn: { + minutes: 10, + items: [ + check('hot_tub.cover', 'Cover intact, latches working'), + check('hot_tub.level', 'Water level above jets'), + reading('hot_tub.temp', 'Temperature', '°F', { min: 98, max: 104 }), + reading('hot_tub.sanitizer', 'Sanitizer', 'ppm', { min: 2, max: 5 }), + check('hot_tub.jets', 'Jets and lights working'), + check('hot_tub.clarity', 'Water clear, no foam or odor'), + ], + }, + preventive: [ + pm({ + key: 'hot_tub_filter_rinse', + name: 'Hot Tub Filter Rinse', + description: 'Rinse cartridge, top up sanitizer, wipe waterline.', + category: 'Pool', + trade: 'handyman', + cadenceValue: 2, + cadenceUnit: 'weeks', + minutes: 20, + priority: 'Medium', + items: [check('rinsed', 'Cartridge rinsed'), check('waterline', 'Waterline wiped')], + }), + pm({ + key: 'hot_tub_drain_refill', + name: 'Hot Tub Drain and Refill', + description: 'Full drain, shell clean, refill and balance.', + category: 'Pool', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 120, + priority: 'Medium', + items: [check('drained', 'Drained and shell cleaned'), check('balanced', 'Refilled and balanced')], + }), + ], + corrective: { callsPerYear: 4, trade: 'pool', minutes: 60 }, + }, + { + amenity: 'screened-lanai', + label: 'Screened lanai', + perTurn: { + minutes: 2, + items: [check('lanai.screens', 'Screens intact, doors latch and self-close')], + }, + preventive: [ + pm({ + key: 'lanai_screen_inspection', + name: 'Lanai Screen and Door Inspection', + description: 'Panels, spline, door closers, and cage hardware.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 30, + priority: 'Low', + items: [check('panels', 'All panels intact'), check('closers', 'Door closers adjusted')], + }), + ], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 90 }, + }, + { + amenity: 'sauna', + label: 'Sauna', + perTurn: { minutes: 3, items: [check('sauna.heater', 'Heater reaches temperature, timer works')] }, + preventive: [ + pm({ + key: 'sauna_inspection', + name: 'Sauna Inspection', + description: 'Heater elements, stones, bench wood, door seal.', + category: 'Wellness', + trade: 'electrical', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 45, + priority: 'Low', + items: [check('elements', 'Elements OK'), check('wood', 'Bench wood sound')], + }), + ], + corrective: { callsPerYear: 1, trade: 'electrical', minutes: 90 }, + }, + { + amenity: 'cold-plunge', + label: 'Cold plunge', + perTurn: { minutes: 3, items: [check('plunge.chiller', 'Chiller running, water clear')] }, + preventive: [ + pm({ + key: 'cold_plunge_service', + name: 'Cold Plunge Filter and Sanitize', + description: 'Filter change, sanitize, chiller coil clean.', + category: 'Wellness', + trade: 'handyman', + cadenceValue: 1, + cadenceUnit: 'months', + minutes: 30, + priority: 'Low', + items: [check('filter', 'Filter changed'), check('sanitized', 'Sanitized')], + }), + ], + corrective: { callsPerYear: 2, trade: 'appliance', minutes: 60 }, + }, + { + amenity: 'boat-dock', + label: 'Boat dock', + perTurn: { minutes: 3, items: [check('dock.boards', 'Boards and cleats secure, lights working')] }, + preventive: [ + pm({ + key: 'dock_inspection', + name: 'Dock Inspection', + description: 'Decking, pilings, ladder, lift and lighting.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 45, + priority: 'Medium', + items: [check('decking', 'Decking sound'), check('ladder', 'Ladder secure'), photo('dock_photo', 'Photo of dock')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 120 }, + }, + { + amenity: 'watercraft', + label: 'Kayaks and paddleboards', + perTurn: { minutes: 3, items: [check('craft.count', 'Craft, paddles, and vests counted and undamaged')] }, + preventive: [], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 30 }, + }, + + // ── play ───────────────────────────────────────────────────────────────── + { + amenity: 'pickleball-court', + label: 'Pickleball court', + perTurn: { + minutes: 5, + items: [ + check('pickleball.surface', 'Surface swept, no standing water'), + check('pickleball.net', 'Net at height, straps tight'), + check('pickleball.gear', 'Paddles and balls counted'), + ], + }, + preventive: [ + pm({ + key: 'court_surface_wash', + name: 'Court Surface Wash', + description: 'Soft wash, blow off, check drainage and lines.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 1, + cadenceUnit: 'months', + minutes: 45, + priority: 'Low', + items: [check('washed', 'Surface washed'), check('lines', 'Lines legible')], + }), + pm({ + key: 'court_surface_inspection', + name: 'Court Surface Inspection', + description: 'Cracks, low spots, coating wear; refer to a court contractor early.', + category: 'Exterior', + trade: 'court_surface', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 30, + priority: 'Low', + items: [check('cracks', 'No open cracks'), photo('court_photo', 'Photo of surface')], + }), + ], + corrective: { callsPerYear: 1, trade: 'court_surface', minutes: 120 }, + }, + { + amenity: 'sport-court', + label: 'Sport court', + perTurn: { minutes: 4, items: [check('court.surface', 'Surface clear, equipment present')] }, + preventive: [ + pm({ + key: 'sport_court_wash', + name: 'Sport Court Wash', + description: 'Soft wash and drainage check.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 1, + cadenceUnit: 'months', + minutes: 45, + priority: 'Low', + items: [check('washed', 'Surface washed')], + }), + ], + corrective: { callsPerYear: 1, trade: 'court_surface', minutes: 120 }, + }, + { + amenity: 'basketball-hoop', + label: 'Basketball hoop', + perTurn: { minutes: 2, items: [check('hoop.rim', 'Rim level, net intact, base stable')] }, + preventive: [ + pm({ + key: 'hoop_hardware_check', + name: 'Hoop Hardware Check', + description: 'Backboard bolts, pole anchors, net.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 15, + priority: 'Low', + items: [check('bolts', 'Bolts tight'), check('net', 'Net replaced if frayed')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 60 }, + }, + { + amenity: 'putting-green', + label: 'Putting green', + perTurn: { minutes: 3, items: [check('green.turf', 'Turf clear of debris, cups and flags in place, putters counted')] }, + preventive: [ + pm({ + key: 'turf_brush_and_infill', + name: 'Turf Brush and Infill', + description: 'Power brush, top up infill, check seams and edging.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 60, + priority: 'Low', + items: [check('brushed', 'Brushed'), check('seams', 'Seams and edges secure')], + }), + pm({ + key: 'turf_deep_clean', + name: 'Turf Deep Clean', + description: 'Sanitize and deep clean synthetic turf.', + category: 'Exterior', + trade: 'landscaping', + cadenceValue: 12, + cadenceUnit: 'months', + minutes: 120, + priority: 'Low', + items: [check('cleaned', 'Deep cleaned')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 60 }, + }, + { + amenity: 'game-room', + label: 'Game room tables', + perTurn: { + minutes: 5, + items: [ + check('games.tables', 'Ping pong, foosball, air hockey working'), + check('games.pieces', 'Balls, paddles, pucks, darts counted'), + ], + }, + preventive: [ + pm({ + key: 'game_table_service', + name: 'Game Table Service', + description: 'Air hockey blower and filter, foosball rods lubricated, net and legs tightened.', + category: 'Interior', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 30, + priority: 'Low', + items: [check('blower', 'Air hockey blower clean'), check('rods', 'Foosball rods lubricated')], + }), + ], + corrective: { callsPerYear: 3, trade: 'handyman', minutes: 45 }, + }, + { + amenity: 'arcade', + label: 'Arcade cabinets', + perTurn: { + minutes: 5, + items: [ + check('arcade.power', 'Every cabinet powers on and reaches attract mode'), + check('arcade.controls', 'Controls responsive on each cabinet'), + ], + }, + preventive: [ + pm({ + key: 'arcade_cabinet_service', + name: 'Arcade Cabinet Service', + description: 'Clean controls, check monitors and power supplies, vacuum cabinets.', + category: 'Interior', + trade: 'arcade', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 60, + priority: 'Low', + items: [check('controls', 'Controls cleaned and tested'), check('monitors', 'Monitors OK')], + }), + ], + corrective: { callsPerYear: 3, trade: 'arcade', minutes: 90 }, + }, + { + amenity: 'yard-games', + label: 'Yard games', + perTurn: { minutes: 2, items: [check('yard_games.count', 'Cornhole, Jenga, Connect Four complete and dry')] }, + preventive: [], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 30 }, + }, + { + amenity: 'playground', + label: 'Playground', + perTurn: { minutes: 3, items: [check('playground.hardware', 'No loose hardware, swings and chains intact')] }, + preventive: [ + pm({ + key: 'playground_inspection', + name: 'Playground Inspection', + description: 'Anchors, hardware, wood condition, fall surface.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 30, + priority: 'Medium', + items: [check('anchors', 'Anchors secure'), check('wood', 'No splinters or rot')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 60 }, + }, + { + amenity: 'trampoline', + label: 'Trampoline', + perTurn: { minutes: 2, items: [check('trampoline.net', 'Net, springs, and pad intact')] }, + preventive: [ + pm({ + key: 'trampoline_inspection', + name: 'Trampoline Inspection', + description: 'Springs, mat, net, and frame.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 20, + priority: 'Medium', + items: [check('springs', 'All springs present'), check('mat', 'Mat and net sound')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 60 }, + }, + { + amenity: 'bikes', + label: 'Bikes', + perTurn: { minutes: 3, items: [check('bikes.count', 'Bikes counted, tires inflated, helmets present')] }, + preventive: [ + pm({ + key: 'bike_tune', + name: 'Bike Tune', + description: 'Brakes, chain, tires, seat posts.', + category: 'Interior', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 30, + priority: 'Low', + items: [check('brakes', 'Brakes OK'), check('chain', 'Chain lubricated')], + }), + ], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 30 }, + }, + { + amenity: 'golf-cart', + label: 'Golf cart', + perTurn: { minutes: 4, items: [check('cart.charge', 'Charged, lights working, no damage')] }, + preventive: [ + pm({ + key: 'golf_cart_service', + name: 'Golf Cart Service', + description: 'Batteries, tires, brakes, charger.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 45, + priority: 'Medium', + items: [check('batteries', 'Battery water and terminals'), check('brakes', 'Brakes OK')], + }), + ], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 90 }, + }, + + // ── outdoor living ─────────────────────────────────────────────────────── + { + amenity: 'outdoor-bar', + label: 'Outdoor bar', + perTurn: { + minutes: 5, + items: [ + check('bar.fridge', 'Bar fridge cold, ice maker running'), + check('bar.lights', 'Lighting and neon working'), + check('bar.seating', 'Swings and stools secure'), + ], + }, + preventive: [ + pm({ + key: 'outdoor_bar_fixture_check', + name: 'Outdoor Bar Fixture Check', + description: 'Swing hardware, fridge coils, GFCI outlets, lighting.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 30, + priority: 'Low', + items: [check('swings', 'Swing hardware tight'), check('gfci', 'GFCI outlets test OK')], + }), + ], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 45 }, + }, + { + amenity: 'fire-pit', + label: 'Fire pit', + perTurn: { + minutes: 5, + items: [ + check('fire_pit.fuel', 'Propane level or ash cleared'), + check('fire_pit.igniter', 'Igniter lights, no gas smell'), + check('fire_pit.area', 'Seating clear, screen in place'), + ], + }, + preventive: [ + pm({ + key: 'fire_pit_gas_inspection', + name: 'Fire Pit Gas Inspection', + description: 'Hose, regulator, burner, leak test.', + category: 'Exterior', + trade: 'gas', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 30, + priority: 'High', + items: [check('leak_test', 'Soap leak test passed'), check('burner', 'Burner ports clear')], + }), + ], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 45 }, + }, + { + amenity: 'outdoor-grill', + label: 'Grill', + perTurn: { + minutes: 5, + items: [ + check('grill.grates', 'Grates scraped, grease tray emptied'), + check('grill.propane', 'Propane above a quarter, spare present'), + check('grill.igniter', 'Igniter works'), + ], + }, + preventive: [ + pm({ + key: 'grill_deep_clean', + name: 'Grill Deep Clean', + description: 'Burners, flavorizer bars, grease system, exterior.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 2, + cadenceUnit: 'months', + minutes: 45, + priority: 'Low', + items: [check('burners', 'Burners clear'), check('grease', 'Grease system cleaned')], + }), + ], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 45 }, + }, + { + amenity: 'outdoor-furniture', + label: 'Outdoor furniture', + perTurn: { minutes: 3, items: [check('furniture.condition', 'Furniture clean, cushions dry, nothing broken')] }, + preventive: [ + pm({ + key: 'outdoor_furniture_service', + name: 'Outdoor Furniture Wash and Tighten', + description: 'Wash frames, tighten hardware, treat wood, rotate cushions.', + category: 'Exterior', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 45, + priority: 'Low', + items: [check('washed', 'Washed'), check('hardware', 'Hardware tightened')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 30 }, + }, + { + amenity: 'beach-gear', + label: 'Beach gear', + perTurn: { minutes: 3, items: [check('beach.count', 'Chairs, umbrella, tent, toys counted and rinsed')] }, + preventive: [], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 15 }, + }, + { + amenity: 'yard', + label: 'Yard', + perTurn: { minutes: 2, items: [check('yard.condition', 'Lawn cut, beds tidy, sprinklers not running on guests')] }, + preventive: [ + pm({ + key: 'lawn_service_verification', + name: 'Lawn Service Verification', + description: 'Confirm the lawn crew is coming and the yard is guest-ready.', + category: 'Exterior', + trade: 'landscaping', + cadenceValue: 14, + cadenceUnit: 'days', + minutes: 15, + priority: 'High', + verifiesVendorService: true, + items: [check('cut', 'Lawn cut within SLA'), check('vendor', 'Vendor schedule confirmed')], + }), + ], + corrective: { callsPerYear: 1, trade: 'landscaping', minutes: 60 }, + }, + + // ── systems ────────────────────────────────────────────────────────────── + { + amenity: 'hvac', + label: 'Heating and cooling', + perTurn: { minutes: 1, items: [check('hvac.thermostat', 'Thermostat at guest setpoint, air blowing cold')] }, + preventive: [ + pm({ + key: 'hvac_filter_replacement', + name: 'HVAC Air Filter Replacement', + description: 'Replace filters; photo of the installed filter.', + category: 'HVAC', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 30, + priority: 'High', + items: [check('size', 'Filter size verified'), check('replaced', 'Filter replaced'), photo('filter_photo', 'Photo of installed filter')], + }), + pm({ + key: 'hvac_tune_up', + name: 'HVAC Tune Up', + description: 'Coil clean, refrigerant check, condensate line flush.', + category: 'HVAC', + trade: 'hvac', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 60, + priority: 'High', + items: [check('coils', 'Coils cleaned'), check('drain', 'Condensate line flushed')], + }), + ], + corrective: { callsPerYear: 2, trade: 'hvac', minutes: 90 }, + }, + { + amenity: 'ceiling-fan', + label: 'Ceiling fans', + perTurn: { minutes: 1, items: [check('fans.run', 'Fans run without wobble')] }, + preventive: [ + pm({ + key: 'ceiling_fan_service', + name: 'Ceiling Fan Tighten and Dust', + description: 'Blades dusted, mounts tightened, remotes paired.', + category: 'Interior', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 20, + priority: 'Low', + items: [check('tightened', 'Mounts tightened')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 45 }, + }, + { + amenity: 'water-heater', + label: 'Water heater', + perTurn: { minutes: 1, items: [check('water_heater.hot', 'Hot water at the farthest tap')] }, + preventive: [ + pm({ + key: 'water_heater_flush', + name: 'Water Heater Flush', + description: 'Flush sediment, test relief valve, check anode.', + category: 'Plumbing', + trade: 'plumbing', + cadenceValue: 12, + cadenceUnit: 'months', + minutes: 60, + priority: 'Medium', + items: [check('flushed', 'Flushed'), check('tpr', 'Relief valve tested')], + }), + ], + corrective: { callsPerYear: 1, trade: 'plumbing', minutes: 90 }, + }, + { + amenity: 'ev-charger', + label: 'EV charger', + perTurn: { minutes: 1, items: [check('ev.status', 'Charger status light normal')] }, + preventive: [ + pm({ + key: 'ev_charger_inspection', + name: 'EV Charger Inspection', + description: 'Cable, connector, breaker, mounting.', + category: 'Electrical', + trade: 'electrical', + cadenceValue: 12, + cadenceUnit: 'months', + minutes: 30, + priority: 'Low', + items: [check('cable', 'Cable and connector undamaged')], + }), + ], + corrective: { callsPerYear: 1, trade: 'electrical', minutes: 60 }, + }, + { + amenity: 'generator', + label: 'Generator', + preventive: [ + pm({ + key: 'generator_exercise', + name: 'Generator Exercise and Service', + description: 'Run test, oil, battery, transfer switch.', + category: 'Electrical', + trade: 'electrical', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 60, + priority: 'Medium', + items: [check('run', 'Ran under load'), check('oil', 'Oil and battery OK')], + }), + ], + corrective: { callsPerYear: 1, trade: 'electrical', minutes: 120 }, + }, + { + amenity: 'elevator', + label: 'Elevator', + perTurn: { minutes: 1, items: [check('elevator.run', 'Elevator runs, door sensors work')] }, + preventive: [ + pm({ + key: 'elevator_service_verification', + name: 'Elevator Service Verification', + description: 'Confirm the elevator contractor visit and certificate.', + category: 'Interior', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 15, + priority: 'High', + verifiesVendorService: true, + items: [check('cert', 'Inspection certificate current')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 60 }, + }, + { + amenity: 'access-hardware', + label: 'Smart lock and access', + perTurn: { minutes: 1, items: [check('access.lock', 'Lock battery OK, keypad responsive, lockbox present')] }, + preventive: [ + pm({ + key: 'lock_battery_and_rekey', + name: 'Lock Battery and Rekey Check', + description: 'Smart lock batteries, guest code rotation, physical rekey schedule.', + category: 'Access', + trade: 'handyman', + cadenceValue: 60, + cadenceUnit: 'days', + minutes: 30, + priority: 'Medium', + items: [check('battery', 'Lock battery level OK'), check('codes', 'Guest codes rotated'), check('rekey', 'Physical rekey not overdue')], + }), + ], + corrective: { callsPerYear: 1, trade: 'locksmith', minutes: 60 }, + }, + { + amenity: 'security-camera', + label: 'Exterior cameras', + perTurn: { minutes: 1, items: [check('camera.online', 'Doorbell and cameras online')] }, + preventive: [ + pm({ + key: 'camera_service', + name: 'Camera Lens and Battery', + description: 'Clean lenses, check batteries and mounting.', + category: 'Access', + trade: 'handyman', + cadenceValue: 3, + cadenceUnit: 'months', + minutes: 10, + priority: 'Low', + items: [check('lens', 'Lenses cleaned'), check('battery', 'Batteries OK')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 30 }, + }, + { + amenity: 'noise-monitor', + label: 'Noise monitor', + preventive: [ + pm({ + key: 'noise_monitor_check', + name: 'Noise Monitor Check', + description: 'Online, battery, placement.', + category: 'Access', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 10, + priority: 'Low', + items: [check('online', 'Device online')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 30 }, + }, + { + amenity: 'safety-equipment', + label: 'Safety equipment', + perTurn: { minutes: 1, items: [check('safety.present', 'Extinguisher and first aid kit present')] }, + preventive: [ + pm({ + key: 'safety_equipment_check', + name: 'Safety Equipment Check', + description: 'Smoke and CO alarms tested, extinguisher gauge, first aid restock, pool alarms.', + category: 'Safety', + trade: 'handyman', + cadenceValue: 1, + cadenceUnit: 'months', + minutes: 15, + priority: 'High', + items: [ + check('smoke', 'Smoke alarms tested'), + check('co', 'CO alarm tested'), + check('extinguisher', 'Extinguisher in the green'), + check('first_aid', 'First aid kit restocked'), + ], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 30 }, + }, + + // ── interior ───────────────────────────────────────────────────────────── + { + amenity: 'kitchen', + label: 'Kitchen', + perTurn: { + minutes: 3, + items: [ + check('kitchen.appliances', 'Fridge cold, dishwasher, disposal, oven, microwave run'), + check('kitchen.leaks', 'No leaks under sink or behind fridge'), + ], + }, + preventive: [ + pm({ + key: 'kitchen_appliance_service', + name: 'Kitchen Appliance Service', + description: 'Fridge coils and ice maker, range hood filter, disposal, dishwasher filter.', + category: 'Appliances', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 45, + priority: 'Medium', + items: [check('coils', 'Fridge coils cleaned'), check('hood', 'Hood filter cleaned'), check('dw_filter', 'Dishwasher filter cleaned')], + }), + ], + corrective: { callsPerYear: 3, trade: 'appliance', minutes: 60 }, + }, + { + amenity: 'laundry', + label: 'Laundry', + perTurn: { minutes: 2, items: [check('laundry.lint', 'Lint trap clear, no hose leaks')] }, + preventive: [ + pm({ + key: 'dryer_vent_clean', + name: 'Dryer Vent Clean', + description: 'Full vent run cleaned; fire prevention.', + category: 'Appliances', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 60, + priority: 'High', + items: [check('vent', 'Vent run cleaned'), check('hoses', 'Washer hoses inspected')], + }), + ], + corrective: { callsPerYear: 2, trade: 'appliance', minutes: 60 }, + }, + { + amenity: 'fireplace', + label: 'Fireplace', + perTurn: { minutes: 2, items: [check('fireplace.safe', 'Glass intact, igniter works, screen in place')] }, + preventive: [ + pm({ + key: 'fireplace_inspection', + name: 'Fireplace Inspection', + description: 'Gas log or chimney inspection and clean.', + category: 'Interior', + trade: 'gas', + cadenceValue: 12, + cadenceUnit: 'months', + minutes: 60, + priority: 'Medium', + items: [check('inspected', 'Inspected and cleaned')], + }), + ], + corrective: { callsPerYear: 1, trade: 'gas', minutes: 60 }, + }, + { + amenity: 'gym', + label: 'Gym', + perTurn: { minutes: 3, items: [check('gym.equipment', 'Equipment works, cables intact, wiped down')] }, + preventive: [ + pm({ + key: 'gym_equipment_service', + name: 'Gym Equipment Service', + description: 'Treadmill belt, cables, bolts, lubrication.', + category: 'Interior', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 45, + priority: 'Low', + items: [check('belt', 'Treadmill belt aligned'), check('bolts', 'Bolts tight')], + }), + ], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 60 }, + }, + { + amenity: 'bathtub', + label: 'Bathtub', + perTurn: { minutes: 1, items: [check('tub.drain', 'Drains freely, caulk intact')] }, + preventive: [ + pm({ + key: 'tub_caulk_inspection', + name: 'Tub and Shower Caulk Inspection', + description: 'Caulk, grout, drain, stopper.', + category: 'Plumbing', + trade: 'handyman', + cadenceValue: 6, + cadenceUnit: 'months', + minutes: 20, + priority: 'Low', + items: [check('caulk', 'Caulk sound'), check('drain', 'Drain clear')], + }), + ], + corrective: { callsPerYear: 1, trade: 'plumbing', minutes: 60 }, + }, + { + amenity: 'tv', + label: 'TVs', + perTurn: { minutes: 2, items: [check('tv.remotes', 'Every TV powers on, remotes present, guest accounts signed out')] }, + preventive: [], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 30 }, + }, + { + amenity: 'wifi', + label: 'Wifi', + perTurn: { minutes: 1, items: [check('wifi.speed', 'Wifi up, speed test acceptable')] }, + preventive: [], + corrective: { callsPerYear: 2, trade: 'handyman', minutes: 30 }, + }, + { + amenity: 'family-kit', + label: 'Family kit', + perTurn: { minutes: 2, items: [check('family.kit', 'Pack and play, high chair, toys complete and clean')] }, + preventive: [], + corrective: { callsPerYear: 1, trade: 'handyman', minutes: 15 }, + }, + { + amenity: 'dedicated-workspace', + label: 'Workspace', + preventive: [], + }, +]; + +/** Catalog lookup by amenity class. */ +export const AMENITY_SPEC_BY_CLASS: ReadonlyMap = new Map( + AMENITY_MAINTENANCE_CATALOG.map((spec) => [spec.amenity, spec] as const), +); + +export function amenitySpec(amenity: PropertyAmenity): AmenityMaintenanceSpec | undefined { + return AMENITY_SPEC_BY_CLASS.get(amenity); +} diff --git a/src/maintenance/index.ts b/src/maintenance/index.ts new file mode 100644 index 0000000..04e6a6b --- /dev/null +++ b/src/maintenance/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './catalog'; +export * from './assets'; +export * from './aliases'; +export * from './plan'; diff --git a/src/maintenance/plan.ts b/src/maintenance/plan.ts new file mode 100644 index 0000000..7cf9e01 --- /dev/null +++ b/src/maintenance/plan.ts @@ -0,0 +1,203 @@ +/** + * `planForProperty` (TURNWRK-630): fold a property's amenities and assets + * through the catalog into a maintenance plan. + * + * Pure and deterministic: same input, same plan, no I/O, no dates. Consumers + * (dispatch's plan route, TURNWRK-631) turn `schedules` into cmms_pmTemplates + * and cmms_pmSchedules, hand `perTurnInspection.sections` to the + * GuestExperience checklist composer, and price `load` at quote time + * (TURNWRK-632). All minutes are integers; monthly figures are rounded once, + * at the end, so the by-trade rows still sum to the totals. + */ +import type { PropertyAmenity, PropertyAsset } from '../types/property'; +import type { ChecklistTemplateSection } from '../types/checklist'; +import { AMENITY_MAINTENANCE_CATALOG, AMENITY_SPEC_BY_CLASS } from './catalog'; +import { ASSET_SPEC_BY_CLASS, classifyAsset } from './assets'; +import { normalizeAmenities } from './aliases'; +import type { + CorrectiveLoadSpec, + MaintenanceCadenceUnit, + MaintenanceLoad, + MaintenancePlan, + MaintenanceTrade, + PlanInput, + PlannedSchedule, + PreventiveTaskSpec, + TradeLoad, +} from './types'; +import { MAINTENANCE_TRADES } from './types'; + +/** Average Gregorian month in days; the same constant a PM schedule's cadence is amortised over. */ +export const DAYS_PER_MONTH = 30.4375; + +/** Trades an org staffs when it says nothing: the handyman is the default in-house seat. */ +export const DEFAULT_IN_HOUSE_TRADES: readonly MaintenanceTrade[] = ['handyman']; + +/** Occurrences of a cadence in an average month, unrounded. */ +export function occurrencesPerMonth(cadenceValue: number, cadenceUnit: MaintenanceCadenceUnit): number { + if (!(cadenceValue > 0)) return 0; + switch (cadenceUnit) { + case 'days': + return DAYS_PER_MONTH / cadenceValue; + case 'weeks': + return DAYS_PER_MONTH / (cadenceValue * 7); + case 'months': + return 1 / cadenceValue; + } +} + +interface Accumulator { + perTurn: number; + preventive: number; + corrective: number; +} + +function emptyAccumulator(): Accumulator { + return { perTurn: 0, preventive: 0, corrective: 0 }; +} + +export function planForProperty(input: PlanInput): MaintenancePlan { + const turnsPerMonth = Math.max(0, input.turnsPerMonth); + const inHouse = new Set(input.inHouseTrades ?? DEFAULT_IN_HOUSE_TRADES); + const isInHouse = (trade: MaintenanceTrade) => inHouse.has(trade); + + const normalized = normalizeAmenities(input.amenities); + const wanted = new Set(normalized.matched); + // Catalog order, not input order: the walk starts at the pool. + const matchedAmenities = AMENITY_MAINTENANCE_CATALOG.filter((s) => wanted.has(s.amenity)).map((s) => s.amenity); + // A class the alias table knows but the catalog does not is a catalog bug; + // report it as unmapped rather than letting it vanish. + const knownButUnplanned = normalized.matched.filter((a) => !AMENITY_SPEC_BY_CLASS.has(a)); + + const byTrade = new Map(); + const bucket = (trade: MaintenanceTrade) => { + let acc = byTrade.get(trade); + if (!acc) { + acc = emptyAccumulator(); + byTrade.set(trade, acc); + } + return acc; + }; + + const schedules: PlannedSchedule[] = []; + const inspectionSections: ChecklistTemplateSection[] = []; + let perTurnMinutesPerTurn = 0; + + const addPreventive = (task: PreventiveTaskSpec, source: PlannedSchedule['source']) => { + const occ = occurrencesPerMonth(task.cadenceValue, task.cadenceUnit); + const minutesPerMonth = occ * task.minutes; + schedules.push({ + ...task, + source, + inHouse: isInHouse(task.trade), + occurrencesPerMonth: occ, + minutesPerMonth, + }); + bucket(task.trade).preventive += minutesPerMonth; + }; + + const addCorrective = (c: CorrectiveLoadSpec | undefined) => { + if (!c) return; + bucket(c.trade).corrective += (c.callsPerYear / 12) * c.minutes; + }; + + for (const amenity of matchedAmenities) { + const spec = AMENITY_SPEC_BY_CLASS.get(amenity); + if (!spec) continue; + if (spec.perTurn && spec.perTurn.items.length > 0) { + perTurnMinutesPerTurn += spec.perTurn.minutes; + inspectionSections.push({ id: `amenity.${amenity}`, title: spec.label, items: [...spec.perTurn.items] }); + // Every per-turn walk is the field seat's work, whichever trade owns the amenity. + bucket('handyman').perTurn += spec.perTurn.minutes * turnsPerMonth; + } + for (const task of spec.preventive) addPreventive(task, { kind: 'amenity', amenity }); + addCorrective(spec.corrective); + } + + const matchedAssets: { assetId: string; assetClass: NonNullable> }[] = []; + const unmappedAssets: string[] = []; + const seenAssetClasses = new Set(); + for (const asset of input.assets ?? []) { + const cls = classifyAsset(asset); + if (!cls) { + unmappedAssets.push(assetLabel(asset)); + continue; + } + matchedAssets.push({ assetId: asset.id, assetClass: cls }); + // One preventive series per asset class, even when the register lists two + // units: the template is per property, the schedule covers both. + if (seenAssetClasses.has(cls)) continue; + seenAssetClasses.add(cls); + const spec = ASSET_SPEC_BY_CLASS.get(cls); + if (!spec) continue; + for (const task of spec.preventive) addPreventive(task, { kind: 'asset', assetClass: cls, assetId: asset.id }); + addCorrective(spec.corrective); + } + + const load = summarise(byTrade, isInHouse, perTurnMinutesPerTurn, turnsPerMonth); + + return { + schedules: schedules.map((s) => ({ ...s, minutesPerMonth: Math.round(s.minutesPerMonth) })), + perTurnInspection: { minutes: perTurnMinutesPerTurn, sections: inspectionSections }, + load, + matchedAmenities, + ignoredAmenities: normalized.ignored, + unmappedAmenities: [...normalized.unmapped, ...knownButUnplanned], + matchedAssets, + unmappedAssets, + }; +} + +function summarise( + byTrade: Map, + isInHouse: (t: MaintenanceTrade) => boolean, + perTurnMinutesPerTurn: number, + turnsPerMonth: number, +): MaintenanceLoad { + const rows: TradeLoad[] = []; + let perTurn = 0; + let preventive = 0; + let corrective = 0; + let inHouse = 0; + let specialty = 0; + // Stable order for consumers rendering a table. + for (const trade of MAINTENANCE_TRADES) { + const acc = byTrade.get(trade); + if (!acc) continue; + const row: TradeLoad = { + trade, + inHouse: isInHouse(trade), + perTurnMinutesPerMonth: Math.round(acc.perTurn), + preventiveMinutesPerMonth: Math.round(acc.preventive), + correctiveMinutesPerMonth: Math.round(acc.corrective), + totalMinutesPerMonth: 0, + }; + row.totalMinutesPerMonth = row.perTurnMinutesPerMonth + row.preventiveMinutesPerMonth + row.correctiveMinutesPerMonth; + if (row.totalMinutesPerMonth === 0) continue; + rows.push(row); + perTurn += row.perTurnMinutesPerMonth; + preventive += row.preventiveMinutesPerMonth; + corrective += row.correctiveMinutesPerMonth; + if (row.inHouse) inHouse += row.totalMinutesPerMonth; + else specialty += row.totalMinutesPerMonth; + } + return { + perTurnMinutesPerTurn, + perTurnMinutesPerMonth: perTurn, + preventiveMinutesPerMonth: preventive, + correctiveMinutesPerMonth: corrective, + totalMinutesPerMonth: perTurn + preventive + corrective, + inHouseMinutesPerMonth: inHouse, + specialtyMinutesPerMonth: specialty, + byTrade: rows, + }; +} + +function assetLabel(asset: PropertyAsset): string { + return [asset.name, asset.brand, asset.model].filter((v): v is string => !!v).join(' ') || asset.id; +} + +/** Whole hours, rounded up, for a minutes figure; what a retainer is quoted in. */ +export function hoursCeil(minutes: number): number { + return Math.ceil(minutes / 60); +} diff --git a/src/maintenance/types.ts b/src/maintenance/types.ts new file mode 100644 index 0000000..08210b8 --- /dev/null +++ b/src/maintenance/types.ts @@ -0,0 +1,185 @@ +/** + * Maintenance plan catalog types (TURNWRK-630). + * + * The catalog is DATA, the same way a vertical pack is: for each amenity or + * asset class it says what a tech checks on every turn, which preventive tasks + * recur and how often, which trade answers a corrective call, and how long + * each of those takes. `planForProperty` (plan.ts) folds a property's amenity + * list and asset register through it into schedules to create, a composed + * per-turn inspection, and a monthly labour load split by trade. + * + * Nothing here is a price. Minutes and cadences are order-of-magnitude + * defaults an org overrides; money comes from the org's rates and the vertical + * pack service seeds at quote time (TURNWRK-632). + */ +import type { PropertyAmenity, PropertyAsset } from '../types/property'; +import type { WOPriority } from '../types/workOrder'; +import type { ChecklistTemplateItem, ChecklistTemplateSection } from '../types/checklist'; + +/** + * Who answers the work. `handyman`, `pool` and `landscaping` share their + * spelling with `VerticalKey` on purpose so an org that has authored that pack + * can route straight to it; the rest are specialty trades the suite does not + * model as packs (yet). Which trades are IN-HOUSE is an org fact passed into + * the planner, never a catalog fact: Breezy Keys' handyman is on staff, a + * pool-only operator's is not. + */ +export type MaintenanceTrade = + | 'handyman' + | 'pool' + | 'landscaping' + | 'hvac' + | 'electrical' + | 'plumbing' + | 'appliance' + | 'gas' + | 'arcade' + | 'court_surface' + | 'locksmith' + | 'pest'; + +export const MAINTENANCE_TRADES: readonly MaintenanceTrade[] = [ + 'handyman', + 'pool', + 'landscaping', + 'hvac', + 'electrical', + 'plumbing', + 'appliance', + 'gas', + 'arcade', + 'court_surface', + 'locksmith', + 'pest', +] as const; + +/** Structurally identical to dispatch's `PMCadenceUnit` (types.ts). */ +export type MaintenanceCadenceUnit = 'days' | 'weeks' | 'months'; + +/** One recurring preventive task. Becomes a PM template plus a schedule. */ +export interface PreventiveTaskSpec { + /** Stable within the catalog; consumers derive template ids from it. */ + key: string; + name: string; + description: string; + /** `PMTemplate.category` ("Pool", "HVAC", "Exterior"...). */ + category: string; + trade: MaintenanceTrade; + cadenceValue: number; + cadenceUnit: MaintenanceCadenceUnit; + /** Tech time on site per occurrence. */ + minutes: number; + priority: WOPriority; + sections: readonly ChecklistTemplateSection[]; + /** + * True when the visit verifies a vendor's recurring service (the weekly + * pool company came, the lawn was cut) rather than performing it. The + * vendor's own visits are not on this plan; they are the owner's contract. + */ + verifiesVendorService?: boolean; +} + +/** What a tech checks about one amenity on every guest-experience walk. */ +export interface PerTurnInspectionSpec { + minutes: number; + items: readonly ChecklistTemplateItem[]; +} + +/** Unplanned calls: how often something about this amenity breaks. */ +export interface CorrectiveLoadSpec { + /** Order-of-magnitude expected calls per year in a rented home. */ + callsPerYear: number; + trade: MaintenanceTrade; + /** Typical minutes on site per call, travel excluded. */ + minutes: number; +} + +export interface AmenityMaintenanceSpec { + amenity: PropertyAmenity; + label: string; + /** Absent when the amenity carries no per-turn check (a workspace). */ + perTurn?: PerTurnInspectionSpec; + preventive: readonly PreventiveTaskSpec[]; + /** Absent when nothing about it breaks in a way we would be called for. */ + corrective?: CorrectiveLoadSpec; +} + +/** + * Equipment classes recognised from a free-text `PropertyAsset` (name, brand, + * model). An asset register is richer than an amenity list: "Rheem 50 gal" + * under a water heater tells us to plan a flush even when no amenity says so. + */ +export type MaintenanceAssetClass = + | 'hvac_unit' + | 'water_heater' + | 'pool_pump' + | 'pool_heater' + | 'garage_door' + | 'smart_lock' + | 'washer_dryer' + | 'refrigerator' + | 'dishwasher' + | 'irrigation' + | 'septic'; + +export interface AssetMaintenanceSpec { + assetClass: MaintenanceAssetClass; + label: string; + /** Lower-cased substrings that identify the class in an asset's name/model. */ + keywords: readonly string[]; + preventive: readonly PreventiveTaskSpec[]; + corrective?: CorrectiveLoadSpec; +} + +export interface PlanInput { + /** Amenity classes, or free-text labels, or a mix. Labels are normalized. */ + amenities: readonly string[]; + assets?: readonly PropertyAsset[]; + /** Guest turns per month; drives the per-turn inspection load. */ + turnsPerMonth: number; + /** Trades the org staffs itself. Defaults to `['handyman']`. */ + inHouseTrades?: readonly MaintenanceTrade[]; +} + +export interface PlannedSchedule extends PreventiveTaskSpec { + /** Amenity or asset class that put this schedule on the plan. */ + source: { kind: 'amenity'; amenity: PropertyAmenity } | { kind: 'asset'; assetClass: MaintenanceAssetClass; assetId: string }; + inHouse: boolean; + /** Expected occurrences in an average month (30.44 days). */ + occurrencesPerMonth: number; + minutesPerMonth: number; +} + +export interface TradeLoad { + trade: MaintenanceTrade; + inHouse: boolean; + perTurnMinutesPerMonth: number; + preventiveMinutesPerMonth: number; + correctiveMinutesPerMonth: number; + totalMinutesPerMonth: number; +} + +export interface MaintenanceLoad { + perTurnMinutesPerTurn: number; + perTurnMinutesPerMonth: number; + preventiveMinutesPerMonth: number; + correctiveMinutesPerMonth: number; + totalMinutesPerMonth: number; + inHouseMinutesPerMonth: number; + specialtyMinutesPerMonth: number; + byTrade: readonly TradeLoad[]; +} + +export interface MaintenancePlan { + schedules: readonly PlannedSchedule[]; + perTurnInspection: { minutes: number; sections: readonly ChecklistTemplateSection[] }; + load: MaintenanceLoad; + /** Amenity classes the plan accounted for, in catalog order, deduplicated. */ + matchedAmenities: readonly PropertyAmenity[]; + /** Inputs recognised as supply, service or marketing labels with no maintenance load. */ + ignoredAmenities: readonly string[]; + /** Inputs the catalog could not place. Never silently dropped. */ + unmappedAmenities: readonly string[]; + matchedAssets: readonly { assetId: string; assetClass: MaintenanceAssetClass }[]; + unmappedAssets: readonly string[]; +} diff --git a/src/types/property.ts b/src/types/property.ts index bd5641e..1fc3558 100644 --- a/src/types/property.ts +++ b/src/types/property.ts @@ -218,14 +218,68 @@ export interface PropertyMaintenance { mercuryRecipientEmail?: string | null; } +/** + * Amenity classes a property can carry (`PropertySupply.amenities`). + * + * Widened 2026-09-04 (TURNWRK-630) from the original seven so the maintenance + * plan catalog (`src/maintenance`) can read them: each class below is one the + * catalog knows how to inspect per turn, maintain preventively, or route to a + * trade. The original seven keep their exact spelling. Map free-text labels + * (an Airbnb amenity list, an operator's notes) through `normalizeAmenity` + * rather than widening this union ad hoc: an unknown class is data the plan + * cannot price. + */ export type PropertyAmenity = + // the original seven | 'kitchen' | 'laundry' | 'pool' | 'hot-tub' | 'outdoor-grill' | 'fireplace' - | 'gym'; + | 'gym' + // water + | 'heated-pool' + | 'screened-lanai' + | 'sauna' + | 'cold-plunge' + | 'boat-dock' + | 'watercraft' + // play + | 'pickleball-court' + | 'sport-court' + | 'basketball-hoop' + | 'putting-green' + | 'game-room' + | 'arcade' + | 'yard-games' + | 'playground' + | 'trampoline' + | 'bikes' + | 'golf-cart' + // outdoor living + | 'outdoor-bar' + | 'fire-pit' + | 'outdoor-furniture' + | 'beach-gear' + | 'yard' + // systems + | 'hvac' + | 'ceiling-fan' + | 'water-heater' + | 'ev-charger' + | 'generator' + | 'elevator' + | 'access-hardware' + | 'security-camera' + | 'noise-monitor' + | 'safety-equipment' + // interior + | 'bathtub' + | 'tv' + | 'wifi' + | 'dedicated-workspace' + | 'family-kit'; export type SupplyTier = 'basics' | 'comfort' | 'luxe'; diff --git a/tests/maintenance/aliases.test.ts b/tests/maintenance/aliases.test.ts new file mode 100644 index 0000000..b7fad48 --- /dev/null +++ b/tests/maintenance/aliases.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + AMENITY_SPEC_BY_CLASS, + KNOWN_AMENITY_CLASSES, + amenityKey, + isPropertyAmenity, + normalizeAmenities, + normalizeAmenity, +} from '../../src/maintenance'; + +describe('amenityKey', () => { + it('lower-cases, strips the Unavailable prefix, and collapses punctuation', () => { + expect(amenityKey("Pack 'n play/Travel crib")).toBe('pack n play travel crib'); + expect(amenityKey('Unavailable: Private entrance')).toBe('private entrance'); + expect(amenityKey(' Wi-Fi ')).toBe('wi fi'); + }); +}); + +describe('normalizeAmenity', () => { + it('maps Airbnb labels to catalog classes', () => { + expect(normalizeAmenity('Private BBQ grill')).toMatchObject({ kind: 'matched', amenity: 'outdoor-grill' }); + expect(normalizeAmenity('Mini golf')).toMatchObject({ kind: 'matched', amenity: 'putting-green' }); + expect(normalizeAmenity('Exterior security cameras on property')).toMatchObject({ + kind: 'matched', + amenity: 'security-camera', + }); + expect(normalizeAmenity('Self check-in')).toMatchObject({ kind: 'matched', amenity: 'access-hardware' }); + }); + + it('passes a class value straight through', () => { + expect(normalizeAmenity('hot-tub')).toMatchObject({ kind: 'matched', amenity: 'hot-tub' }); + }); + + it('ignores consumables, policy flags, and struck-through Unavailable rows', () => { + expect(normalizeAmenity('Shampoo').kind).toBe('ignored'); + expect(normalizeAmenity('Long term stays allowed').kind).toBe('ignored'); + expect(normalizeAmenity('Unavailable: Essentials').kind).toBe('ignored'); + // An unavailable row never matches even when the label itself would. + expect(normalizeAmenity('Unavailable: Pool').kind).toBe('ignored'); + expect(normalizeAmenity(' ').kind).toBe('ignored'); + }); + + it('reports what it does not know as unmapped, never silently', () => { + const n = normalizeAmenity('Helipad'); + expect(n).toEqual({ kind: 'unmapped', input: 'Helipad' }); + }); +}); + +describe('normalizeAmenities', () => { + it('deduplicates matches in first-seen order and keeps the other two buckets', () => { + const out = normalizeAmenities(['Refrigerator', 'Kitchen', 'Oven', 'Hangers', 'Helipad', 'Pool']); + expect(out.matched).toEqual(['kitchen', 'pool']); + expect(out.ignored).toEqual(['Hangers']); + expect(out.unmapped).toEqual(['Helipad']); + }); +}); + +describe('class vocabulary', () => { + it('every class the alias table can produce has a catalog entry', () => { + for (const cls of KNOWN_AMENITY_CLASSES) { + expect(isPropertyAmenity(cls)).toBe(true); + expect(AMENITY_SPEC_BY_CLASS.has(cls as never), `catalog entry missing for '${cls}'`).toBe(true); + } + }); + + it('keeps the original seven classes', () => { + for (const cls of ['kitchen', 'laundry', 'pool', 'hot-tub', 'outdoor-grill', 'fireplace', 'gym']) { + expect(isPropertyAmenity(cls)).toBe(true); + } + }); +}); diff --git a/tests/maintenance/catalog.test.ts b/tests/maintenance/catalog.test.ts new file mode 100644 index 0000000..0f0d195 --- /dev/null +++ b/tests/maintenance/catalog.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + AMENITY_MAINTENANCE_CATALOG, + ASSET_MAINTENANCE_CATALOG, + MAINTENANCE_TRADES, + classifyAsset, +} from '../../src/maintenance'; +import type { PreventiveTaskSpec } from '../../src/maintenance'; + +const allTasks: PreventiveTaskSpec[] = [ + ...AMENITY_MAINTENANCE_CATALOG.flatMap((s) => [...s.preventive]), + ...ASSET_MAINTENANCE_CATALOG.flatMap((s) => [...s.preventive]), +]; + +describe('amenity catalog shape', () => { + it('lists each amenity class once', () => { + const keys = AMENITY_MAINTENANCE_CATALOG.map((s) => s.amenity); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('gives every preventive task a unique key across amenities and assets', () => { + const keys = allTasks.map((t) => t.key); + const dupes = keys.filter((k, i) => keys.indexOf(k) !== i); + expect(dupes).toEqual([]); + }); + + it('keeps cadences expressible as a PM schedule and minutes as positive integers', () => { + for (const t of allTasks) { + expect(t.cadenceValue, t.key).toBeGreaterThan(0); + expect(Number.isInteger(t.cadenceValue), t.key).toBe(true); + expect(['days', 'weeks', 'months']).toContain(t.cadenceUnit); + expect(Number.isInteger(t.minutes) && t.minutes > 0, t.key).toBe(true); + expect(MAINTENANCE_TRADES).toContain(t.trade); + expect(['High', 'Medium', 'Low']).toContain(t.priority); + expect(t.sections.length, t.key).toBeGreaterThan(0); + for (const section of t.sections) { + const ids = section.items.map((i) => i.id); + expect(new Set(ids).size, `${t.key}/${section.id}`).toBe(ids.length); + expect(section.items.length).toBeGreaterThan(0); + } + } + }); + + it('keeps per-turn item ids unique across the whole walk', () => { + const ids = AMENITY_MAINTENANCE_CATALOG.flatMap((s) => s.perTurn?.items.map((i) => i.id) ?? []); + const dupes = ids.filter((k, i) => ids.indexOf(k) !== i); + expect(dupes).toEqual([]); + for (const s of AMENITY_MAINTENANCE_CATALOG) { + if (!s.perTurn) continue; + expect(Number.isInteger(s.perTurn.minutes) && s.perTurn.minutes > 0, s.amenity).toBe(true); + // Namespaced `.` so a composed walk never collides across sections. + for (const item of s.perTurn.items) expect(item.id, item.id).toMatch(/^[a-z_]+\.[a-z_]+$/); + } + }); + + it('routes corrective calls to a known trade', () => { + for (const s of [...AMENITY_MAINTENANCE_CATALOG, ...ASSET_MAINTENANCE_CATALOG]) { + if (!s.corrective) continue; + expect(MAINTENANCE_TRADES).toContain(s.corrective.trade); + expect(s.corrective.callsPerYear).toBeGreaterThan(0); + expect(s.corrective.minutes).toBeGreaterThan(0); + } + }); + + it('starts the walk at the pool', () => { + expect(AMENITY_MAINTENANCE_CATALOG[0]?.amenity).toBe('pool'); + }); +}); + +describe('classifyAsset', () => { + it('recognises equipment from free text on the register', () => { + expect(classifyAsset({ id: 'a1', name: 'Water heater', brand: 'Rheem', model: '50 gal' })).toBe('water_heater'); + expect(classifyAsset({ id: 'a2', name: 'Pool heat pump', brand: 'AquaCal' })).toBe('pool_heater'); + expect(classifyAsset({ id: 'a3', name: 'Variable speed pump', brand: 'Pentair' })).toBe('pool_pump'); + expect(classifyAsset({ id: 'a4', name: 'Front door', model: 'Schlage Encode' })).toBe('smart_lock'); + expect(classifyAsset({ id: 'a5', name: 'Garage opener', brand: 'LiftMaster' })).toBe('garage_door'); + }); + + it('prefers the pool heater over the pool pump when both words appear', () => { + expect(classifyAsset({ id: 'a6', name: 'Pentair pool heater' })).toBe('pool_heater'); + }); + + it('returns null rather than guessing', () => { + expect(classifyAsset({ id: 'a7', name: 'Dining table' })).toBeNull(); + expect(classifyAsset({ id: 'a8', name: '' })).toBeNull(); + }); +}); diff --git a/tests/maintenance/fixtures/palmshine.ts b/tests/maintenance/fixtures/palmshine.ts new file mode 100644 index 0000000..77170e0 --- /dev/null +++ b/tests/maintenance/fixtures/palmshine.ts @@ -0,0 +1,107 @@ +/** + * Palmshine Hideaway, Seminole FL (Airbnb listing 1576951617298022984), read + * from the live amenities modal on 2026-09-04. The first field partner + * prospect the catalog was built against (TURNWRK-630): 4BR/3BA, sleeps 10, + * about five turns a month at eight months of hosting and 41 reviews. + * + * `PALMSHINE_AIRBNB_AMENITIES` is the modal verbatim, section by section, as + * Airbnb renders it (60 of the "65" rows carried a readable label; the modal + * counts a few sub-rows the accessibility tree does not expose). The listing + * DESCRIPTION names outdoor features the modal does not have a row for; those + * are `PALMSHINE_DESCRIPTION_AMENITIES` and a scraper that reads the + * description will add them. + */ +export const PALMSHINE_AIRBNB_AMENITIES: readonly string[] = [ + // Bathroom + 'Bathtub', + 'Hair dryer', + 'Cleaning products', + 'Shampoo', + 'Conditioner', + 'Body soap', + 'Hot water', + 'Shower gel', + // Bedroom and laundry + 'Washer', + 'Dryer', + 'Hangers', + 'Bed linens', + 'Extra pillows and blankets', + 'Iron', + 'Clothing storage', + // Entertainment + 'TV', + 'Ping pong table', + 'Arcade games', + 'Books and reading material', + 'Life size games', + 'Mini golf', + // Family + "Pack 'n play/Travel crib", + "Children's books and toys", + 'High chair', + 'Board games', + // Heating and cooling + 'Air conditioning', + 'Ceiling fan', + 'Heating', + // Home safety + 'Noise decibel monitors on property', + 'Exterior security cameras on property', + 'Smoke alarm', + 'Carbon monoxide alarm', + 'Fire extinguisher', + 'First aid kit', + // Internet and office + 'Wifi', + 'Dedicated workspace', + // Kitchen and dining + 'Kitchen', + 'Refrigerator', + 'Microwave', + 'Cooking basics', + 'Dishes and silverware', + 'Freezer', + 'Dishwasher', + 'Stove', + 'Oven', + 'Coffee maker', + 'Wine glasses', + 'Toaster', + // Outdoor + 'Backyard', + 'Fire pit', + 'Outdoor furniture', + 'Outdoor dining area', + 'Private BBQ grill', + 'Beach essentials', + // Parking and facilities + 'Free parking on premises', + 'Free street parking', + 'Pool', + 'Hot tub', + // Services + 'Long term stays allowed', + 'Self check-in', + // Not included + 'Unavailable: Essentials', + 'Unavailable: Private entrance', +]; + +/** From the listing description: "Massive heated pool", "Pickleball/Basketball court", "Putting green", "Tiki Bar". */ +export const PALMSHINE_DESCRIPTION_AMENITIES: readonly string[] = [ + 'Heated pool', + 'Pickleball court', + 'Basketball hoop', + 'Putting green', + 'Tiki bar', + 'Game room', +]; + +export const PALMSHINE_ALL_AMENITIES: readonly string[] = [ + ...PALMSHINE_AIRBNB_AMENITIES, + ...PALMSHINE_DESCRIPTION_AMENITIES, +]; + +/** Eight months hosting, 41 reviews, so roughly five stays a month. */ +export const PALMSHINE_TURNS_PER_MONTH = 5; diff --git a/tests/maintenance/plan.test.ts b/tests/maintenance/plan.test.ts new file mode 100644 index 0000000..cc54032 --- /dev/null +++ b/tests/maintenance/plan.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { + DAYS_PER_MONTH, + hoursCeil, + occurrencesPerMonth, + planForProperty, +} from '../../src/maintenance'; +import type { MaintenancePlan } from '../../src/maintenance'; +import { + PALMSHINE_ALL_AMENITIES, + PALMSHINE_AIRBNB_AMENITIES, + PALMSHINE_TURNS_PER_MONTH, +} from './fixtures/palmshine'; + +function tradeRow(plan: MaintenancePlan, trade: string) { + return plan.load.byTrade.find((r) => r.trade === trade); +} + +describe('occurrencesPerMonth', () => { + it('amortises a cadence over an average month', () => { + expect(occurrencesPerMonth(7, 'days')).toBeCloseTo(DAYS_PER_MONTH / 7, 6); + expect(occurrencesPerMonth(2, 'weeks')).toBeCloseTo(DAYS_PER_MONTH / 14, 6); + expect(occurrencesPerMonth(3, 'months')).toBeCloseTo(1 / 3, 6); + expect(occurrencesPerMonth(0, 'days')).toBe(0); + }); +}); + +describe('planForProperty: three-amenity baseline', () => { + const plan = planForProperty({ amenities: ['kitchen', 'laundry', 'pool'], turnsPerMonth: 4 }); + + it('creates one schedule per preventive task, in catalog order', () => { + expect(plan.schedules.map((s) => s.key)).toEqual([ + 'pool_service_verification', + 'pool_equipment_inspection', + 'pool_filter_clean', + 'kitchen_appliance_service', + 'dryer_vent_clean', + ]); + expect(plan.matchedAmenities).toEqual(['pool', 'kitchen', 'laundry']); + }); + + it('composes the per-turn inspection from each amenity section', () => { + expect(plan.perTurnInspection.sections.map((s) => s.id)).toEqual([ + 'amenity.pool', + 'amenity.kitchen', + 'amenity.laundry', + ]); + // 10 + 3 + 2 minutes per walk. + expect(plan.perTurnInspection.minutes).toBe(15); + expect(plan.load.perTurnMinutesPerTurn).toBe(15); + expect(plan.load.perTurnMinutesPerMonth).toBe(60); + }); + + it('splits the load by trade with the handyman in-house by default', () => { + const handyman = tradeRow(plan, 'handyman'); + const pool = tradeRow(plan, 'pool'); + const appliance = tradeRow(plan, 'appliance'); + expect(handyman?.inHouse).toBe(true); + expect(pool?.inHouse).toBe(false); + expect(appliance?.inHouse).toBe(false); + // Per-turn walks are always the field seat's minutes. + expect(handyman?.perTurnMinutesPerMonth).toBe(60); + // Weekly verification (15 min) plus a quarterly filter clean (60 min). + expect(pool?.preventiveMinutesPerMonth).toBe(Math.round((DAYS_PER_MONTH / 7) * 15 + 60 / 3)); + // Six pool calls a year at an hour each. + expect(pool?.correctiveMinutesPerMonth).toBe(30); + }); + + it('sums by-trade rows into the totals exactly', () => { + const rows = plan.load.byTrade; + const sum = (k: 'perTurnMinutesPerMonth' | 'preventiveMinutesPerMonth' | 'correctiveMinutesPerMonth' | 'totalMinutesPerMonth') => + rows.reduce((acc, r) => acc + r[k], 0); + expect(plan.load.perTurnMinutesPerMonth).toBe(sum('perTurnMinutesPerMonth')); + expect(plan.load.preventiveMinutesPerMonth).toBe(sum('preventiveMinutesPerMonth')); + expect(plan.load.correctiveMinutesPerMonth).toBe(sum('correctiveMinutesPerMonth')); + expect(plan.load.totalMinutesPerMonth).toBe(sum('totalMinutesPerMonth')); + expect(plan.load.inHouseMinutesPerMonth + plan.load.specialtyMinutesPerMonth).toBe(plan.load.totalMinutesPerMonth); + for (const r of rows) { + expect(r.totalMinutesPerMonth).toBe(r.perTurnMinutesPerMonth + r.preventiveMinutesPerMonth + r.correctiveMinutesPerMonth); + expect(Number.isInteger(r.totalMinutesPerMonth)).toBe(true); + } + }); + + it('is deterministic', () => { + const again = planForProperty({ amenities: ['kitchen', 'laundry', 'pool'], turnsPerMonth: 4 }); + expect(again).toEqual(plan); + }); +}); + +describe('planForProperty: options and edges', () => { + it('honours the org in-house trade list', () => { + const plan = planForProperty({ amenities: ['pool'], turnsPerMonth: 4, inHouseTrades: ['handyman', 'pool'] }); + expect(tradeRow(plan, 'pool')?.inHouse).toBe(true); + expect(plan.schedules.every((s) => s.inHouse)).toBe(true); + expect(plan.load.specialtyMinutesPerMonth).toBe(0); + }); + + it('zero turns means no per-turn load but the preventive plan still stands', () => { + const plan = planForProperty({ amenities: ['pool'], turnsPerMonth: 0 }); + expect(plan.load.perTurnMinutesPerMonth).toBe(0); + expect(plan.perTurnInspection.minutes).toBe(10); + expect(plan.schedules.length).toBe(3); + }); + + it('keeps unknown labels visible and leaves ignored labels out of the plan', () => { + const plan = planForProperty({ amenities: ['Pool', 'Helipad', 'Shampoo', 'Unavailable: Hot tub'], turnsPerMonth: 1 }); + expect(plan.matchedAmenities).toEqual(['pool']); + expect(plan.unmappedAmenities).toEqual(['Helipad']); + expect(plan.ignoredAmenities).toEqual(['Shampoo', 'Unavailable: Hot tub']); + }); + + it('adds asset-driven schedules once per class and reports unknown assets', () => { + const plan = planForProperty({ + amenities: [], + turnsPerMonth: 2, + assets: [ + { id: 'wh', name: 'Water heater', brand: 'Rheem' }, + { id: 'ac1', name: 'Air handler', brand: 'Carrier' }, + { id: 'ac2', name: 'Condenser', brand: 'Carrier', location: 'side yard' }, + { id: 'x', name: 'Dining table' }, + ], + }); + expect(plan.matchedAssets).toEqual([ + { assetId: 'wh', assetClass: 'water_heater' }, + { assetId: 'ac1', assetClass: 'hvac_unit' }, + { assetId: 'ac2', assetClass: 'hvac_unit' }, + ]); + expect(plan.unmappedAssets).toEqual(['Dining table']); + expect(plan.schedules.map((s) => s.key)).toEqual(['asset_water_heater_flush', 'asset_hvac_tune_up']); + expect(plan.schedules[1]?.source).toEqual({ kind: 'asset', assetClass: 'hvac_unit', assetId: 'ac1' }); + expect(tradeRow(plan, 'plumbing')?.inHouse).toBe(false); + }); + + it('rounds hours up for a retainer', () => { + expect(hoursCeil(61)).toBe(2); + expect(hoursCeil(60)).toBe(1); + expect(hoursCeil(0)).toBe(0); + }); +}); + +describe('planForProperty: Palmshine Hideaway', () => { + const plan = planForProperty({ amenities: PALMSHINE_ALL_AMENITIES, turnsPerMonth: PALMSHINE_TURNS_PER_MONTH }); + + it('places every row of the live Airbnb amenities modal', () => { + const modalOnly = planForProperty({ amenities: PALMSHINE_AIRBNB_AMENITIES, turnsPerMonth: PALMSHINE_TURNS_PER_MONTH }); + expect(modalOnly.unmappedAmenities).toEqual([]); + expect(modalOnly.matchedAmenities).toEqual( + expect.arrayContaining(['pool', 'hot-tub', 'arcade', 'game-room', 'putting-green', 'fire-pit', 'outdoor-grill', 'hvac', 'laundry', 'kitchen', 'safety-equipment']), + ); + // The Not included rows are absences, not gaps. + expect(modalOnly.ignoredAmenities).toEqual(expect.arrayContaining(['Unavailable: Essentials', 'Unavailable: Private entrance'])); + }); + + it('picks up the description-only outdoor features', () => { + expect(plan.unmappedAmenities).toEqual([]); + expect(plan.matchedAmenities).toEqual( + expect.arrayContaining(['heated-pool', 'pickleball-court', 'basketball-hoop', 'outdoor-bar']), + ); + }); + + it('is a heavy home: a long walk, many schedules, and at least four trades', () => { + expect(plan.perTurnInspection.minutes).toBeGreaterThanOrEqual(60); + expect(plan.schedules.length).toBeGreaterThanOrEqual(20); + const trades = plan.load.byTrade.map((r) => r.trade); + expect(trades).toEqual(expect.arrayContaining(['handyman', 'pool', 'arcade', 'court_surface'])); + expect(trades.length).toBeGreaterThanOrEqual(4); + expect(tradeRow(plan, 'handyman')?.inHouse).toBe(true); + expect(tradeRow(plan, 'pool')?.inHouse).toBe(false); + expect(plan.load.inHouseMinutesPerMonth).toBeGreaterThan(plan.load.specialtyMinutesPerMonth); + expect(hoursCeil(plan.load.totalMinutesPerMonth)).toBeGreaterThan(10); + }); + + it('has a pool chemistry reading on the walk, so the numeric widget gets used', () => { + const poolSection = plan.perTurnInspection.sections.find((s) => s.id === 'amenity.pool'); + expect(poolSection?.items.some((i) => i.inputType === 'number' && i.id === 'pool.chlorine')).toBe(true); + }); +});