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); + }); +});