From 1188a081c86f7cdcf9a1c4da2f35395f0e154528 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:34:50 +0000 Subject: [PATCH 1/7] feat(config): permanent ids for all 262 starter tasks Every task in config.json now carries an id of the form - (e.g. maintenance-014), assigned once by scripts/assign-task-ids.mjs. The script only ever ADDS missing ids: existing ids are never changed, numbering continues after the highest number used in each department, it edits config.json as text so the diff is one inserted "id" per task line, and it re-parses its own output and refuses to write if anything other than ids would change. `npm run ids:assign` / `npm run ids:check`. Tests: all 262 present, well-formed, unique, not derived from names; script is idempotent, never reuses numbers, rejects duplicate ids and layouts it cannot edit safely. Mutation-checked (reassigning existing ids fails 3 tests). Runtime matching by id comes in the next commits; behavior is unchanged here. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GXAgt2SAabUwyuTA9TbMsr --- config.json | 524 +++++++++++++++++----------------- js/__tests__/task-ids.test.js | 94 ++++++ package.json | 4 +- scripts/assign-task-ids.mjs | 101 +++++++ 4 files changed, 460 insertions(+), 263 deletions(-) create mode 100644 js/__tests__/task-ids.test.js create mode 100644 scripts/assign-task-ids.mjs diff --git a/config.json b/config.json index fc3cb02..9229a8e 100644 --- a/config.json +++ b/config.json @@ -7,26 +7,26 @@ "name": "Leasing & Marketing", "color": "#1976d2", "tasks": [ - { "name": "Create property listings and syndicate to platforms", "owner": "UNOWNED" }, - { "name": "Conduct pricing strategy & rent comp analysis", "owner": "UNOWNED" }, - { "name": "Schedule and conduct showings", "owner": "UNOWNED" }, - { "name": "Manage prospect communication and lead follow-up", "owner": "UNOWNED" }, - { "name": "Pre-screen applicants before handoff", "owner": "UNOWNED" }, - { "name": "Process Notice to Vacate (leasing portion)", "owner": "UNOWNED" }, - { "name": "Prepare vacancy reporting to owners", "owner": "UNOWNED" }, - { "name": "Update property status in system", "owner": "UNOWNED" }, - { "name": "Write property descriptions for listings", "owner": "UNOWNED" }, - { "name": "Order and schedule professional photos", "owner": "UNOWNED" }, - { "name": "Coordinate move-in preparation", "owner": "UNOWNED" }, - { "name": "Post flyer campaigns", "owner": "UNOWNED" }, - { "name": "Manage social media (TikTok, Instagram, Facebook)", "owner": "UNOWNED" }, - { "name": "Guest card management", "owner": "UNOWNED" }, - { "name": "Monitor listing engagement metrics", "owner": "UNOWNED" }, - { "name": "Website improvements and SEO", "owner": "UNOWNED" }, - { "name": "Blog content creation", "owner": "UNOWNED" }, - { "name": "Paid advertising campaigns", "owner": "UNOWNED" }, - { "name": "Analytics & tracking", "owner": "UNOWNED" }, - { "name": "Video content creation", "owner": "UNOWNED" } + { "id": "leasing-001", "name": "Create property listings and syndicate to platforms", "owner": "UNOWNED" }, + { "id": "leasing-002", "name": "Conduct pricing strategy & rent comp analysis", "owner": "UNOWNED" }, + { "id": "leasing-003", "name": "Schedule and conduct showings", "owner": "UNOWNED" }, + { "id": "leasing-004", "name": "Manage prospect communication and lead follow-up", "owner": "UNOWNED" }, + { "id": "leasing-005", "name": "Pre-screen applicants before handoff", "owner": "UNOWNED" }, + { "id": "leasing-006", "name": "Process Notice to Vacate (leasing portion)", "owner": "UNOWNED" }, + { "id": "leasing-007", "name": "Prepare vacancy reporting to owners", "owner": "UNOWNED" }, + { "id": "leasing-008", "name": "Update property status in system", "owner": "UNOWNED" }, + { "id": "leasing-009", "name": "Write property descriptions for listings", "owner": "UNOWNED" }, + { "id": "leasing-010", "name": "Order and schedule professional photos", "owner": "UNOWNED" }, + { "id": "leasing-011", "name": "Coordinate move-in preparation", "owner": "UNOWNED" }, + { "id": "leasing-012", "name": "Post flyer campaigns", "owner": "UNOWNED" }, + { "id": "leasing-013", "name": "Manage social media (TikTok, Instagram, Facebook)", "owner": "UNOWNED" }, + { "id": "leasing-014", "name": "Guest card management", "owner": "UNOWNED" }, + { "id": "leasing-015", "name": "Monitor listing engagement metrics", "owner": "UNOWNED" }, + { "id": "leasing-016", "name": "Website improvements and SEO", "owner": "UNOWNED" }, + { "id": "leasing-017", "name": "Blog content creation", "owner": "UNOWNED" }, + { "id": "leasing-018", "name": "Paid advertising campaigns", "owner": "UNOWNED" }, + { "id": "leasing-019", "name": "Analytics & tracking", "owner": "UNOWNED" }, + { "id": "leasing-020", "name": "Video content creation", "owner": "UNOWNED" } ] }, { @@ -34,22 +34,22 @@ "name": "Applications & Tenant Screening", "color": "#6a1b9a", "tasks": [ - { "name": "Receive and process applications", "owner": "UNOWNED" }, - { "name": "Follow up on incomplete applications", "owner": "UNOWNED" }, - { "name": "Process application fees", "owner": "UNOWNED" }, - { "name": "Conduct application completeness checks", "owner": "UNOWNED" }, - { "name": "Perform income verification", "owner": "UNOWNED" }, - { "name": "Conduct employment verification", "owner": "UNOWNED" }, - { "name": "Contact previous landlords for references", "owner": "UNOWNED" }, - { "name": "Verify rental history", "owner": "UNOWNED" }, - { "name": "Calculate rent-to-income ratios", "owner": "UNOWNED" }, - { "name": "Process voucher applications (CHA, Section 8)", "owner": "UNOWNED" }, - { "name": "Run credit & criminal screening", "owner": "UNOWNED" }, - { "name": "Conduct pet screening", "owner": "UNOWNED" }, - { "name": "Perform fraud detection", "owner": "UNOWNED" }, - { "name": "Risk scoring and tenant selection", "owner": "UNOWNED" }, - { "name": "Generate approval & denial letters", "owner": "UNOWNED" }, - { "name": "Notify owners of applicant status", "owner": "UNOWNED" } + { "id": "applications-001", "name": "Receive and process applications", "owner": "UNOWNED" }, + { "id": "applications-002", "name": "Follow up on incomplete applications", "owner": "UNOWNED" }, + { "id": "applications-003", "name": "Process application fees", "owner": "UNOWNED" }, + { "id": "applications-004", "name": "Conduct application completeness checks", "owner": "UNOWNED" }, + { "id": "applications-005", "name": "Perform income verification", "owner": "UNOWNED" }, + { "id": "applications-006", "name": "Conduct employment verification", "owner": "UNOWNED" }, + { "id": "applications-007", "name": "Contact previous landlords for references", "owner": "UNOWNED" }, + { "id": "applications-008", "name": "Verify rental history", "owner": "UNOWNED" }, + { "id": "applications-009", "name": "Calculate rent-to-income ratios", "owner": "UNOWNED" }, + { "id": "applications-010", "name": "Process voucher applications (CHA, Section 8)", "owner": "UNOWNED" }, + { "id": "applications-011", "name": "Run credit & criminal screening", "owner": "UNOWNED" }, + { "id": "applications-012", "name": "Conduct pet screening", "owner": "UNOWNED" }, + { "id": "applications-013", "name": "Perform fraud detection", "owner": "UNOWNED" }, + { "id": "applications-014", "name": "Risk scoring and tenant selection", "owner": "UNOWNED" }, + { "id": "applications-015", "name": "Generate approval & denial letters", "owner": "UNOWNED" }, + { "id": "applications-016", "name": "Notify owners of applicant status", "owner": "UNOWNED" } ] }, { @@ -57,19 +57,19 @@ "name": "Tenant Onboarding & Move-Ins", "color": "#00897b", "tasks": [ - { "name": "Prepare move-in packet", "owner": "UNOWNED" }, - { "name": "Coordinate lease signing", "owner": "UNOWNED" }, - { "name": "Collect security deposits and first month rent", "owner": "UNOWNED" }, - { "name": "Collect pet deposits/fees", "owner": "UNOWNED" }, - { "name": "Process holding deposits", "owner": "UNOWNED" }, - { "name": "Calculate prorated rent", "owner": "UNOWNED" }, - { "name": "Set up condition report in Zinspector", "owner": "UNOWNED" }, - { "name": "Provide utility transfer instructions", "owner": "UNOWNED" }, - { "name": "Assign keys/lockbox access", "owner": "UNOWNED" }, - { "name": "Schedule and conduct move-in inspections", "owner": "UNOWNED" }, - { "name": "Complete inspection reports with photos", "owner": "UNOWNED" }, - { "name": "Send welcome messages", "owner": "UNOWNED" }, - { "name": "Update marketing listings (remove from market)", "owner": "UNOWNED" } + { "id": "move-ins-001", "name": "Prepare move-in packet", "owner": "UNOWNED" }, + { "id": "move-ins-002", "name": "Coordinate lease signing", "owner": "UNOWNED" }, + { "id": "move-ins-003", "name": "Collect security deposits and first month rent", "owner": "UNOWNED" }, + { "id": "move-ins-004", "name": "Collect pet deposits/fees", "owner": "UNOWNED" }, + { "id": "move-ins-005", "name": "Process holding deposits", "owner": "UNOWNED" }, + { "id": "move-ins-006", "name": "Calculate prorated rent", "owner": "UNOWNED" }, + { "id": "move-ins-007", "name": "Set up condition report in Zinspector", "owner": "UNOWNED" }, + { "id": "move-ins-008", "name": "Provide utility transfer instructions", "owner": "UNOWNED" }, + { "id": "move-ins-009", "name": "Assign keys/lockbox access", "owner": "UNOWNED" }, + { "id": "move-ins-010", "name": "Schedule and conduct move-in inspections", "owner": "UNOWNED" }, + { "id": "move-ins-011", "name": "Complete inspection reports with photos", "owner": "UNOWNED" }, + { "id": "move-ins-012", "name": "Send welcome messages", "owner": "UNOWNED" }, + { "id": "move-ins-013", "name": "Update marketing listings (remove from market)", "owner": "UNOWNED" } ] }, { @@ -77,16 +77,16 @@ "name": "Tenant Communications & Services", "color": "#00695c", "tasks": [ - { "name": "Handle general tenant inquiries", "owner": "UNOWNED" }, - { "name": "Answer lease questions", "owner": "UNOWNED" }, - { "name": "Respond to ledger questions (non-delinquency)", "owner": "UNOWNED" }, - { "name": "Clarify guest/visitor policy", "owner": "UNOWNED" }, - { "name": "Process parking requests", "owner": "UNOWNED" }, - { "name": "Handle pets/ESA requests (after move-in)", "owner": "UNOWNED" }, - { "name": "Clarify property rules", "owner": "UNOWNED" }, - { "name": "Triage service requests (non-maintenance)", "owner": "UNOWNED" }, - { "name": "Answer rent increase questions", "owner": "UNOWNED" }, - { "name": "Provide move-out instructions", "owner": "UNOWNED" } + { "id": "tenant-comms-001", "name": "Handle general tenant inquiries", "owner": "UNOWNED" }, + { "id": "tenant-comms-002", "name": "Answer lease questions", "owner": "UNOWNED" }, + { "id": "tenant-comms-003", "name": "Respond to ledger questions (non-delinquency)", "owner": "UNOWNED" }, + { "id": "tenant-comms-004", "name": "Clarify guest/visitor policy", "owner": "UNOWNED" }, + { "id": "tenant-comms-005", "name": "Process parking requests", "owner": "UNOWNED" }, + { "id": "tenant-comms-006", "name": "Handle pets/ESA requests (after move-in)", "owner": "UNOWNED" }, + { "id": "tenant-comms-007", "name": "Clarify property rules", "owner": "UNOWNED" }, + { "id": "tenant-comms-008", "name": "Triage service requests (non-maintenance)", "owner": "UNOWNED" }, + { "id": "tenant-comms-009", "name": "Answer rent increase questions", "owner": "UNOWNED" }, + { "id": "tenant-comms-010", "name": "Provide move-out instructions", "owner": "UNOWNED" } ] }, { @@ -94,22 +94,22 @@ "name": "Delinquency & Collections", "color": "#c62828", "tasks": [ - { "name": "Review daily delinquencies", "owner": "UNOWNED" }, - { "name": "Send automated payment reminders", "owner": "UNOWNED" }, - { "name": "Set up and track payment plans", "owner": "UNOWNED" }, - { "name": "Generate and send 5-day notices", "owner": "UNOWNED" }, - { "name": "Coordinate with process servers", "owner": "UNOWNED" }, - { "name": "Coordinate posting with Field Tech", "owner": "UNOWNED" }, - { "name": "Handle eviction filing (admin)", "owner": "UNOWNED" }, - { "name": "Manage court scheduling (admin)", "owner": "UNOWNED" }, - { "name": "Perform ledger cleanup & accuracy checks", "owner": "UNOWNED" }, - { "name": "Track NSF (non-sufficient funds)", "owner": "UNOWNED" }, - { "name": "Negotiate early move-outs", "owner": "UNOWNED" }, - { "name": "Issue lease violation notices", "owner": "UNOWNED" }, - { "name": "Handle smoking violations", "owner": "UNOWNED" }, - { "name": "Address unauthorized occupants/pets", "owner": "UNOWNED" }, - { "name": "Prevent utility shutoff situations", "owner": "UNOWNED" }, - { "name": "Process garnishments or court-ordered payments", "owner": "UNOWNED" } + { "id": "delinquency-001", "name": "Review daily delinquencies", "owner": "UNOWNED" }, + { "id": "delinquency-002", "name": "Send automated payment reminders", "owner": "UNOWNED" }, + { "id": "delinquency-003", "name": "Set up and track payment plans", "owner": "UNOWNED" }, + { "id": "delinquency-004", "name": "Generate and send 5-day notices", "owner": "UNOWNED" }, + { "id": "delinquency-005", "name": "Coordinate with process servers", "owner": "UNOWNED" }, + { "id": "delinquency-006", "name": "Coordinate posting with Field Tech", "owner": "UNOWNED" }, + { "id": "delinquency-007", "name": "Handle eviction filing (admin)", "owner": "UNOWNED" }, + { "id": "delinquency-008", "name": "Manage court scheduling (admin)", "owner": "UNOWNED" }, + { "id": "delinquency-009", "name": "Perform ledger cleanup & accuracy checks", "owner": "UNOWNED" }, + { "id": "delinquency-010", "name": "Track NSF (non-sufficient funds)", "owner": "UNOWNED" }, + { "id": "delinquency-011", "name": "Negotiate early move-outs", "owner": "UNOWNED" }, + { "id": "delinquency-012", "name": "Issue lease violation notices", "owner": "UNOWNED" }, + { "id": "delinquency-013", "name": "Handle smoking violations", "owner": "UNOWNED" }, + { "id": "delinquency-014", "name": "Address unauthorized occupants/pets", "owner": "UNOWNED" }, + { "id": "delinquency-015", "name": "Prevent utility shutoff situations", "owner": "UNOWNED" }, + { "id": "delinquency-016", "name": "Process garnishments or court-ordered payments", "owner": "UNOWNED" } ] }, { @@ -117,31 +117,31 @@ "name": "Maintenance Coordination", "color": "#ff6f00", "tasks": [ - { "name": "Monitor work order queue daily", "owner": "UNOWNED" }, - { "name": "Triage and prioritize incoming work orders", "owner": "UNOWNED" }, - { "name": "Receive and edit service requests in Property Meld", "owner": "UNOWNED" }, - { "name": "Respond to emergency maintenance requests (24/7)", "owner": "UNOWNED" }, - { "name": "Dispatch emergency services", "owner": "UNOWNED" }, - { "name": "Coordinate routine maintenance requests", "owner": "UNOWNED" }, - { "name": "Communicate with tenants about work orders", "owner": "UNOWNED" }, - { "name": "Follow up on open work orders", "owner": "UNOWNED" }, - { "name": "Gather invoices & after photos from vendors", "owner": "UNOWNED" }, - { "name": "Enter completed work orders into Buildium", "owner": "UNOWNED" }, - { "name": "Handle tenant maintenance complaints", "owner": "UNOWNED" }, - { "name": "Track maintenance costs per property", "owner": "UNOWNED" }, - { "name": "Coordinate access for vendors", "owner": "UNOWNED" }, - { "name": "Document before/after photos for repairs", "owner": "UNOWNED" }, - { "name": "Get owner approval for repairs ($500-$650 threshold)", "owner": "UNOWNED" }, - { "name": "Manage preventive maintenance schedules", "owner": "UNOWNED" }, - { "name": "Coordinate seasonal maintenance", "owner": "UNOWNED" }, - { "name": "Schedule common area cleaning", "owner": "UNOWNED" }, - { "name": "Coordinate snow removal", "owner": "UNOWNED" }, - { "name": "Manage lawn care", "owner": "UNOWNED" }, - { "name": "HVAC maintenance (fall heat check)", "owner": "UNOWNED" }, - { "name": "Coordinate appliance replacements", "owner": "UNOWNED" }, - { "name": "Order parts and supplies", "owner": "UNOWNED" }, - { "name": "Manage maintenance supply inventory", "owner": "UNOWNED" }, - { "name": "Respond to code violations or city notices", "owner": "UNOWNED" } + { "id": "maintenance-001", "name": "Monitor work order queue daily", "owner": "UNOWNED" }, + { "id": "maintenance-002", "name": "Triage and prioritize incoming work orders", "owner": "UNOWNED" }, + { "id": "maintenance-003", "name": "Receive and edit service requests in Property Meld", "owner": "UNOWNED" }, + { "id": "maintenance-004", "name": "Respond to emergency maintenance requests (24/7)", "owner": "UNOWNED" }, + { "id": "maintenance-005", "name": "Dispatch emergency services", "owner": "UNOWNED" }, + { "id": "maintenance-006", "name": "Coordinate routine maintenance requests", "owner": "UNOWNED" }, + { "id": "maintenance-007", "name": "Communicate with tenants about work orders", "owner": "UNOWNED" }, + { "id": "maintenance-008", "name": "Follow up on open work orders", "owner": "UNOWNED" }, + { "id": "maintenance-009", "name": "Gather invoices & after photos from vendors", "owner": "UNOWNED" }, + { "id": "maintenance-010", "name": "Enter completed work orders into Buildium", "owner": "UNOWNED" }, + { "id": "maintenance-011", "name": "Handle tenant maintenance complaints", "owner": "UNOWNED" }, + { "id": "maintenance-012", "name": "Track maintenance costs per property", "owner": "UNOWNED" }, + { "id": "maintenance-013", "name": "Coordinate access for vendors", "owner": "UNOWNED" }, + { "id": "maintenance-014", "name": "Document before/after photos for repairs", "owner": "UNOWNED" }, + { "id": "maintenance-015", "name": "Get owner approval for repairs ($500-$650 threshold)", "owner": "UNOWNED" }, + { "id": "maintenance-016", "name": "Manage preventive maintenance schedules", "owner": "UNOWNED" }, + { "id": "maintenance-017", "name": "Coordinate seasonal maintenance", "owner": "UNOWNED" }, + { "id": "maintenance-018", "name": "Schedule common area cleaning", "owner": "UNOWNED" }, + { "id": "maintenance-019", "name": "Coordinate snow removal", "owner": "UNOWNED" }, + { "id": "maintenance-020", "name": "Manage lawn care", "owner": "UNOWNED" }, + { "id": "maintenance-021", "name": "HVAC maintenance (fall heat check)", "owner": "UNOWNED" }, + { "id": "maintenance-022", "name": "Coordinate appliance replacements", "owner": "UNOWNED" }, + { "id": "maintenance-023", "name": "Order parts and supplies", "owner": "UNOWNED" }, + { "id": "maintenance-024", "name": "Manage maintenance supply inventory", "owner": "UNOWNED" }, + { "id": "maintenance-025", "name": "Respond to code violations or city notices", "owner": "UNOWNED" } ] }, { @@ -149,19 +149,19 @@ "name": "Vendor Management", "color": "#388e3c", "tasks": [ - { "name": "Build and maintain vendor list", "owner": "UNOWNED" }, - { "name": "Vet and onboard new vendors", "owner": "UNOWNED" }, - { "name": "Verify vendor insurance", "owner": "UNOWNED" }, - { "name": "Collect W-9 forms", "owner": "UNOWNED" }, - { "name": "Negotiate rates", "owner": "UNOWNED" }, - { "name": "Track vendor performance", "owner": "UNOWNED" }, - { "name": "Conduct annual vendor reviews", "owner": "UNOWNED" }, - { "name": "Maintain emergency vendor roster", "owner": "UNOWNED" }, - { "name": "Get multiple bids for major repairs", "owner": "UNOWNED" }, - { "name": "Process vendor invoices", "owner": "UNOWNED" }, - { "name": "Match invoices to work orders", "owner": "UNOWNED" }, - { "name": "Review owner billable expenses", "owner": "UNOWNED" }, - { "name": "Coordinate payment processing", "owner": "UNOWNED" } + { "id": "vendors-001", "name": "Build and maintain vendor list", "owner": "UNOWNED" }, + { "id": "vendors-002", "name": "Vet and onboard new vendors", "owner": "UNOWNED" }, + { "id": "vendors-003", "name": "Verify vendor insurance", "owner": "UNOWNED" }, + { "id": "vendors-004", "name": "Collect W-9 forms", "owner": "UNOWNED" }, + { "id": "vendors-005", "name": "Negotiate rates", "owner": "UNOWNED" }, + { "id": "vendors-006", "name": "Track vendor performance", "owner": "UNOWNED" }, + { "id": "vendors-007", "name": "Conduct annual vendor reviews", "owner": "UNOWNED" }, + { "id": "vendors-008", "name": "Maintain emergency vendor roster", "owner": "UNOWNED" }, + { "id": "vendors-009", "name": "Get multiple bids for major repairs", "owner": "UNOWNED" }, + { "id": "vendors-010", "name": "Process vendor invoices", "owner": "UNOWNED" }, + { "id": "vendors-011", "name": "Match invoices to work orders", "owner": "UNOWNED" }, + { "id": "vendors-012", "name": "Review owner billable expenses", "owner": "UNOWNED" }, + { "id": "vendors-013", "name": "Coordinate payment processing", "owner": "UNOWNED" } ] }, { @@ -169,19 +169,19 @@ "name": "Make-Ready & Unit Turns", "color": "#f57c00", "tasks": [ - { "name": "Coordinate pre-inspection", "owner": "UNOWNED" }, - { "name": "Collect scope & estimates", "owner": "UNOWNED" }, - { "name": "Obtain owner approval", "owner": "UNOWNED" }, - { "name": "Schedule vendors", "owner": "UNOWNED" }, - { "name": "Set up key access", "owner": "UNOWNED" }, - { "name": "Create work orders for all punchlist items", "owner": "UNOWNED" }, - { "name": "Coordinate trash-out", "owner": "UNOWNED" }, - { "name": "Schedule painting", "owner": "UNOWNED" }, - { "name": "Schedule cleaning", "owner": "UNOWNED" }, - { "name": "Conduct final QC inspection", "owner": "UNOWNED" }, - { "name": "Confirm lease readiness", "owner": "UNOWNED" }, - { "name": "Hand off to Leasing when move-in ready", "owner": "UNOWNED" }, - { "name": "Update property information in Buildium", "owner": "UNOWNED" } + { "id": "unit-turns-001", "name": "Coordinate pre-inspection", "owner": "UNOWNED" }, + { "id": "unit-turns-002", "name": "Collect scope & estimates", "owner": "UNOWNED" }, + { "id": "unit-turns-003", "name": "Obtain owner approval", "owner": "UNOWNED" }, + { "id": "unit-turns-004", "name": "Schedule vendors", "owner": "UNOWNED" }, + { "id": "unit-turns-005", "name": "Set up key access", "owner": "UNOWNED" }, + { "id": "unit-turns-006", "name": "Create work orders for all punchlist items", "owner": "UNOWNED" }, + { "id": "unit-turns-007", "name": "Coordinate trash-out", "owner": "UNOWNED" }, + { "id": "unit-turns-008", "name": "Schedule painting", "owner": "UNOWNED" }, + { "id": "unit-turns-009", "name": "Schedule cleaning", "owner": "UNOWNED" }, + { "id": "unit-turns-010", "name": "Conduct final QC inspection", "owner": "UNOWNED" }, + { "id": "unit-turns-011", "name": "Confirm lease readiness", "owner": "UNOWNED" }, + { "id": "unit-turns-012", "name": "Hand off to Leasing when move-in ready", "owner": "UNOWNED" }, + { "id": "unit-turns-013", "name": "Update property information in Buildium", "owner": "UNOWNED" } ] }, { @@ -189,20 +189,20 @@ "name": "Move-Outs & Inspections", "color": "#00acc1", "tasks": [ - { "name": "Receive Notice to Vacate", "owner": "UNOWNED" }, - { "name": "Send move-out instructions", "owner": "UNOWNED" }, - { "name": "Confirm utility transfer", "owner": "UNOWNED" }, - { "name": "Send pre-move-out reminders", "owner": "UNOWNED" }, - { "name": "Provide move-out checklist", "owner": "UNOWNED" }, - { "name": "Schedule move-out inspection", "owner": "UNOWNED" }, - { "name": "Conduct move-out inspection", "owner": "UNOWNED" }, - { "name": "Complete inspection reports in Zinspector", "owner": "UNOWNED" }, - { "name": "Document property condition with photos/notes", "owner": "UNOWNED" }, - { "name": "Identify maintenance needs", "owner": "UNOWNED" }, - { "name": "Provide inspection reports to owners and tenants", "owner": "UNOWNED" }, - { "name": "Hand off to Resident Services for deposit disposition", "owner": "UNOWNED" }, - { "name": "Schedule and conduct routine inspections", "owner": "UNOWNED" }, - { "name": "Conduct CHA inspections", "owner": "UNOWNED" } + { "id": "move-outs-001", "name": "Receive Notice to Vacate", "owner": "UNOWNED" }, + { "id": "move-outs-002", "name": "Send move-out instructions", "owner": "UNOWNED" }, + { "id": "move-outs-003", "name": "Confirm utility transfer", "owner": "UNOWNED" }, + { "id": "move-outs-004", "name": "Send pre-move-out reminders", "owner": "UNOWNED" }, + { "id": "move-outs-005", "name": "Provide move-out checklist", "owner": "UNOWNED" }, + { "id": "move-outs-006", "name": "Schedule move-out inspection", "owner": "UNOWNED" }, + { "id": "move-outs-007", "name": "Conduct move-out inspection", "owner": "UNOWNED" }, + { "id": "move-outs-008", "name": "Complete inspection reports in Zinspector", "owner": "UNOWNED" }, + { "id": "move-outs-009", "name": "Document property condition with photos/notes", "owner": "UNOWNED" }, + { "id": "move-outs-010", "name": "Identify maintenance needs", "owner": "UNOWNED" }, + { "id": "move-outs-011", "name": "Provide inspection reports to owners and tenants", "owner": "UNOWNED" }, + { "id": "move-outs-012", "name": "Hand off to Resident Services for deposit disposition", "owner": "UNOWNED" }, + { "id": "move-outs-013", "name": "Schedule and conduct routine inspections", "owner": "UNOWNED" }, + { "id": "move-outs-014", "name": "Conduct CHA inspections", "owner": "UNOWNED" } ] }, { @@ -210,20 +210,20 @@ "name": "Field Services & Access Control", "color": "#5e35b1", "tasks": [ - { "name": "Post notices (5-day, eviction, etc.)", "owner": "UNOWNED" }, - { "name": "Install locks", "owner": "UNOWNED" }, - { "name": "Label keys", "owner": "UNOWNED" }, - { "name": "Check lock inventory", "owner": "UNOWNED" }, - { "name": "Take property photos", "owner": "UNOWNED" }, - { "name": "Deliver/return keys", "owner": "UNOWNED" }, - { "name": "Install lockboxes", "owner": "UNOWNED" }, - { "name": "Meet vendors on-site", "owner": "UNOWNED" }, - { "name": "Conduct property walk-throughs", "owner": "UNOWNED" }, - { "name": "Perform safety checks", "owner": "UNOWNED" }, - { "name": "Conduct smoke/CO inspections", "owner": "UNOWNED" }, - { "name": "Perform basic repairs", "owner": "UNOWNED" }, - { "name": "Read utility meters", "owner": "UNOWNED" }, - { "name": "Conduct exterior property checks (vacant units)", "owner": "UNOWNED" } + { "id": "field-services-001", "name": "Post notices (5-day, eviction, etc.)", "owner": "UNOWNED" }, + { "id": "field-services-002", "name": "Install locks", "owner": "UNOWNED" }, + { "id": "field-services-003", "name": "Label keys", "owner": "UNOWNED" }, + { "id": "field-services-004", "name": "Check lock inventory", "owner": "UNOWNED" }, + { "id": "field-services-005", "name": "Take property photos", "owner": "UNOWNED" }, + { "id": "field-services-006", "name": "Deliver/return keys", "owner": "UNOWNED" }, + { "id": "field-services-007", "name": "Install lockboxes", "owner": "UNOWNED" }, + { "id": "field-services-008", "name": "Meet vendors on-site", "owner": "UNOWNED" }, + { "id": "field-services-009", "name": "Conduct property walk-throughs", "owner": "UNOWNED" }, + { "id": "field-services-010", "name": "Perform safety checks", "owner": "UNOWNED" }, + { "id": "field-services-011", "name": "Conduct smoke/CO inspections", "owner": "UNOWNED" }, + { "id": "field-services-012", "name": "Perform basic repairs", "owner": "UNOWNED" }, + { "id": "field-services-013", "name": "Read utility meters", "owner": "UNOWNED" }, + { "id": "field-services-014", "name": "Conduct exterior property checks (vacant units)", "owner": "UNOWNED" } ] }, { @@ -231,15 +231,15 @@ "name": "Utilities & Insurance", "color": "#689f38", "tasks": [ - { "name": "Prevent utility shutoff", "owner": "UNOWNED" }, - { "name": "Switch utilities into company name at vacancy", "owner": "UNOWNED" }, - { "name": "Switch utilities out at move-in", "owner": "UNOWNED" }, - { "name": "Track utility bills", "owner": "UNOWNED" }, - { "name": "Coordinate with Accounting department", "owner": "UNOWNED" }, - { "name": "Conduct gas/electric checks before move-in", "owner": "UNOWNED" }, - { "name": "Ensure service is on during turns", "owner": "UNOWNED" }, - { "name": "Confirm boiler-building exceptions", "owner": "UNOWNED" }, - { "name": "Handle insurance claims related to property damage", "owner": "UNOWNED" } + { "id": "utilities-001", "name": "Prevent utility shutoff", "owner": "UNOWNED" }, + { "id": "utilities-002", "name": "Switch utilities into company name at vacancy", "owner": "UNOWNED" }, + { "id": "utilities-003", "name": "Switch utilities out at move-in", "owner": "UNOWNED" }, + { "id": "utilities-004", "name": "Track utility bills", "owner": "UNOWNED" }, + { "id": "utilities-005", "name": "Coordinate with Accounting department", "owner": "UNOWNED" }, + { "id": "utilities-006", "name": "Conduct gas/electric checks before move-in", "owner": "UNOWNED" }, + { "id": "utilities-007", "name": "Ensure service is on during turns", "owner": "UNOWNED" }, + { "id": "utilities-008", "name": "Confirm boiler-building exceptions", "owner": "UNOWNED" }, + { "id": "utilities-009", "name": "Handle insurance claims related to property damage", "owner": "UNOWNED" } ] }, { @@ -247,26 +247,26 @@ "name": "Owner Communications & Relations", "color": "#0277bd", "tasks": [ - { "name": "Handle routine owner questions", "owner": "UNOWNED" }, - { "name": "Provide rent updates", "owner": "UNOWNED" }, - { "name": "Send maintenance updates (non-escalated)", "owner": "UNOWNED" }, - { "name": "Process document requests", "owner": "UNOWNED" }, - { "name": "Provide showing activity summaries", "owner": "UNOWNED" }, - { "name": "Send application status updates", "owner": "UNOWNED" }, - { "name": "Communicate renewal updates", "owner": "UNOWNED" }, - { "name": "Send monthly owner statements", "owner": "UNOWNED" }, - { "name": "Handle owner inquiries (calls, emails)", "owner": "UNOWNED" }, - { "name": "Facilitate owner portal access", "owner": "UNOWNED" }, - { "name": "Send annual property performance reports", "owner": "UNOWNED" }, - { "name": "Provide market updates and rental trend analysis", "owner": "UNOWNED" }, - { "name": "Handle escalation situations", "owner": "UNOWNED" }, - { "name": "Align owner expectations", "owner": "UNOWNED" }, - { "name": "Provide strategic pricing recommendations", "owner": "UNOWNED" }, - { "name": "Review renewal pricing", "owner": "UNOWNED" }, - { "name": "Plan CapEx projects", "owner": "UNOWNED" }, - { "name": "Conduct portfolio performance discussions", "owner": "UNOWNED" }, - { "name": "Manage difficult tenant/owner situations", "owner": "UNOWNED" }, - { "name": "Conduct owner onboarding calls (high-level clients)", "owner": "UNOWNED" } + { "id": "owner-relations-001", "name": "Handle routine owner questions", "owner": "UNOWNED" }, + { "id": "owner-relations-002", "name": "Provide rent updates", "owner": "UNOWNED" }, + { "id": "owner-relations-003", "name": "Send maintenance updates (non-escalated)", "owner": "UNOWNED" }, + { "id": "owner-relations-004", "name": "Process document requests", "owner": "UNOWNED" }, + { "id": "owner-relations-005", "name": "Provide showing activity summaries", "owner": "UNOWNED" }, + { "id": "owner-relations-006", "name": "Send application status updates", "owner": "UNOWNED" }, + { "id": "owner-relations-007", "name": "Communicate renewal updates", "owner": "UNOWNED" }, + { "id": "owner-relations-008", "name": "Send monthly owner statements", "owner": "UNOWNED" }, + { "id": "owner-relations-009", "name": "Handle owner inquiries (calls, emails)", "owner": "UNOWNED" }, + { "id": "owner-relations-010", "name": "Facilitate owner portal access", "owner": "UNOWNED" }, + { "id": "owner-relations-011", "name": "Send annual property performance reports", "owner": "UNOWNED" }, + { "id": "owner-relations-012", "name": "Provide market updates and rental trend analysis", "owner": "UNOWNED" }, + { "id": "owner-relations-013", "name": "Handle escalation situations", "owner": "UNOWNED" }, + { "id": "owner-relations-014", "name": "Align owner expectations", "owner": "UNOWNED" }, + { "id": "owner-relations-015", "name": "Provide strategic pricing recommendations", "owner": "UNOWNED" }, + { "id": "owner-relations-016", "name": "Review renewal pricing", "owner": "UNOWNED" }, + { "id": "owner-relations-017", "name": "Plan CapEx projects", "owner": "UNOWNED" }, + { "id": "owner-relations-018", "name": "Conduct portfolio performance discussions", "owner": "UNOWNED" }, + { "id": "owner-relations-019", "name": "Manage difficult tenant/owner situations", "owner": "UNOWNED" }, + { "id": "owner-relations-020", "name": "Conduct owner onboarding calls (high-level clients)", "owner": "UNOWNED" } ] }, { @@ -274,23 +274,23 @@ "name": "Owner Onboarding", "color": "#7b1fa2", "tasks": [ - { "name": "Collect owner documents", "owner": "UNOWNED" }, - { "name": "Confirm banking information", "owner": "UNOWNED" }, - { "name": "Set up owner accounts in Buildium", "owner": "UNOWNED" }, - { "name": "Set up in LeadSimple", "owner": "UNOWNED" }, - { "name": "Set up in Property Meld", "owner": "UNOWNED" }, - { "name": "Establish owner payment preferences", "owner": "UNOWNED" }, - { "name": "Create management agreements", "owner": "UNOWNED" }, - { "name": "Collect initial owner funds", "owner": "UNOWNED" }, - { "name": "Conduct initial property walkthrough/inspection", "owner": "UNOWNED" }, - { "name": "Set rental price recommendations", "owner": "UNOWNED" }, - { "name": "Get owner approval on rental pricing", "owner": "UNOWNED" }, - { "name": "Check utility account status", "owner": "UNOWNED" }, - { "name": "Set up lockbox & access", "owner": "UNOWNED" }, - { "name": "Complete move-in-ready checklist", "owner": "UNOWNED" }, - { "name": "Verify insurance", "owner": "UNOWNED" }, - { "name": "Send owner welcome email", "owner": "UNOWNED" }, - { "name": "Create Notion owner page", "owner": "UNOWNED" } + { "id": "owner-onboarding-001", "name": "Collect owner documents", "owner": "UNOWNED" }, + { "id": "owner-onboarding-002", "name": "Confirm banking information", "owner": "UNOWNED" }, + { "id": "owner-onboarding-003", "name": "Set up owner accounts in Buildium", "owner": "UNOWNED" }, + { "id": "owner-onboarding-004", "name": "Set up in LeadSimple", "owner": "UNOWNED" }, + { "id": "owner-onboarding-005", "name": "Set up in Property Meld", "owner": "UNOWNED" }, + { "id": "owner-onboarding-006", "name": "Establish owner payment preferences", "owner": "UNOWNED" }, + { "id": "owner-onboarding-007", "name": "Create management agreements", "owner": "UNOWNED" }, + { "id": "owner-onboarding-008", "name": "Collect initial owner funds", "owner": "UNOWNED" }, + { "id": "owner-onboarding-009", "name": "Conduct initial property walkthrough/inspection", "owner": "UNOWNED" }, + { "id": "owner-onboarding-010", "name": "Set rental price recommendations", "owner": "UNOWNED" }, + { "id": "owner-onboarding-011", "name": "Get owner approval on rental pricing", "owner": "UNOWNED" }, + { "id": "owner-onboarding-012", "name": "Check utility account status", "owner": "UNOWNED" }, + { "id": "owner-onboarding-013", "name": "Set up lockbox & access", "owner": "UNOWNED" }, + { "id": "owner-onboarding-014", "name": "Complete move-in-ready checklist", "owner": "UNOWNED" }, + { "id": "owner-onboarding-015", "name": "Verify insurance", "owner": "UNOWNED" }, + { "id": "owner-onboarding-016", "name": "Send owner welcome email", "owner": "UNOWNED" }, + { "id": "owner-onboarding-017", "name": "Create Notion owner page", "owner": "UNOWNED" } ] }, { @@ -298,36 +298,36 @@ "name": "Accounting & Finance", "color": "#00897b", "tasks": [ - { "name": "Process accounts payable", "owner": "UNOWNED" }, - { "name": "Process accounts receivable", "owner": "UNOWNED" }, - { "name": "Reconcile bank accounts monthly", "owner": "UNOWNED" }, - { "name": "Process owner distributions/payments", "owner": "UNOWNED" }, - { "name": "Generate financial reports", "owner": "UNOWNED" }, - { "name": "Handle trust account management", "owner": "UNOWNED" }, - { "name": "Process refunds (tenant, owner)", "owner": "UNOWNED" }, - { "name": "Coordinate with CPA/bookkeeper", "owner": "UNOWNED" }, - { "name": "File sales tax (if applicable)", "owner": "UNOWNED" }, - { "name": "Manage chart of accounts", "owner": "UNOWNED" }, - { "name": "Maintain rent roll accuracy", "owner": "UNOWNED" }, - { "name": "Track reserve accounts", "owner": "UNOWNED" }, - { "name": "Monitor bank accounts", "owner": "UNOWNED" }, - { "name": "Audit preparation and support", "owner": "UNOWNED" }, - { "name": "Process owner draws", "owner": "UNOWNED" }, - { "name": "Handle reserve requests", "owner": "UNOWNED" }, - { "name": "Process special funding requests", "owner": "UNOWNED" }, - { "name": "Manage CapEx fund", "owner": "UNOWNED" }, - { "name": "Track negative balances", "owner": "UNOWNED" }, - { "name": "Handle year-end tax prep (1099s, owner statements)", "owner": "UNOWNED" }, - { "name": "Match vendor invoices to work orders", "owner": "UNOWNED" }, - { "name": "Review owner billable expenses", "owner": "UNOWNED" }, - { "name": "Make ledger adjustments", "owner": "UNOWNED" }, - { "name": "Check invoice accuracy", "owner": "UNOWNED" }, - { "name": "Process chargebacks to tenants", "owner": "UNOWNED" }, - { "name": "Receive deposit funds", "owner": "UNOWNED" }, - { "name": "Assign proper account designation", "owner": "UNOWNED" }, - { "name": "Track deposit compliance", "owner": "UNOWNED" }, - { "name": "Adjust ledger after Statement of Damages", "owner": "UNOWNED" }, - { "name": "Process final refunds", "owner": "UNOWNED" } + { "id": "accounting-001", "name": "Process accounts payable", "owner": "UNOWNED" }, + { "id": "accounting-002", "name": "Process accounts receivable", "owner": "UNOWNED" }, + { "id": "accounting-003", "name": "Reconcile bank accounts monthly", "owner": "UNOWNED" }, + { "id": "accounting-004", "name": "Process owner distributions/payments", "owner": "UNOWNED" }, + { "id": "accounting-005", "name": "Generate financial reports", "owner": "UNOWNED" }, + { "id": "accounting-006", "name": "Handle trust account management", "owner": "UNOWNED" }, + { "id": "accounting-007", "name": "Process refunds (tenant, owner)", "owner": "UNOWNED" }, + { "id": "accounting-008", "name": "Coordinate with CPA/bookkeeper", "owner": "UNOWNED" }, + { "id": "accounting-009", "name": "File sales tax (if applicable)", "owner": "UNOWNED" }, + { "id": "accounting-010", "name": "Manage chart of accounts", "owner": "UNOWNED" }, + { "id": "accounting-011", "name": "Maintain rent roll accuracy", "owner": "UNOWNED" }, + { "id": "accounting-012", "name": "Track reserve accounts", "owner": "UNOWNED" }, + { "id": "accounting-013", "name": "Monitor bank accounts", "owner": "UNOWNED" }, + { "id": "accounting-014", "name": "Audit preparation and support", "owner": "UNOWNED" }, + { "id": "accounting-015", "name": "Process owner draws", "owner": "UNOWNED" }, + { "id": "accounting-016", "name": "Handle reserve requests", "owner": "UNOWNED" }, + { "id": "accounting-017", "name": "Process special funding requests", "owner": "UNOWNED" }, + { "id": "accounting-018", "name": "Manage CapEx fund", "owner": "UNOWNED" }, + { "id": "accounting-019", "name": "Track negative balances", "owner": "UNOWNED" }, + { "id": "accounting-020", "name": "Handle year-end tax prep (1099s, owner statements)", "owner": "UNOWNED" }, + { "id": "accounting-021", "name": "Match vendor invoices to work orders", "owner": "UNOWNED" }, + { "id": "accounting-022", "name": "Review owner billable expenses", "owner": "UNOWNED" }, + { "id": "accounting-023", "name": "Make ledger adjustments", "owner": "UNOWNED" }, + { "id": "accounting-024", "name": "Check invoice accuracy", "owner": "UNOWNED" }, + { "id": "accounting-025", "name": "Process chargebacks to tenants", "owner": "UNOWNED" }, + { "id": "accounting-026", "name": "Receive deposit funds", "owner": "UNOWNED" }, + { "id": "accounting-027", "name": "Assign proper account designation", "owner": "UNOWNED" }, + { "id": "accounting-028", "name": "Track deposit compliance", "owner": "UNOWNED" }, + { "id": "accounting-029", "name": "Adjust ledger after Statement of Damages", "owner": "UNOWNED" }, + { "id": "accounting-030", "name": "Process final refunds", "owner": "UNOWNED" } ] }, { @@ -335,19 +335,19 @@ "name": "Compliance & Legal", "color": "#512da8", "tasks": [ - { "name": "Stay updated on local/state landlord-tenant laws", "owner": "UNOWNED" }, - { "name": "Ensure lease templates comply with regulations", "owner": "UNOWNED" }, - { "name": "File required business licenses and renewals", "owner": "UNOWNED" }, - { "name": "Maintain fair housing compliance", "owner": "UNOWNED" }, - { "name": "Process public records requests", "owner": "UNOWNED" }, - { "name": "Coordinate with attorney on legal matters", "owner": "UNOWNED" }, - { "name": "Maintain required insurance policies", "owner": "UNOWNED" }, - { "name": "Handle eviction filings and court appearances", "owner": "UNOWNED" }, - { "name": "Process security deposit legal requirements", "owner": "UNOWNED" }, - { "name": "Ensure lead paint disclosures", "owner": "UNOWNED" }, - { "name": "Handle ADA compliance issues", "owner": "UNOWNED" }, - { "name": "File required state/local PM reports", "owner": "UNOWNED" }, - { "name": "Maintain required trust account documentation", "owner": "UNOWNED" } + { "id": "compliance-001", "name": "Stay updated on local/state landlord-tenant laws", "owner": "UNOWNED" }, + { "id": "compliance-002", "name": "Ensure lease templates comply with regulations", "owner": "UNOWNED" }, + { "id": "compliance-003", "name": "File required business licenses and renewals", "owner": "UNOWNED" }, + { "id": "compliance-004", "name": "Maintain fair housing compliance", "owner": "UNOWNED" }, + { "id": "compliance-005", "name": "Process public records requests", "owner": "UNOWNED" }, + { "id": "compliance-006", "name": "Coordinate with attorney on legal matters", "owner": "UNOWNED" }, + { "id": "compliance-007", "name": "Maintain required insurance policies", "owner": "UNOWNED" }, + { "id": "compliance-008", "name": "Handle eviction filings and court appearances", "owner": "UNOWNED" }, + { "id": "compliance-009", "name": "Process security deposit legal requirements", "owner": "UNOWNED" }, + { "id": "compliance-010", "name": "Ensure lead paint disclosures", "owner": "UNOWNED" }, + { "id": "compliance-011", "name": "Handle ADA compliance issues", "owner": "UNOWNED" }, + { "id": "compliance-012", "name": "File required state/local PM reports", "owner": "UNOWNED" }, + { "id": "compliance-013", "name": "Maintain required trust account documentation", "owner": "UNOWNED" } ] }, { @@ -355,13 +355,13 @@ "name": "Reporting & KPIs", "color": "#7b1fa2", "tasks": [ - { "name": "Track and report KPIs", "owner": "UNOWNED" }, - { "name": "Generate weekly reports", "owner": "UNOWNED" }, - { "name": "Create monthly summaries", "owner": "UNOWNED" }, - { "name": "Prepare owner performance reports", "owner": "UNOWNED" }, - { "name": "Build LeadSimple report views", "owner": "UNOWNED" }, - { "name": "Track maintenance metrics", "owner": "UNOWNED" }, - { "name": "Monitor and report company scorecard", "owner": "UNOWNED" } + { "id": "reporting-001", "name": "Track and report KPIs", "owner": "UNOWNED" }, + { "id": "reporting-002", "name": "Generate weekly reports", "owner": "UNOWNED" }, + { "id": "reporting-003", "name": "Create monthly summaries", "owner": "UNOWNED" }, + { "id": "reporting-004", "name": "Prepare owner performance reports", "owner": "UNOWNED" }, + { "id": "reporting-005", "name": "Build LeadSimple report views", "owner": "UNOWNED" }, + { "id": "reporting-006", "name": "Track maintenance metrics", "owner": "UNOWNED" }, + { "id": "reporting-007", "name": "Monitor and report company scorecard", "owner": "UNOWNED" } ] }, { @@ -369,18 +369,18 @@ "name": "Technology & Systems", "color": "#0277bd", "tasks": [ - { "name": "Configure Buildium settings", "owner": "UNOWNED" }, - { "name": "Build LeadSimple workflows", "owner": "UNOWNED" }, - { "name": "Adjust Property Meld settings", "owner": "UNOWNED" }, - { "name": "Create and update document templates", "owner": "UNOWNED" }, - { "name": "Update Knowledge Core (internal wiki)", "owner": "UNOWNED" }, - { "name": "Manage user access", "owner": "UNOWNED" }, - { "name": "Build system automations (Zapier, etc.)", "owner": "UNOWNED" }, - { "name": "Update SOP documentation", "owner": "UNOWNED" }, - { "name": "Create process diagrams", "owner": "UNOWNED" }, - { "name": "Record tutorial videos", "owner": "UNOWNED" }, - { "name": "Build checklists", "owner": "UNOWNED" }, - { "name": "Develop role training modules", "owner": "UNOWNED" } + { "id": "systems-001", "name": "Configure Buildium settings", "owner": "UNOWNED" }, + { "id": "systems-002", "name": "Build LeadSimple workflows", "owner": "UNOWNED" }, + { "id": "systems-003", "name": "Adjust Property Meld settings", "owner": "UNOWNED" }, + { "id": "systems-004", "name": "Create and update document templates", "owner": "UNOWNED" }, + { "id": "systems-005", "name": "Update Knowledge Core (internal wiki)", "owner": "UNOWNED" }, + { "id": "systems-006", "name": "Manage user access", "owner": "UNOWNED" }, + { "id": "systems-007", "name": "Build system automations (Zapier, etc.)", "owner": "UNOWNED" }, + { "id": "systems-008", "name": "Update SOP documentation", "owner": "UNOWNED" }, + { "id": "systems-009", "name": "Create process diagrams", "owner": "UNOWNED" }, + { "id": "systems-010", "name": "Record tutorial videos", "owner": "UNOWNED" }, + { "id": "systems-011", "name": "Build checklists", "owner": "UNOWNED" }, + { "id": "systems-012", "name": "Develop role training modules", "owner": "UNOWNED" } ] } ] diff --git a/js/__tests__/task-ids.test.js b/js/__tests__/task-ids.test.js new file mode 100644 index 0000000..1eca582 --- /dev/null +++ b/js/__tests__/task-ids.test.js @@ -0,0 +1,94 @@ +import { describe, test, expect } from 'vitest'; +import config from '../../config.json'; +import { assignTaskIds, findTasksMissingIds, TASK_ID_PATTERN } from '../../scripts/assign-task-ids.mjs'; + +const FIXTURE = `{ + "orgData": { + "departments": [ + { + "id": "leasing", + "tasks": [ + { "name": "List units", "owner": "UNOWNED" }, + { "name": "Show units", "owner": "UNOWNED" } + ] + }, + { + "id": "vendors", + "tasks": [ + { "id": "vendors-007", "name": "Pay vendors", "owner": "UNOWNED" }, + { "name": "Review insurance", "owner": "UNOWNED" } + ] + } + ] + } +} +`; + +const tasksOf = text => JSON.parse(text).orgData.departments.flatMap(d => d.tasks); + +describe('config.json task ids', () => { + const tasks = config.orgData.departments.flatMap(d => d.tasks.map(t => ({ dept: d.id, ...t }))); + + test('every starter task has a well-formed permanent id', () => { + expect(tasks.length).toBe(262); + tasks.forEach(t => expect(t.id, `${t.dept}: ${t.name}`).toMatch(TASK_ID_PATTERN)); + }); + + test('ids are unique across the whole config', () => { + const ids = tasks.map(t => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test('ids are not derived from the visible name (renaming must not require a new id)', () => { + tasks.forEach(t => expect(t.id).toMatch(new RegExp(`^${t.dept}-\\d{3}$`))); + }); + + test('the assign script has nothing left to do', () => { + expect(findTasksMissingIds(config)).toEqual([]); + }); +}); + +describe('assignTaskIds', () => { + test('adds ids to tasks that lack one and numbers them per department', () => { + const { text, added } = assignTaskIds(FIXTURE); + const ids = tasksOf(text).map(t => t.id); + expect(added).toBe(3); + expect(ids).toEqual(['leasing-001', 'leasing-002', 'vendors-007', 'vendors-008']); + }); + + test('never changes an id that already exists', () => { + const { text } = assignTaskIds(FIXTURE); + expect(tasksOf(text).find(t => t.name === 'Pay vendors').id).toBe('vendors-007'); + }); + + test('is idempotent: a second run changes nothing', () => { + const first = assignTaskIds(FIXTURE); + const second = assignTaskIds(first.text); + expect(second.added).toBe(0); + expect(second.text).toBe(first.text); + }); + + test('only inserts ids: names, owners, order and formatting are untouched', () => { + const { text } = assignTaskIds(FIXTURE); + expect(text.replace(/"id": "[a-z0-9-]+", /g, '').replace('"id": "vendors-007", ', '')).toBe(FIXTURE.replace('"id": "vendors-007", ', '')); + expect(tasksOf(text).map(t => [t.name, t.owner])).toEqual(tasksOf(FIXTURE).map(t => [t.name, t.owner])); + }); + + test('new tasks added later get the next free number, never a reused one', () => { + const first = assignTaskIds(FIXTURE).text; + const grown = first.replace('{ "id": "vendors-008", "name": "Review insurance"', '{ "name": "Brand new task", "owner": "UNOWNED" },\n { "id": "vendors-008", "name": "Review insurance"'); + const ids = tasksOf(assignTaskIds(grown).text).map(t => t.id); + expect(new Set(ids).size).toBe(ids.length); + expect(tasksOf(assignTaskIds(grown).text).find(t => t.name === 'Brand new task').id).toBe('vendors-009'); + }); + + test('refuses duplicate existing ids', () => { + const dup = FIXTURE.replace('{ "name": "Show units"', '{ "id": "vendors-007", "name": "Show units"'); + expect(() => assignTaskIds(dup)).toThrow(/Duplicate task id/); + }); + + test('refuses a layout it cannot edit safely instead of guessing', () => { + const multiline = FIXTURE.replace('{ "name": "List units", "owner": "UNOWNED" }', '{\n"name": "List units", "owner": "UNOWNED" }'); + expect(() => assignTaskIds(multiline)).toThrow(/one-line task entries/); + }); +}); diff --git a/package.json b/package.json index ef5db70..2105a2f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "test:e2e": "playwright test", "test:e2e:pages": "npm run build && playwright test --config playwright.pages.config.mjs", "start": "webpack serve --open --config webpack.config.dev.js", - "build": "webpack --config webpack.config.prod.js" + "build": "webpack --config webpack.config.prod.js", + "ids:assign": "node scripts/assign-task-ids.mjs", + "ids:check": "node scripts/assign-task-ids.mjs --check" }, "devDependencies": { "@playwright/test": "1.56.1", diff --git a/scripts/assign-task-ids.mjs b/scripts/assign-task-ids.mjs new file mode 100644 index 0000000..17cdcb4 --- /dev/null +++ b/scripts/assign-task-ids.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +// Gives every task in config.json a permanent id such as "maintenance-014". +// +// Ids are what let saved progress, imports and backups find a task even after +// its visible name in config.json is reworded. This script only ever ADDS +// missing ids: an id that already exists is never changed, and numbering +// continues after the highest number already used in each department. +// +// node scripts/assign-task-ids.mjs add missing ids to config.json +// node scripts/assign-task-ids.mjs --check exit 1 if any task has no id +// +// config.json is edited as text (one inserted `"id": "…",` per task line) so +// the diff stays reviewable; the result is re-parsed and compared with the +// original before anything is written. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const TASK_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*-\d{3,}$/; +const TASK_LINE = /^(\s*)\{ "(id|name)":/; + +function listTasks(config) { + return config.orgData.departments.flatMap(dept => dept.tasks.map(task => ({ dept, task }))); +} + +export function findTasksMissingIds(config) { + return listTasks(config).filter(({ task }) => !task.id).map(({ dept, task }) => `${dept.id}: ${task.name}`); +} + +// Returns { text, added } — `text` is the new config.json contents. +export function assignTaskIds(configText) { + const config = JSON.parse(configText); + const all = listTasks(config); + + const used = new Set(); + const nextNumber = new Map(); + all.forEach(({ dept, task }) => { + if (!task.id) return; + if (used.has(task.id)) throw new Error(`Duplicate task id "${task.id}" in config.json`); + used.add(task.id); + const m = /^(.*)-(\d+)$/.exec(task.id); + if (m && m[1] === dept.id) nextNumber.set(dept.id, Math.max(nextNumber.get(dept.id) || 0, Number(m[2]))); + }); + + const assigned = new Map(); // task object -> new id + all.forEach(({ dept, task }) => { + if (task.id) return; + let n = nextNumber.get(dept.id) || 0; + let id; + do { n++; id = `${dept.id}-${String(n).padStart(3, '0')}`; } while (used.has(id)); + nextNumber.set(dept.id, n); + used.add(id); + assigned.set(task, id); + }); + + const lines = configText.split('\n'); + const taskLineIdx = lines.map((l, i) => (TASK_LINE.test(l) ? i : -1)).filter(i => i >= 0); + if (taskLineIdx.length !== all.length) { + throw new Error( + `config.json has ${all.length} tasks but ${taskLineIdx.length} one-line task entries; ` + + 'keep each task on its own line ({ "name": …, "owner": … }) so ids can be inserted safely.' + ); + } + all.forEach(({ task }, i) => { + const id = assigned.get(task); + if (!id) return; + const idx = taskLineIdx[i]; + lines[idx] = lines[idx].replace('{ "name":', `{ "id": "${id}", "name":`); + }); + const text = lines.join('\n'); + + // Safety net: the new file must equal the old one plus the added ids, nothing else. + const after = JSON.parse(text); + listTasks(after).forEach(({ task }, i) => { + const before = all[i].task; + const expected = assigned.get(before) ? { id: assigned.get(before), ...before } : before; + if (JSON.stringify(task) !== JSON.stringify(expected)) { + throw new Error(`Refusing to write: task ${i} (${before.name}) would change in ways other than its id.`); + } + }); + return { text, added: assigned.size }; +} + +function main() { + const file = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'config.json'); + const original = fs.readFileSync(file, 'utf8'); + if (process.argv.includes('--check')) { + const missing = findTasksMissingIds(JSON.parse(original)); + if (missing.length) { + console.error(`${missing.length} task(s) have no id. Run: npm run ids:assign\n ${missing.slice(0, 10).join('\n ')}`); + process.exit(1); + } + console.log('All tasks have ids.'); + return; + } + const { text, added } = assignTaskIds(original); + if (added) fs.writeFileSync(file, text); + console.log(added ? `Added ${added} task id(s) to config.json.` : 'Nothing to do: every task already has an id.'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); From 94de7306fdef4feeb7e39230808af36ac59af2a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:43:07 +0000 Subject: [PATCH 2/7] feat(identity): match saved data, imports, backups and dependencies by permanent task id Step 1 of Phase 1. Renaming a starter task in config.json no longer orphans anyone's progress. - js/taskIdentity.js (pure): stampTaskIdentity() records _configName and guarantees a unique, safe id at boot (deterministic fallback for custom configs without ids); findConfigTask() matches saved rows by id first, then by _configName or the task's `aliases` (former names) inside the saved department; findBlockerTask() does the same for dependencies. - applySavedTasks (the one merge point: storage load, import, backup restore, undo, sync) and validateImportedState now share that matcher, so the import review can no longer promise a match the import won't make (it previously also matched by display name). - A name the user never edited follows config.json's current wording; an edited name is kept. Restoring an unedited row puts the starter name back. - Dependencies: blockedBy gains taskId (configName still written and read); moved/renamed blockers keep blocking. - Export schema v4 adds id per task. v2 and v3 files (no ids) still import. - localStorage, undo snapshots, showcase and backups carry the id. - e2e canary payloads include hostile id/taskId values. Verified: failing tests first; mutation-checked the matcher (id, alias and department scoping), storage writer, export payload and dependency writer. A real-browser test rewrites config.json mid-test and reloads. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GXAgt2SAabUwyuTA9TbMsr --- CHANGELOG.md | 1 + CONTRIBUTING.md | 12 + README.md | 6 +- docs/architecture/code-glossary.md | 8 +- .../explain-the-code-practice-guide.md | 8 +- .../problem-algorithm-pseudocode-flowchart.md | 5 +- docs/architecture/site-walkthrough.md | 4 +- e2e/data-safety.spec.js | 2 +- e2e/helpers/security.js | 6 +- e2e/task-identity.spec.js | 79 +++++++ js/__tests__/data-safety.test.js | 6 +- js/__tests__/dependency-identity.test.js | 51 ++++ js/__tests__/identity-roundtrip.test.js | 107 +++++++++ js/__tests__/stateSchema.test.js | 47 ++++ js/__tests__/task-identity.test.js | 218 ++++++++++++++++++ js/app.js | 11 +- js/io.js | 3 +- js/normalize.js | 24 +- js/showcase.js | 2 +- js/stateSchema.js | 11 +- js/storage.js | 5 +- js/taskIdentity.js | 71 ++++++ js/views/tracking.js | 18 +- 23 files changed, 654 insertions(+), 51 deletions(-) create mode 100644 e2e/task-identity.spec.js create mode 100644 js/__tests__/dependency-identity.test.js create mode 100644 js/__tests__/identity-roundtrip.test.js create mode 100644 js/__tests__/task-identity.test.js create mode 100644 js/taskIdentity.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d291114..fb40914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Work orders without a timestamp no longer display the text "Invalid Date". ### Added +- **Permanent task ids.** Every starter task in `config.json` now has an id such as `maintenance-014`, assigned once by `npm run ids:assign` (it only adds missing ids, never changes one). Saved progress, imports, backups, undo and task dependencies match by id first, then by starter name, then by a task's `aliases` (former names), so rewording a task in `config.json` no longer orphans anyone's progress. A task whose name you never edited follows the new wording; one you renamed keeps your name. Export schema is now **v4** (adds `id` per task and `taskId` on dependencies); v2 and v3 files still import. - **Live demo** at `?demo=1`: a fictional company with gaps, an overloaded teammate, blocked work, and work orders in every stage, with a permanent "Fictional data only" banner, one-click reset, isolated storage (`pm-ops-demo-*` keys), and Team Sync disabled. Deployed to GitHub Pages by `.github/workflows/pages.yml` after the production build passes its own browser tests. - **In-product privacy messaging**: a Portfolio notice, a *Data & Privacy* dialog, a first-run note, a once-per-session confirmation before exporting resident data, and honest *Beta · trusted-team sync* labelling for Team Sync. - Playwright browser suite (security fuzzing, CSV, data safety, privacy, demo, layout, smoke, and built-site tests) running in CI; Dependabot and a weekly production-dependency audit. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c38dda..bf6eb2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,18 +68,30 @@ pm-ops-map/ │ ├── launchPlan.js Beginner setup dashboard and readiness checks │ ├── handbook.js Markdown handbook export │ ├── normalize.js Sanitizers for every path that loads outside data +│ ├── taskIdentity.js Permanent task ids: stamping at boot and matching saved rows to tasks │ ├── privacy.js Data-handling guidance and export confirmations │ ├── demoMode.js ?demo=1 detection and storage-key namespacing │ ├── demo.js Hosted demo: seeding, banner, reset │ ├── showcase.js The fictional workspace the demo loads │ ├── views/ Tracking, map, team, portfolio, and work order screens │ └── __tests__/ Vitest unit tests — they import the shipped ES modules directly +├── scripts/ Maintenance scripts (assign-task-ids.mjs) ├── e2e/ Playwright browser tests (security regression suite) └── server/ Optional sync server — separate package.json, own deps, own tests ``` Starter operating data lives in `config.json`. Runtime state is loaded by `app.js`, kept in `state.js`, and persisted by `storage.js`. Feature rendering lives in the relevant `js/views/` module. +## Editing `config.json` tasks + +Every task has a permanent `id` (`-`, e.g. `maintenance-014`). Saved data, imports and backups find a task by that id, so its visible `name` is free to change. + +- **Adding a task:** write `{ "name": "…", "owner": "UNOWNED" }` on its own line, then run `npm run ids:assign`. It adds ids to tasks that lack one and never changes an existing id. A test fails if any task has no id or two share one. +- **Rewording a task:** change `name` only — never the `id`. Also list the old wording in `"aliases": ["old name"]` so data saved *before ids existed* (and old exports) still finds the task. Data saved since ids shipped follows the id on its own. +- **Moving a task to another department:** keep its `id`; progress follows it. +- **Removing a task:** delete the line. Do not reuse its id for a different task. +- Keep each task on a single line; the id script refuses a layout it can't edit safely. + ## Security Conventions PM Ops Map renders data it did not create — imported JSON, pasted clipboard text, Team Sync snapshots, restored backups — so every contribution follows these rules: diff --git a/README.md b/README.md index eeff3ce..2e55cbe 100644 --- a/README.md +++ b/README.md @@ -375,7 +375,7 @@ Edit `config.json` in the project root. No JavaScript needed. } ``` -Add a new department the same way, using a new `id`. +Add a new department the same way, using a new `id`. After adding tasks, run `npm run ids:assign` — every task carries a permanent id (like `leasing-006`) that keeps saved progress attached even if you reword the task later. See [CONTRIBUTING](CONTRIBUTING.md#editing-configjson-tasks). ### Fork and brand it @@ -419,6 +419,7 @@ pm-ops-map/ │ ├── handbook.js Markdown + HTML operations handbook generator │ ├── stateSchema.js Versioned JSON export/import schema and validation │ ├── normalize.js Boundary sanitizers for imports, sync, backups, and storage loads +│ ├── taskIdentity.js Permanent task ids: boot stamping and saved-row → task matching │ ├── privacy.js Data-handling guidance, Data & Privacy dialog, export confirmations │ ├── demoMode.js ?demo=1 detection and storage-key namespacing (no dependencies) │ ├── demo.js Hosted demo: seeding, banner, reset, sync disabled @@ -437,6 +438,7 @@ pm-ops-map/ │ ├── utils.test.js Utility function tests │ ├── stateSchema.test.js │ └── templates.test.js +├── scripts/ assign-task-ids.mjs — adds permanent ids to new config.json tasks ├── e2e/ Playwright browser tests, including the XSS regression suite ├── server/ Optional self-hosted sync server (own package.json, tests, Docker) — see server/README.md ├── docker-compose.yml Runs the sync server locally with durable storage @@ -470,7 +472,7 @@ pm-ops-map/ ### Data flow -`config.json` is fetched on load. `app.js` stamps each task with a stable `_configName` identity key and passes the result to `state.js`. Every module imports from `state.js`. UI edits mutate in-memory objects; `storage.js` persists them to `localStorage` after each change. Nothing goes to a server unless `sync.js` is connected to a Team Sync workspace. +`config.json` is fetched on load. `app.js` stamps each task with its starter name (`_configName`), makes sure it has a permanent `id`, and passes the result to `state.js`; saved data is matched back by `id` first (see `js/taskIdentity.js`). Every module imports from `state.js`. UI edits mutate in-memory objects; `storage.js` persists them to `localStorage` after each change. Nothing goes to a server unless `sync.js` is connected to a Team Sync workspace. ### Module graph (no circular dependencies) diff --git a/docs/architecture/code-glossary.md b/docs/architecture/code-glossary.md index 898e945..7113d57 100644 --- a/docs/architecture/code-glossary.md +++ b/docs/architecture/code-glossary.md @@ -27,7 +27,7 @@ The composition root and browser entry point. |---|---| | `switchView(view, tabEl)` | Activates one view panel, updates the tab state, clears tracking-only UI when leaving it, and renders the destination view. | | `initApp()` | Hydrates every persisted collection, renders initial screens, applies preferences, starts sync/notifications, and decides whether to show onboarding. | -| `bootstrapWithConfig(config)` | Loads configuration into shared state and stamps task `_configName` values before initialization. | +| `bootstrapWithConfig(config)` | Loads configuration into shared state and stamps each task's `_configName` and permanent `id` (`stampTaskIdentity`) before initialization. | | `Object.assign(window, {...})` | Adapter between ES modules and inline/generated HTML handlers. Missing entries cause otherwise-visible controls to fail at click time. | ### `js/state.js` @@ -58,7 +58,7 @@ Owns browser persistence. |---|---| | `STORAGE_KEY` and sibling constants | Namespaces for task, company, profile, team, work-order, portfolio, audit, backup, and sync records. | | `saveToStorage()` | Saves editable task fields, excluding presentation-only configuration where possible. | -| `loadFromStorage()` | Reconciles saved task edits with configured tasks using `_configName`; bounds and validates restored values. | +| `loadFromStorage()` | Reconciles saved task edits with configured tasks by `id`, then `_configName`/aliases (`findConfigTask`); bounds and validates restored values. | | `saveTeamData()` / `loadTeamData()` | Persists the roster or seeds it from configuration. | | `saveWorkOrders()` / `loadWorkOrders()` | Persists the maintenance collection. | | `savePortfolio()` / `loadPortfolio()` | Persists properties, tenants, and vendors with safe empty-array fallback. | @@ -289,7 +289,7 @@ Department id, name, color, tasks[] Task - _configName, name, owner, status, priority, + id, _configName, name, owner, status, priority, dueDate, blockedBy, notes, customFields Employee @@ -330,7 +330,7 @@ File import, clipboard paste, and sync conflicts share `validateImportedState` a ### Stable config identity -`_configName` preserves the task’s original configured name so visible renames do not break reload/import matching. A future schema would be stronger with explicit immutable task IDs in `config.json`. +Every task in `config.json` has a permanent `id` (`maintenance-014`). Saved rows are matched to tasks by `id` first, then by `_configName` (the starter name the row was saved under) or one of the task's `aliases` (former starter names, listed in `config.json` when a task is reworded) — see `js/taskIdentity.js`. Because the id never changes, rewording a starter task does not orphan saved progress, and a task can move departments. A name the user never edited follows config's current wording; an edited name is kept. ### Tests import the shipped modules diff --git a/docs/architecture/explain-the-code-practice-guide.md b/docs/architecture/explain-the-code-practice-guide.md index 0ab4b07..acd56e0 100644 --- a/docs/architecture/explain-the-code-practice-guide.md +++ b/docs/architecture/explain-the-code-practice-guide.md @@ -19,7 +19,7 @@ Use this sequence while sharing the repository: 1. **Lead with the operator problem.** Show the README problem statement. Explain that transaction systems can record events without creating a complete responsibility map. 2. **Show the evidence.** Point to the screenshots and `config.json`: 17 departments and 262 real starter responsibilities, not a blank task-board demo. 3. **Open the composition root.** In [`js/app.js`](../../js/app.js), show configuration bootstrap, initialization order, and `window` handler binding. -4. **Explain state and persistence.** In [`js/state.js`](../../js/state.js) and [`js/storage.js`](../../js/storage.js), describe shared objects, local-first saves, stable `_configName` matching, backups, and the mutate/persist/rerender pattern. +4. **Explain state and persistence.** In [`js/state.js`](../../js/state.js) and [`js/storage.js`](../../js/storage.js), describe shared objects, local-first saves, permanent task-id matching (with `_configName`/alias fallback), backups, and the mutate/persist/rerender pattern. 5. **Explain one algorithm well.** In [`runAutoAssign`](../../js/views/team.js), trace affinity filtering, least-loaded selection, immediate workload increments, fallback, backup, undo, audit, and refresh. 6. **Show product depth without listing everything.** Use Tracking, Work Orders, and Portfolio as three connected examples: responsibility, execution, and operating context. 7. **Explain sync honestly.** Show [`js/sync.js`](../../js/sync.js) and [`server/src/store.js`](../../server/src/store.js). Call it optimistic whole-snapshot sync for trusted teams, not real-time enterprise collaboration. @@ -45,7 +45,7 @@ Why this works: it names the user, pain, workflow, and proof instead of saying **Strong answer:** -> `index.html` loads `js/app.js`. On DOM ready, source/hosted builds fetch `config.json`; production builds can use config injected by Webpack so the downloaded build works over `file://`. `bootstrapWithConfig` sets shared configuration and gives each task a stable `_configName`. `initApp` loads team, work orders, portfolio, audit log, and saved task edits before rendering. Then it initializes guidance, stats, filters, preferences, notifications, and optional sync. The order prevents a first render with stale defaults followed by a visible state jump. +> `index.html` loads `js/app.js`. On DOM ready, source/hosted builds fetch `config.json`; production builds can use config injected by Webpack so the downloaded build works over `file://`. `bootstrapWithConfig` sets shared configuration and gives each task a permanent `id` and its starter name (`_configName`). `initApp` loads team, work orders, portfolio, audit log, and saved task edits before rendering. Then it initializes guidance, stats, filters, preferences, notifications, and optional sync. The order prevents a first render with stale defaults followed by a visible state jump. ### “How does auto-assignment work?” @@ -63,7 +63,7 @@ Why this works: it names the user, pain, workflow, and proof instead of saying **Strong answer:** -> During bootstrap, each configured task gets `_configName` equal to its original starter name. The displayed `name` can change, but persistence and import matching use `_configName` to reconnect saved fields to the current configured task. It solves the immediate rename problem. A stronger future design would add explicit immutable task IDs to `config.json`, because an original name is still a derived identity. +> Every configured task has a permanent `id` in `config.json`, and bootstrap also records its starter name as `_configName`. The displayed `name` can change, but persistence and import matching use the `id` first (then `_configName` or an alias, for data saved before ids existed) to reconnect saved fields to the current configured task. Because the id is not derived from the name, rewording a starter task in a later release doesn't orphan anyone's progress. ### “How do you avoid losing data?” @@ -112,7 +112,7 @@ Answer each in 60–90 seconds without reading the sample answers. 5. How does a task edit travel from a click to durable storage and back to the screen? 6. Why do the tests import the shipped modules directly instead of keeping test-only copies? 7. What happens if two teammates push at the same time? -8. What failure does `_configName` prevent, and what weakness remains? +8. What failure do permanent task ids prevent, and why is `_configName` kept as a fallback? 9. Which user inputs are security-sensitive at render time? 10. What would break first if the app grew to hundreds of simultaneous users? 11. Which claims can you prove from tests or screenshots? diff --git a/docs/architecture/problem-algorithm-pseudocode-flowchart.md b/docs/architecture/problem-algorithm-pseudocode-flowchart.md index a6abb3d..4383665 100644 --- a/docs/architecture/problem-algorithm-pseudocode-flowchart.md +++ b/docs/architecture/problem-algorithm-pseudocode-flowchart.md @@ -160,7 +160,7 @@ flowchart TD Auto-assignment only works because the app first reconciles three layers of state: 1. [`config.json`](../../config.json) defines the current supported departments and starter tasks. -2. [`js/app.js`](../../js/app.js) stamps every task with its original name as `_configName`. +2. [`js/app.js`](../../js/app.js) stamps every task with its original name as `_configName` and guarantees a permanent `id` (`js/taskIdentity.js`). 3. [`js/storage.js`](../../js/storage.js) overlays compatible saved fields onto those current task objects. That approach avoids blindly trusting an old serialized copy of the entire configuration. New code can ship updated display structure while compatible user edits are restored by stable task identity. @@ -171,12 +171,13 @@ FUNCTION startApplication(config, browserStorage): FOR EACH configured task: task._configName = task.name + task.id = task.id or deterministic fallback load team, work orders, portfolio, and audit log FOR EACH saved department that still exists: FOR EACH saved task: - match current task by saved _configName + match current task by saved id, else _configName / alias IF matched: copy bounded, valid editable fields diff --git a/docs/architecture/site-walkthrough.md b/docs/architecture/site-walkthrough.md index 4f4a002..873a545 100644 --- a/docs/architecture/site-walkthrough.md +++ b/docs/architecture/site-walkthrough.md @@ -52,7 +52,7 @@ On `DOMContentLoaded`, it chooses one of two configuration paths: `bootstrapWithConfig` then: 1. places `orgData`, `defaultAffinities`, and `ownerColors` in [`js/state.js`](../../js/state.js); -2. stamps every starter task with `_configName`, a stable identity that survives user-visible renames; and +2. gives every starter task a permanent `id` and records its starter name as `_configName`, so saved progress survives both user renames and later rewording in `config.json`; and 3. calls `initApp`. `initApp` deliberately hydrates saved data before rendering. Team, work orders, portfolio, audit entries, and task edits are loaded first; only then are Tracking, Launch Plan, stats, filters, legend, portfolio, notifications, and optional sync initialized. A returning user gets the saved company name. A first-time user gets onboarding. @@ -77,7 +77,7 @@ The app uses shared mutable objects rather than Redux, a framework store, or an [`js/storage.js`](../../js/storage.js) is the browser persistence boundary. It owns the `localStorage` keys, reloads saved task state into the current configuration, persists team/work-order/portfolio/audit changes, and manages five rolling backup snapshots. -Task restoration is identity-aware. The visible task name is editable, so a saved task is matched back to the current starter task with `_configName`, not only the displayed name. Unsupported values are normalized: status and priority must be in their allowed cycles, note and custom-field sizes are capped, and malformed collections fall back safely. +Task restoration is identity-aware. The visible task name is editable, so a saved task is matched back to the current starter task by permanent `id` (then `_configName` or an alias), not the displayed name. Unsupported values are normalized: status and priority must be in their allowed cycles, note and custom-field sizes are capped, and malformed collections fall back safely. Before risky bulk replacements—imports, auto-assignment, and remote sync—the app records a backup. JSON import also has a one-step in-memory undo path in [`js/io.js`](../../js/io.js). diff --git a/e2e/data-safety.spec.js b/e2e/data-safety.spec.js index ba0c2d9..a54adb0 100644 --- a/e2e/data-safety.spec.js +++ b/e2e/data-safety.spec.js @@ -45,7 +45,7 @@ test('notes and custom fields survive export -> reset -> import', async ({ page const [download] = await Promise.all([page.waitForEvent('download'), call(page, 'exportJSON')]); const exported = writeTemp('export.json', fs.readFileSync(await download.path())); const file = JSON.parse(fs.readFileSync(exported, 'utf8')); - expect(file.schemaVersion).toBe(3); + expect(file.schemaVersion).toBe(4); const first = file.departments.find(d => d.id === 'leasing').tasks[0]; expect(first.notes).toBe(NOTE); expect(first.customFields).toEqual({ 'Gate code': '4471' }); diff --git a/e2e/helpers/security.js b/e2e/helpers/security.js index f264cff..35ad2d6 100644 --- a/e2e/helpers/security.js +++ b/e2e/helpers/security.js @@ -44,15 +44,15 @@ export function buildEvilWorkspace() { name: 'Leasing', tasks: [ { - _configName: t0.name, name: evil('task.name'), owner: evil('emp.name'), + id: evil('task.id'), _configName: t0.name, name: evil('task.name'), owner: evil('emp.name'), status: evil('task.status'), priority: evil('task.priority'), dueDate: evil('task.due'), - blockedBy: { deptId: evil('dep.dept'), configName: evil('dep.cfg'), name: evil('dep.name') }, + blockedBy: { deptId: evil('dep.dept'), taskId: evil('dep.taskId'), configName: evil('dep.cfg'), name: evil('dep.name') }, }, { _configName: t1.name, name: t1.name, owner: evil('emp.name'), status: 'blocked', priority: 'high', dueDate: '2020-01-01', - blockedBy: { deptId: 'leasing', configName: t0.name, name: evil('dep.ok.name') }, + blockedBy: { deptId: 'leasing', taskId: evil('dep.ok.taskId'), configName: t0.name, name: evil('dep.ok.name') }, }, ], }, diff --git a/e2e/task-identity.spec.js b/e2e/task-identity.spec.js new file mode 100644 index 0000000..c3b3c3a --- /dev/null +++ b/e2e/task-identity.spec.js @@ -0,0 +1,79 @@ +// Permanent task ids in a real browser: rewording a starter task in config.json +// must not orphan anyone's saved progress. The app fetches config.json at boot, so +// the "new release" is simulated by intercepting that request. +import { test, expect } from '@playwright/test'; +import { prepare, call, config } from './helpers/security.js'; + +const REWORDED = 'Create listings and syndicate them to every platform'; +const first = config.orgData.departments[0].tasks[0]; + +const firstLeasingRow = page => page.locator('.department[data-id="leasing"] .task-item').first(); + +async function serveConfig(page, mutate) { + const modified = structuredClone(config); + mutate(modified.orgData.departments[0].tasks[0]); + await page.route('**/config.json', route => route.fulfill({ json: modified })); +} + +test.beforeEach(async ({ page }) => { + await prepare(page); + await page.goto('/index.html'); + await page.waitForSelector('#departments .department'); +}); + +test('config ships ids and the app persists them with saved progress', async ({ page }) => { + expect(first.id).toBe('leasing-001'); + await call(page, 'setTaskOwner', 'leasing', 0, 'Maria'); + const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('pm-ops-data-v1'))); + expect(saved.find(d => d.id === 'leasing').tasks[0]).toMatchObject({ id: 'leasing-001', owner: 'Maria', _configName: first.name }); +}); + +test('progress survives the starter task being reworded in config.json', async ({ page }) => { + await call(page, 'setTaskOwner', 'leasing', 0, 'Maria'); + await call(page, 'cycleTaskStatus', 'leasing', 0); + await expect(firstLeasingRow(page).locator('.task-owner')).toContainText('Maria'); + + await serveConfig(page, t => { t.name = REWORDED; }); + await page.reload(); + await page.waitForSelector('#departments .department'); + + const row = firstLeasingRow(page); + await expect(row.locator('.task-name')).toHaveText(REWORDED); + await expect(row.locator('.task-owner')).toContainText('Maria'); + await expect(row).not.toHaveAttribute('data-status', 'todo'); +}); + +test('data saved before ids existed follows the task through a reword when the old name is an alias', async ({ page }) => { + const legacy = [{ id: 'leasing', tasks: [{ _configName: first.name, name: first.name, owner: 'Maria', status: 'done', priority: 'high' }] }]; + await page.evaluate(v => localStorage.setItem('pm-ops-data-v1', JSON.stringify(v)), legacy); + + await serveConfig(page, t => { t.name = REWORDED; t.aliases = [first.name]; }); + await page.reload(); + await page.waitForSelector('#departments .department'); + + const row = firstLeasingRow(page); + await expect(row.locator('.task-name')).toHaveText(REWORDED); + await expect(row.locator('.task-owner')).toContainText('Maria'); + await expect(row).toHaveAttribute('data-status', 'done'); +}); + +test('without an alias, old-format data for a reworded task is left alone rather than misapplied', async ({ page }) => { + const legacy = [{ id: 'leasing', tasks: [{ _configName: first.name, name: first.name, owner: 'Maria', status: 'done' }] }]; + await page.evaluate(v => localStorage.setItem('pm-ops-data-v1', JSON.stringify(v)), legacy); + await serveConfig(page, t => { t.name = REWORDED; }); + await page.reload(); + await page.waitForSelector('#departments .department'); + await expect(firstLeasingRow(page).locator('.task-owner')).not.toContainText('Maria'); +}); + +test('an export from this version carries ids (schema v4) and re-imports onto the same tasks', async ({ page }) => { + await call(page, 'setTaskOwner', 'leasing', 0, 'Maria'); + const [download] = await Promise.all([page.waitForEvent('download'), call(page, 'exportJSON')]); + const fs = await import('node:fs'); + const file = JSON.parse(fs.readFileSync(await download.path(), 'utf8')); + expect(file.schemaVersion).toBe(4); + const rows = file.departments.flatMap(d => d.tasks); + expect(rows).toHaveLength(262); + expect(new Set(rows.map(r => r.id)).size).toBe(262); + expect(file.departments.find(d => d.id === 'leasing').tasks[0]).toMatchObject({ id: 'leasing-001', owner: 'Maria' }); +}); diff --git a/js/__tests__/data-safety.test.js b/js/__tests__/data-safety.test.js index c8ffba5..1834d1c 100644 --- a/js/__tests__/data-safety.test.js +++ b/js/__tests__/data-safety.test.js @@ -40,12 +40,12 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()); describe('1. notes and custom fields travel with the workspace', () => { - test('the payload carries notes and customFields, at schema v3', () => { + test('the payload carries notes and customFields (schema v3 and later)', () => { task().notes = 'Call the owner before listing'; task().customFields = { 'PO #': '4471', Unit: '2B' }; const payload = payloadFromState(); - expect(STATE_SCHEMA_VERSION).toBe(3); - expect(payload.schemaVersion).toBe(3); + expect(STATE_SCHEMA_VERSION).toBeGreaterThanOrEqual(3); + expect(payload.schemaVersion).toBe(STATE_SCHEMA_VERSION); expect(payload.departments[0].tasks[0]).toMatchObject({ notes: 'Call the owner before listing', customFields: { 'PO #': '4471', Unit: '2B' }, diff --git a/js/__tests__/dependency-identity.test.js b/js/__tests__/dependency-identity.test.js new file mode 100644 index 0000000..5b6e261 --- /dev/null +++ b/js/__tests__/dependency-identity.test.js @@ -0,0 +1,51 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import { setOrgData, setOwnerColors, setDefaultAffinities, orgData } from '../state.js'; +import { setTaskDependency, isDependencyBlocking } from '../views/tracking.js'; +import { stampTaskIdentity } from '../taskIdentity.js'; + +function load() { + document.body.innerHTML = ''; + localStorage.clear(); + setOwnerColors({}); + setDefaultAffinities({}); + setOrgData({ departments: stampTaskIdentity([ + { id: 'leasing', name: 'Leasing', color: '#1976d2', tasks: [ + { id: 'leasing-001', name: 'List units', owner: 'Maria', status: 'in-progress' }, + { id: 'leasing-002', name: 'Show units', owner: 'Maria', status: 'todo' }, + ] }, + { id: 'accounting', name: 'Accounting', color: '#2e7d32', tasks: [{ id: 'accounting-001', name: 'Reconcile', owner: 'Sam', status: 'todo' }] }, + ]) }); +} + +beforeEach(load); + +describe('task dependencies', () => { + test('a new dependency records the blocker\'s permanent id and still writes the legacy configName', () => { + const [leasing] = orgData.departments; + setTaskDependency('leasing', 1, 'leasing', leasing.tasks[0], null); + expect(leasing.tasks[1].blockedBy).toEqual({ deptId: 'leasing', taskId: 'leasing-001', configName: 'List units', name: 'List units' }); + }); + + test('a dependency keeps blocking after the blocker is renamed or moved to another department', () => { + const [leasing, accounting] = orgData.departments; + setTaskDependency('accounting', 0, 'leasing', leasing.tasks[0], null); + const blocked = accounting.tasks[0]; + expect(isDependencyBlocking(blocked)).toBe(true); + + leasing.tasks[0].name = 'Renamed in config'; leasing.tasks[0]._configName = 'Renamed in config'; + expect(isDependencyBlocking(blocked)).toBe(true); + + accounting.tasks.push(leasing.tasks.shift()); // blocker moved departments; blockedBy.deptId is now stale + expect(isDependencyBlocking(blocked)).toBe(true); + accounting.tasks.find(t => t.id === 'leasing-001').status = 'done'; + expect(isDependencyBlocking(blocked)).toBe(false); + }); + + test('a dependency saved by an older version (configName only) still resolves', () => { + const [leasing] = orgData.departments; + leasing.tasks[1].blockedBy = { deptId: 'leasing', configName: 'List units', name: 'List units' }; + expect(isDependencyBlocking(leasing.tasks[1])).toBe(true); + leasing.tasks[0].status = 'done'; + expect(isDependencyBlocking(leasing.tasks[1])).toBe(false); + }); +}); diff --git a/js/__tests__/identity-roundtrip.test.js b/js/__tests__/identity-roundtrip.test.js new file mode 100644 index 0000000..a04a657 --- /dev/null +++ b/js/__tests__/identity-roundtrip.test.js @@ -0,0 +1,107 @@ +// "Renaming a task in config.json no longer orphans anyone's progress." +// Each test saves progress under one version of config.json, rewords a starter +// task (what a future release might do), boots the NEW config, and checks the +// progress is still attached — through every path that persists or moves data. +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import config from '../../config.json'; +import * as state from '../state.js'; +import * as storage from '../storage.js'; +import { _applyImportedState, _saveUndoSnapshot, undoLastAction } from '../io.js'; +import { buildStatePayload } from '../stateSchema.js'; +import { stampTaskIdentity } from '../taskIdentity.js'; + +const html = fs.readFileSync(path.join(process.cwd(), 'index.html'), 'utf8'); +const REWORDED = 'Create listings and syndicate them to every platform'; +const OLD_NAME = config.orgData.departments[0].tasks[0].name; + +function bootConfig({ rename = false, alias = false } = {}) { + const orgData = structuredClone(config.orgData); + if (rename) { + orgData.departments[0].tasks[0].name = REWORDED; + if (alias) orgData.departments[0].tasks[0].aliases = [OLD_NAME]; + } + stampTaskIdentity(orgData.departments); + state.setOrgData(orgData); + state.setOwnerColors(config.ownerColors); + state.setDefaultAffinities(config.defaultAffinities); + state.setTeamData({ employees: [{ name: 'Maria', hex: '#336699', affinities: ['leasing'] }] }); + state.setWorkOrders([]); + state.setPortfolio({ properties: [], tenants: [], vendors: [] }); + state.setAuditLog([]); + return orgData.departments[0].tasks[0]; +} + +const progress = t => ({ owner: t.owner, status: t.status, priority: t.priority, notes: t.notes }); +const MADE = { owner: 'Maria', status: 'in-progress', priority: 'high', notes: 'waiting on photos' }; + +beforeEach(() => { + document.body.innerHTML = html.match(/]*>([\s\S]*)<\/body>/)[1]; + localStorage.clear(); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +describe('progress survives a reworded starter task', () => { + test('localStorage: saved by this version, loaded by a config that reworded the task', () => { + Object.assign(bootConfig(), MADE); + storage.saveToStorage(); + expect(JSON.parse(localStorage.getItem(storage.STORAGE_KEY))[0].tasks[0].id).toBe('leasing-001'); + + const reloaded = bootConfig({ rename: true }); + storage.loadFromStorage(); + expect(progress(reloaded)).toEqual(MADE); + expect(reloaded.name).toBe(REWORDED); // never edited by the user, so it shows the new wording + }); + + test('localStorage written BEFORE ids existed loads through the alias table', () => { + // Exactly what v1.0 wrote: no id field. + localStorage.setItem(storage.STORAGE_KEY, JSON.stringify([{ + id: 'leasing', tasks: [{ _configName: OLD_NAME, name: OLD_NAME, owner: 'Maria', status: 'in-progress', priority: 'high', notes: 'waiting on photos' }], + }])); + const reloaded = bootConfig({ rename: true, alias: true }); + storage.loadFromStorage(); + expect(progress(reloaded)).toEqual(MADE); + }); + + test('JSON export from this version, imported after the reword', () => { + Object.assign(bootConfig(), MADE); + const file = JSON.parse(JSON.stringify(buildStatePayload({ + company: 'Co', departments: state.orgData.departments, team: state.teamData, + workOrders: [], portfolio: { properties: [], tenants: [], vendors: [] }, + }))); + const reloaded = bootConfig({ rename: true }); + _applyImportedState(file); + expect(progress(reloaded)).toEqual(MADE); + }); + + test('automatic backup, restored after the reword', () => { + vi.useFakeTimers(); + vi.spyOn(storage.pageControl, 'reload').mockImplementation(() => {}); + vi.stubGlobal('confirm', () => true); + Object.assign(bootConfig(), MADE); + storage.saveBackupSnapshot('before reword'); + const reloaded = bootConfig({ rename: true }); + storage.restoreBackupSnapshot(0); + vi.useRealTimers(); + expect(progress(reloaded)).toEqual(MADE); + }); + + test('undo snapshot restores onto the same task', () => { + const t = bootConfig(); + Object.assign(t, MADE); + _saveUndoSnapshot(); + t.owner = 'UNOWNED'; t.status = 'todo'; t.notes = null; + undoLastAction(); + expect(progress(t)).toEqual(MADE); + }); + + test('control: without ids or an alias, the reword would orphan the data (this is the bug ids fix)', () => { + localStorage.setItem(storage.STORAGE_KEY, JSON.stringify([{ + id: 'leasing', tasks: [{ _configName: OLD_NAME, name: OLD_NAME, owner: 'Maria', status: 'done' }], + }])); + const reloaded = bootConfig({ rename: true }); + storage.loadFromStorage(); + expect(reloaded.owner).toBe('UNOWNED'); + }); +}); diff --git a/js/__tests__/stateSchema.test.js b/js/__tests__/stateSchema.test.js index a037ddc..aa32303 100644 --- a/js/__tests__/stateSchema.test.js +++ b/js/__tests__/stateSchema.test.js @@ -6,6 +6,7 @@ import { validateImportedState, formatImportReport, } from '../stateSchema.js'; +import { stampTaskIdentity } from '../taskIdentity.js'; const { orgData } = config; @@ -89,3 +90,49 @@ describe('state schema', () => { expect(report.warnings.length).toBeGreaterThan(0); }); }); + +describe('schema v4: permanent task ids', () => { + const stamp = () => stampTaskIdentity(cloneOrgData().departments); + const payloadOf = departments => buildStatePayload({ + company: 'Test PM', departments, team: { employees: [] }, workOrders: [], + portfolio: { properties: [], tenants: [], vendors: [] }, exportedAt: '2026-05-19T00:00:00.000Z', + }); + + test('exports are schema v4 and every task row carries its id', () => { + const payload = payloadOf(stamp()); + expect(payload.schemaVersion).toBe(4); + const rows = payload.departments.flatMap(d => d.tasks); + expect(rows).toHaveLength(262); + rows.forEach(r => expect(r.id).toMatch(/^[a-z0-9-]+-\d{3}$/)); + }); + + test('a v4 export still matches after the starter task is reworded in config.json', () => { + const exported = payloadOf(stamp()); + exported.departments[0].tasks[0].owner = 'Maria'; + const live = stamp(); + live[0].tasks[0].name = 'Reworded starter task'; + live[0].tasks[0]._configName = 'Reworded starter task'; + const report = validateImportedState(exported, { departments: live }); + expect(report.ok).toBe(true); + expect(report.matchedTasks).toBe(262); + expect(report.skippedTasks).toBe(0); + }); + + test('v2 and v3 files (no ids) still import against the current config', () => { + [2, 3].forEach(version => { + const file = payloadOf(stamp()); + file.schemaVersion = version; + file.departments.forEach(d => d.tasks.forEach(t => { delete t.id; })); + const report = validateImportedState(file, { departments: stamp() }); + expect(report.ok, `v${version}`).toBe(true); + expect(report.matchedTasks, `v${version}`).toBe(262); + }); + }); + + test('a file from a newer schema version warns but the current one does not', () => { + const file = payloadOf(stamp()); + expect(validateImportedState(file, { departments: stamp() }).warnings.join(' ')).not.toMatch(/newer schema/); + file.schemaVersion = 5; + expect(validateImportedState(file, { departments: stamp() }).warnings.join(' ')).toMatch(/newer schema/); + }); +}); diff --git a/js/__tests__/task-identity.test.js b/js/__tests__/task-identity.test.js new file mode 100644 index 0000000..64f3de7 --- /dev/null +++ b/js/__tests__/task-identity.test.js @@ -0,0 +1,218 @@ +import { describe, test, expect } from 'vitest'; +import config from '../../config.json'; +import { stampTaskIdentity, findConfigTask, findBlockerTask } from '../taskIdentity.js'; +import { applySavedTasks, normalizeBlockedBy, normalizeSavedTask } from '../normalize.js'; + +const clone = v => JSON.parse(JSON.stringify(v)); +const EVIL = '">'; + +function depts() { + return stampTaskIdentity(clone([ + { id: 'leasing', tasks: [ + { id: 'leasing-001', name: 'List units', owner: 'UNOWNED' }, + { id: 'leasing-002', name: 'Show units', owner: 'UNOWNED' }, + ] }, + { id: 'vendors', tasks: [{ id: 'vendors-001', name: 'Review owner billable expenses', owner: 'UNOWNED' }] }, + { id: 'accounting', tasks: [{ id: 'accounting-001', name: 'Review owner billable expenses', owner: 'UNOWNED' }] }, + ])); +} + +describe('stampTaskIdentity', () => { + test('records the config name as _configName and keeps config ids', () => { + const d = depts(); + expect(d[0].tasks[0]).toMatchObject({ id: 'leasing-001', _configName: 'List units' }); + }); + + test('gives a deterministic, unique fallback id to tasks from a config without ids', () => { + const make = () => stampTaskIdentity([{ id: 'leasing', tasks: [ + { name: 'List units', owner: 'U' }, { name: 'List units!', owner: 'U' }, { name: 'Show units', owner: 'U' }, + ] }]); + const ids = make()[0].tasks.map(t => t.id); + expect(new Set(ids).size).toBe(3); + ids.forEach(id => expect(id).toMatch(/^leasing-[a-z0-9-]+$/)); + expect(make()[0].tasks.map(t => t.id)).toEqual(ids); // same input -> same ids + }); + + test('repairs unsafe or duplicated ids instead of trusting them', () => { + const d = stampTaskIdentity([{ id: 'leasing', tasks: [ + { id: 'leasing-001', name: 'A', owner: 'U' }, { id: 'leasing-001', name: 'B', owner: 'U' }, { id: EVIL, name: 'C', owner: 'U' }, + ] }]); + const ids = d[0].tasks.map(t => t.id); + expect(new Set(ids).size).toBe(3); + expect(ids[0]).toBe('leasing-001'); + ids.forEach(id => expect(id).toMatch(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/)); + }); + + test('is idempotent', () => { + const d = depts(); + const before = JSON.stringify(d); + stampTaskIdentity(d); + expect(JSON.stringify(d)).toBe(before); + }); + + test('stamps every task of the real config with a unique id', () => { + const real = stampTaskIdentity(clone(config.orgData.departments)); + const ids = real.flatMap(d => d.tasks.map(t => t.id)); + expect(ids).toHaveLength(262); + expect(new Set(ids).size).toBe(262); + expect(real.flatMap(d => d.tasks).every(t => t._configName === t.name)).toBe(true); + }); +}); + +describe('findConfigTask', () => { + test('matches by id first, even when the saved name and _configName are stale', () => { + const d = depts(); + const t = findConfigTask(d, { id: 'leasing-002', _configName: 'Old name', name: 'Whatever' }, { deptId: 'leasing' }); + expect(t).toBe(d[0].tasks[1]); + }); + + test('an id match works across departments (a task moved in config keeps its progress)', () => { + const d = depts(); + expect(findConfigTask(d, { id: 'leasing-001', name: 'x' }, { deptId: 'accounting' })).toBe(d[0].tasks[0]); + }); + + test('falls back to _configName, then to name, inside the saved department', () => { + const d = depts(); + expect(findConfigTask(d, { _configName: 'Show units' }, { deptId: 'leasing' })).toBe(d[0].tasks[1]); + expect(findConfigTask(d, { name: 'Show units' }, { deptId: 'leasing' })).toBe(d[0].tasks[1]); + expect(findConfigTask(d, { _configName: 'Show units' }, { deptId: 'vendors' })).toBeNull(); + }); + + test('falls back to a task\'s aliases (former config names) for data saved before ids existed', () => { + const d = depts(); + d[0].tasks[0].aliases = ['List all units']; + expect(findConfigTask(d, { _configName: 'List all units' }, { deptId: 'leasing' })).toBe(d[0].tasks[0]); + }); + + test('same-named tasks in two departments never collide', () => { + const d = depts(); + expect(findConfigTask(d, { id: 'vendors-001' }, { deptId: 'vendors' })).toBe(d[1].tasks[0]); + expect(findConfigTask(d, { id: 'accounting-001' }, { deptId: 'accounting' })).toBe(d[2].tasks[0]); + expect(findConfigTask(d, { _configName: 'Review owner billable expenses' }, { deptId: 'accounting' })).toBe(d[2].tasks[0]); + }); + + test('an unknown id with no usable legacy key matches nothing; hostile input never throws', () => { + const d = depts(); + expect(findConfigTask(d, { id: 'nope-999' }, { deptId: 'leasing' })).toBeNull(); + expect(findConfigTask(d, { id: EVIL, _configName: EVIL }, { deptId: 'leasing' })).toBeNull(); + expect(findConfigTask(d, null, { deptId: 'leasing' })).toBeNull(); + expect(findConfigTask(d, { id: { toString: 1 } }, { deptId: 'leasing' })).toBeNull(); + }); +}); + +describe('applySavedTasks — renaming a starter task no longer orphans progress', () => { + function liveConfigAfterRename() { + const renamed = clone(config.orgData.departments); + renamed[0].tasks[0].name = 'Create listings and syndicate them to every platform'; // reworded in config.json + return stampTaskIdentity(renamed); + } + const savedFromOldVersion = () => { + const before = stampTaskIdentity(clone(config.orgData.departments)); + const t = before[0].tasks[0]; + return [{ id: before[0].id, tasks: [{ id: t.id, _configName: t._configName, name: t.name, owner: 'Maria', status: 'done', priority: 'high' }] }]; + }; + + test('data saved with an id follows the task through a rename', () => { + const live = liveConfigAfterRename(); + const result = applySavedTasks(live, savedFromOldVersion()); + expect(result).toEqual({ matched: 1, skipped: 0 }); + expect(live[0].tasks[0]).toMatchObject({ owner: 'Maria', status: 'done', priority: 'high' }); + }); + + test('data saved BEFORE ids existed survives a rename when the old name is listed as an alias', () => { + const legacy = savedFromOldVersion(); + delete legacy[0].tasks[0].id; + const withAlias = clone(config.orgData.departments); + withAlias[0].tasks[0].name = 'Create listings and syndicate them to every platform'; + withAlias[0].tasks[0].aliases = [config.orgData.departments[0].tasks[0].name]; + const live = stampTaskIdentity(withAlias); + expect(applySavedTasks(live, legacy)).toEqual({ matched: 1, skipped: 0 }); + expect(live[0].tasks[0].owner).toBe('Maria'); + }); + + test('data saved BEFORE ids existed, with no alias, is reported as skipped rather than misapplied', () => { + const legacy = savedFromOldVersion(); + delete legacy[0].tasks[0].id; + const live = liveConfigAfterRename(); + expect(applySavedTasks(live, legacy)).toEqual({ matched: 0, skipped: 1 }); + expect(live[0].tasks[0].owner).toBe('UNOWNED'); + }); + + test('legacy data (no ids at all) still matches the current config by _configName', () => { + const live = stampTaskIdentity(clone(config.orgData.departments)); + const legacy = savedFromOldVersion(); + delete legacy[0].tasks[0].id; + expect(applySavedTasks(live, legacy)).toEqual({ matched: 1, skipped: 0 }); + }); + + describe('names: unedited rows follow config.json, edited rows keep the user\'s wording', () => { + const liveWith = (name, extra = {}) => stampTaskIdentity([{ id: 'leasing', tasks: [{ id: 'leasing-001', name, owner: 'UNOWNED', ...extra }] }]); + const rowFor = row => [{ id: 'leasing', tasks: [{ id: 'leasing-001', owner: 'Maria', ...row }] }]; + + test('a task whose name was never edited adopts the reworded starter name', () => { + const live = liveWith('Reworded starter name'); + applySavedTasks(live, rowFor({ _configName: 'Old starter name', name: 'Old starter name' })); + expect(live[0].tasks[0]).toMatchObject({ name: 'Reworded starter name', owner: 'Maria' }); + }); + + test('a task the user renamed keeps the user\'s name through a config reword', () => { + const live = liveWith('Reworded starter name'); + applySavedTasks(live, rowFor({ _configName: 'Old starter name', name: 'My own wording' })); + expect(live[0].tasks[0].name).toBe('My own wording'); + }); + + test('restoring an unedited row puts the starter name back over a later custom rename (undo / backup restore)', () => { + const live = liveWith('Starter name'); + live[0].tasks[0].name = 'Renamed after the backup was taken'; + applySavedTasks(live, rowFor({ _configName: 'Starter name', name: 'Starter name' }), { fill: true }); + expect(live[0].tasks[0].name).toBe('Starter name'); + }); + + test('a row with no _configName (very old files) still applies its name as before', () => { + const live = liveWith('Starter name'); + applySavedTasks(live, rowFor({ name: 'Starter name' })); + expect(live[0].tasks[0].name).toBe('Starter name'); + }); + }); + + test('normalizeSavedTask never lets a saved row overwrite the id', () => { + expect(normalizeSavedTask({ id: 'leasing-001', name: 'x', owner: 'y' })).not.toHaveProperty('id'); + }); +}); + +describe('blockedBy accepts the new taskId shape and the old shape', () => { + test('old shape (configName only) is still accepted', () => { + expect(normalizeBlockedBy({ deptId: 'leasing', configName: 'List units', name: 'List units' })) + .toEqual({ deptId: 'leasing', configName: 'List units', name: 'List units' }); + }); + test('new shape (taskId) is accepted with or without configName', () => { + expect(normalizeBlockedBy({ deptId: 'leasing', taskId: 'leasing-001', configName: 'List units', name: 'List units' })) + .toEqual({ deptId: 'leasing', taskId: 'leasing-001', configName: 'List units', name: 'List units' }); + expect(normalizeBlockedBy({ deptId: 'leasing', taskId: 'leasing-001', name: 'List units' })) + .toEqual({ deptId: 'leasing', taskId: 'leasing-001', name: 'List units' }); + }); + test('needs a department and at least one usable key; unsafe ids are dropped', () => { + expect(normalizeBlockedBy({ deptId: 'leasing', name: 'x' })).toBeNull(); + expect(normalizeBlockedBy({ taskId: 'leasing-001', name: 'x' })).toBeNull(); + expect(normalizeBlockedBy({ deptId: 'leasing', taskId: EVIL, configName: 'List units' })).toEqual({ deptId: 'leasing', configName: 'List units', name: 'List units' }); + expect(normalizeBlockedBy({ deptId: 'leasing', taskId: EVIL })).toBeNull(); + }); +}); + +describe('findBlockerTask', () => { + test('resolves by taskId even after the blocker was renamed or moved', () => { + const d = depts(); + d[0].tasks[0].name = 'Renamed since'; + expect(findBlockerTask(d, { deptId: 'accounting', taskId: 'leasing-001', configName: 'List units', name: 'List units' })).toBe(d[0].tasks[0]); + }); + test('resolves old-shape references by configName in their own department', () => { + const d = depts(); + expect(findBlockerTask(d, { deptId: 'accounting', configName: 'Review owner billable expenses', name: 'x' })).toBe(d[2].tasks[0]); + expect(findBlockerTask(d, { deptId: 'vendors', configName: 'Review owner billable expenses', name: 'x' })).toBe(d[1].tasks[0]); + }); + test('returns null for a missing blocker or no reference', () => { + const d = depts(); + expect(findBlockerTask(d, { deptId: 'leasing', configName: 'Gone', name: 'Gone' })).toBeNull(); + expect(findBlockerTask(d, null)).toBeNull(); + }); +}); diff --git a/js/app.js b/js/app.js index e87703d..56ba29a 100644 --- a/js/app.js +++ b/js/app.js @@ -93,6 +93,7 @@ import { initSync, openSyncModal, closeSyncModal, connectSync, disconnectSync, syncNow, } from './sync.js'; import { openPrivacyModal, closePrivacyModal } from './privacy.js'; +import { stampTaskIdentity } from './taskIdentity.js'; import { DEMO_MODE } from './demoMode.js'; import { initDemoMode, seedShowcaseWorkspace, resetDemo } from './demo.js'; @@ -213,12 +214,10 @@ function bootstrapWithConfig(config) { setOrgData(config.orgData); setDefaultAffinities(config.defaultAffinities); setOwnerColors(config.ownerColors); - // Stamp each task with its original config name as a stable identity key. - // User edits can rename tasks later, so this hidden key lets storage.js - // reconnect saved progress to the correct starter task on reload. - config.orgData.departments.forEach(dept => { - dept.tasks.forEach(task => { task._configName = task.name; }); - }); + // Give every task its permanent id and remember its starter name (_configName). + // User edits can rename tasks later; saved progress is reconnected by id first + // (see taskIdentity.js), so reworded starter tasks don't orphan anyone's data. + stampTaskIdentity(config.orgData.departments); initApp(); } diff --git a/js/io.js b/js/io.js index 77f8f62..936ace6 100644 --- a/js/io.js +++ b/js/io.js @@ -26,6 +26,7 @@ export function _saveUndoSnapshot() { setUndoSnapshot(orgData.departments.map(dept => ({ id: dept.id, tasks: dept.tasks.map(t => ({ + id: t.id, _configName: t._configName, name: t.name, owner: t.owner, @@ -58,7 +59,7 @@ export function undoLastAction() { // the same path as a file/clipboard import. export function _applyImportedState(data) { // Nothing from the file reaches shared state unvalidated: tasks are matched to - // config by _configName and field-checked, everything else goes through + // config by id (then _configName) and field-checked, everything else goes through // sanitizeWorkspace (enums, colors, ids, dates, numbers, links, lengths). applySavedTasks(orgData.departments, data.departments); diff --git a/js/normalize.js b/js/normalize.js index f34d250..fa8cc27 100644 --- a/js/normalize.js +++ b/js/normalize.js @@ -12,6 +12,7 @@ // escape it at render time. Pure functions only — no DOM, no shared state. import { STATUS_CYCLE, PRIORITY_CYCLE, WO_STATUS_CYCLE } from './state.js'; import { isValidISODate, normalizeUrl } from './utils.js'; +import { findConfigTask } from './taskIdentity.js'; export const TENANT_STATUSES = ['active', 'applicant', 'notice', 'past']; export const DEFAULT_EMPLOYEE_HEX = '#607d8b'; @@ -275,13 +276,21 @@ export function normalizeTeam(raw, options = {}) { // ── Tasks ───────────────────────────────────────────────────────────────────── +// A dependency points at its blocker by `taskId` (permanent) and/or `configName` (the +// starter name older versions wrote). Either is enough; both are kept when present so +// a file written by this version still works in an older one. export function normalizeBlockedBy(raw) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; const deptId = typeof raw.deptId === 'string' && DEPT_ID_RE.test(raw.deptId) ? raw.deptId : ''; + const taskId = cleanId(raw.taskId); const configName = cleanText(raw.configName, LIMITS.taskName); const name = cleanText(raw.name, LIMITS.taskName); - if (!deptId || !configName) return null; - return { deptId, configName, name: name || configName }; + if (!deptId || (!taskId && !configName)) return null; + const result = { deptId }; + if (taskId) result.taskId = taskId; + if (configName) result.configName = configName; + result.name = name || configName || ''; + return result; } export function normalizeCustomFields(raw) { @@ -323,8 +332,9 @@ export function normalizeSavedTask(saved, { fill = false } = {}) { // Merges saved/imported department data onto the live config departments. This // is the ONE place saved task fields are applied — storage load, backup restore // and file/clipboard/sync import all call it, so they can never disagree about -// which fields survive. Rows are matched to config tasks by their stable -// _configName key. Returns { matched, skipped } counts. +// which fields survive. Rows are matched to config tasks by permanent `id`, then by +// the starter name (`_configName`) or one of the task's `aliases` (see taskIdentity.js). +// Returns { matched, skipped } counts. export function applySavedTasks(departments, savedDepartments, options = {}) { const result = { matched: 0, skipped: 0 }; if (!Array.isArray(savedDepartments)) return result; @@ -334,9 +344,11 @@ export function applySavedTasks(departments, savedDepartments, options = {}) { if (!dept || !Array.isArray(savedDept.tasks)) return; savedDept.tasks.forEach(savedTask => { const patch = normalizeSavedTask(savedTask, options); - const key = savedTask && (savedTask._configName || savedTask.name); - const task = patch && dept.tasks.find(t => t._configName === key); + const task = patch && findConfigTask(departments, savedTask, { deptId: dept.id }); if (!task) { result.skipped++; return; } + // A name the user never edited (it still equals the starter name the row was saved + // under) follows config.json's current wording; an edited name is the user's and stays. + if (task._configName && patch.name === cleanText(savedTask._configName, LIMITS.taskName)) patch.name = task._configName; Object.assign(task, patch); result.matched++; }); diff --git a/js/showcase.js b/js/showcase.js index 7f7df93..846fa70 100644 --- a/js/showcase.js +++ b/js/showcase.js @@ -186,7 +186,7 @@ export function applyShowcaseTasks(departments, employees, now = new Date()) { if (!blocked || !blocker) return; blocker.status = blocker.status === 'done' ? 'in-progress' : blocker.status; // a dependency only matters while it is open blocked.status = 'blocked'; - blocked.blockedBy = { deptId, configName: blocker._configName || blocker.name, name: blocker.name }; + blocked.blockedBy = { deptId, taskId: blocker.id, configName: blocker._configName || blocker.name, name: blocker.name }; }; // Hand-placed storylines. diff --git a/js/stateSchema.js b/js/stateSchema.js index fd7c021..d20ab09 100644 --- a/js/stateSchema.js +++ b/js/stateSchema.js @@ -1,7 +1,9 @@ import { normalizeSavedTask, sanitizeWorkspace } from './normalize.js'; +import { findConfigTask } from './taskIdentity.js'; -// v3 adds per-task notes and customFields to the portable payload (v2 files still import). -export const STATE_SCHEMA_VERSION = 3; +// v3 added per-task notes and customFields. v4 adds each task's permanent `id` and a +// `taskId` on dependencies; v2 and v3 files (no ids) still import by starter name. +export const STATE_SCHEMA_VERSION = 4; export const STATE_SCHEMA_NAME = 'pm-ops-map-state'; export function buildStatePayload({ @@ -22,6 +24,7 @@ export function buildStatePayload({ id: dept.id, name: dept.name, tasks: dept.tasks.map(task => ({ + id: task.id, _configName: task._configName || task.name, name: task.name, owner: task.owner, @@ -95,8 +98,8 @@ export function validateImportedState(data, orgData) { report.invalidTasks++; return; } - const key = savedTask?._configName || savedTask?.name; - const task = dept.tasks.find(item => item._configName === key || item.name === key); + // Same matcher the import itself uses, so the review can't promise a match that won't happen. + const task = findConfigTask(orgData.departments, savedTask, { deptId: dept.id }); if (!task) { report.skippedTasks++; return; diff --git a/js/storage.js b/js/storage.js index 23541c4..6d243f4 100644 --- a/js/storage.js +++ b/js/storage.js @@ -64,6 +64,7 @@ export function saveToStorage() { const payload = orgData.departments.map(dept => ({ id: dept.id, tasks: dept.tasks.map(t => ({ + id: t.id, _configName: t._configName || t.name, name: t.name, owner: t.owner, @@ -83,8 +84,8 @@ export function saveToStorage() { } export function loadFromStorage() { - // PROJECT BEACON: Reconcile saved edits onto current config by _configName; - // visible task names are user-editable and are not durable identity keys. + // PROJECT BEACON: Reconcile saved edits onto current config by permanent task id + // (falling back to _configName); visible task names are user-editable and are not durable identity keys. // Field-level validation lives in normalize.js (applySavedTasks). try { const raw = localStorage.getItem(STORAGE_KEY); diff --git a/js/taskIdentity.js b/js/taskIdentity.js new file mode 100644 index 0000000..a9ee79f --- /dev/null +++ b/js/taskIdentity.js @@ -0,0 +1,71 @@ +// Task identity: how a saved/imported/backed-up task row finds its starter task. +// +// Every starter task in config.json has a permanent `id` (e.g. "maintenance-014"). +// Its visible name can be reworded in config.json — or edited by the user — without +// orphaning anyone's progress, because rows are matched in this order: +// 1. `id` permanent, and valid even if the task moved departments +// 2. `_configName` the starter name an older export/save was written with +// 3. `aliases` former starter names, listed on the task in config.json +// when it is reworded (so data saved before ids existed still lands) +// Steps 2 and 3 only look inside the saved department, because two departments can +// legitimately contain tasks with the same name. Pure functions; no DOM, no shared state. + +// Same character set as the other ids that reach attributes and selectors (see normalize.js). +const TASK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/; + +export function isTaskId(value) { + return typeof value === 'string' && TASK_ID_RE.test(value); +} + +function slugify(name) { + const slug = String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30).replace(/-+$/, ''); + return slug || 'task'; +} + +// Called once at boot, after config.json is loaded. Records each task's starter name +// as `_configName` and makes sure every task has a unique, safe `id`. Tasks from a +// custom config without ids get a deterministic fallback derived from their starter +// name, so the same config always produces the same ids. +export function stampTaskIdentity(departments) { + const used = new Set(); + departments.forEach(dept => dept.tasks.forEach(task => { + if (isTaskId(task.id) && !used.has(task.id)) used.add(task.id); + else task.id = undefined; // missing, unsafe, or a duplicate: assigned below + })); + departments.forEach(dept => dept.tasks.forEach(task => { + task._configName = task.name; + if (task.id !== undefined) return; + const base = `${dept.id}-${slugify(task.name)}`.slice(0, 74); + let id = base; + for (let n = 2; used.has(id); n++) id = `${base}-${n}`; + used.add(id); + task.id = id; + })); + return departments; +} + +function nameMatches(task, key) { + return task._configName === key || (Array.isArray(task.aliases) && task.aliases.includes(key)); +} + +// Finds the live config task a saved row refers to, or null. +// `saved` may carry { id, _configName, name }; `deptId` is the department it was saved under. +export function findConfigTask(departments, saved, { deptId } = {}) { + if (!saved || typeof saved !== 'object') return null; + if (isTaskId(saved.id)) { + for (const dept of departments) { + const hit = dept.tasks.find(t => t.id === saved.id); + if (hit) return hit; + } + } + const key = saved._configName || saved.name; + if (typeof key !== 'string' || !key) return null; + const dept = departments.find(d => d.id === deptId); + return (dept && dept.tasks.find(t => nameMatches(t, key))) || null; +} + +// Resolves a task's `blockedBy` reference ({ deptId, taskId?, configName?, name }) to the blocking task. +export function findBlockerTask(departments, blockedBy) { + if (!blockedBy || typeof blockedBy !== 'object') return null; + return findConfigTask(departments, { id: blockedBy.taskId, _configName: blockedBy.configName }, { deptId: blockedBy.deptId }); +} diff --git a/js/views/tracking.js b/js/views/tracking.js index c80bbd4..08be2ed 100644 --- a/js/views/tracking.js +++ b/js/views/tracking.js @@ -6,6 +6,7 @@ import { getEmployeeHex, getEmployeeNames, } from '../state.js'; import { saveToStorage, logAudit, _showActionToast } from '../storage.js'; +import { findBlockerTask } from '../taskIdentity.js'; import { escapeHtml, isTaskOverdue, isValidISODate, formatDueChip, buildDeptCompletionText, @@ -750,12 +751,11 @@ export function openDependencyPicker(deptId, taskIdx, anchorEl) { matchingTasks.forEach(t => { const btn = document.createElement('button'); btn.className = 'dep-picker-item' + - (task.blockedBy && task.blockedBy.deptId === d.id && task.blockedBy.configName === t._configName - ? ' dep-picker-item--active' : ''); + (findBlockerTask(orgData.departments, task.blockedBy) === t ? ' dep-picker-item--active' : ''); btn.textContent = t.name; btn.addEventListener('click', (e) => { e.stopPropagation(); - setTaskDependency(deptId, taskIdx, d.id, t._configName, t.name, anchorEl); + setTaskDependency(deptId, taskIdx, d.id, t, anchorEl); _closeDependencyPicker(); }); list.appendChild(btn); @@ -796,14 +796,15 @@ function _closeDependencyPicker() { } } -export function setTaskDependency(deptId, taskIdx, depDeptId, depConfigName, depName, anchorEl) { +export function setTaskDependency(deptId, taskIdx, depDeptId, blocker, anchorEl) { const dept = orgData.departments.find(d => d.id === deptId); if (!dept) return; const task = dept.tasks[taskIdx]; if (!task) return; - task.blockedBy = { deptId: depDeptId, configName: depConfigName, name: depName }; - logAudit('dependency_set', { dept: dept.name, task: task.name, from: null, to: depName }); + // taskId is the permanent reference; configName is kept so an older version can still read it. + task.blockedBy = { deptId: depDeptId, taskId: blocker.id, configName: blocker._configName, name: blocker.name }; + logAudit('dependency_set', { dept: dept.name, task: task.name, from: null, to: blocker.name }); _updateDepChip(deptId, taskIdx, task, anchorEl); saveToStorage(); } @@ -1002,10 +1003,7 @@ function _updateCustomFieldsIcon(deptId, taskIdx, task) { export function isDependencyBlocking(task) { if (!task.blockedBy) return false; - const dep = task.blockedBy; - const depDept = orgData.departments.find(d => d.id === dep.deptId); - if (!depDept) return false; - const depTask = depDept.tasks.find(t => t._configName === dep.configName); + const depTask = findBlockerTask(orgData.departments, task.blockedBy); if (!depTask) return false; return (depTask.status || 'todo') !== 'done'; } From 3916a41cf613acaae66381f3eb7a9f5596cfe633 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:07:35 +0000 Subject: [PATCH 3/7] fix(copy): grammar in the coverage risk and honest label for the demo team card Spotted on the live demo: - Risk Queue said "Compliance need clearer ownership" for a single weak lane. coverageRiskDetail() now picks needs/need by count (still names at most 3). - The team card called the demo's fictional staff "real people"; in demo mode it now says "fictional people". Tests first: singular/plural/cap, and real-workspace vs demo label. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GXAgt2SAabUwyuTA9TbMsr --- CHANGELOG.md | 1 + js/__tests__/launchplan-copy.test.js | 58 ++++++++++++++++++++++++++++ js/launchPlan.js | 11 +++++- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 js/__tests__/launchplan-copy.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fb40914..33b3d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed - Per-task **notes and custom fields** are now part of the portable workspace (schema v3): they were missing from JSON export, Copy State, and Team Sync, and were not restored from backups. Version 2 files still import. - **Reset now deletes automatic backups** (full workspace copies, tenant records included) with "Operating workspace" and "Everything"; its confirmation says what a reset does not touch. Team Sync is stopped first so it cannot re-save the passphrase. +- Risk Queue grammar: "Compliance **needs** clearer ownership" when exactly one lane is weak (was "need"). The demo's team card now says "fictional people" instead of "real people". - Task rows no longer squeeze the task name under the status pill when a task has a due date, a dependency, and notes. - Work orders without a timestamp no longer display the text "Invalid Date". diff --git a/js/__tests__/launchplan-copy.test.js b/js/__tests__/launchplan-copy.test.js new file mode 100644 index 0000000..e7f145f --- /dev/null +++ b/js/__tests__/launchplan-copy.test.js @@ -0,0 +1,58 @@ +// Copy fixes spotted on the live demo: grammar of the coverage risk, and the +// team card must not call the demo's fictional staff "real people". +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import config from '../../config.json'; + +const html = fs.readFileSync(path.join(process.cwd(), 'index.html'), 'utf8'); + +async function load(search = '') { + history.pushState({}, '', `/${search}`); + vi.resetModules(); + const [state, launchPlan] = await Promise.all([import('../state.js'), import('../launchPlan.js')]); + const orgData = structuredClone(config.orgData); + orgData.departments.forEach(d => d.tasks.forEach(t => { t._configName = t.name; })); + state.setOrgData(orgData); + state.setOwnerColors(config.ownerColors); + state.setDefaultAffinities(config.defaultAffinities); + state.setWorkOrders([]); + state.setPortfolio({ properties: [], tenants: [], vendors: [] }); + state.setTeamData({ employees: [{ name: 'Dana Whitfield', hex: '#1976d2', affinities: ['leasing'] }] }); + return { state, launchPlan }; +} + +beforeEach(() => { + document.body.innerHTML = html.match(/]*>([\s\S]*)<\/body>/)[1]; + localStorage.clear(); +}); + +describe('coverageRiskDetail', () => { + test('one weak lane takes a singular verb', async () => { + const { launchPlan } = await load(); + expect(launchPlan.coverageRiskDetail([{ label: 'Compliance' }])).toBe('Compliance needs clearer ownership.'); + }); + test('several weak lanes take a plural verb and are capped at three names', async () => { + const { launchPlan } = await load(); + expect(launchPlan.coverageRiskDetail([{ label: 'A' }, { label: 'B' }])).toBe('A, B need clearer ownership.'); + expect(launchPlan.coverageRiskDetail(['A', 'B', 'C', 'D'].map(label => ({ label })))).toBe('A, B, C need clearer ownership.'); + }); +}); + +describe('team roster card', () => { + const rosterNote = () => [...document.querySelectorAll('.launch-metric, .launch-metrics-grid > *')] + .find(el => /Team roster/i.test(el.textContent))?.textContent.replace(/\s+/g, ' '); + + test('a real workspace with its own team says "real people"', async () => { + const { launchPlan } = await load(); + launchPlan.renderLaunchPlan(); + expect(rosterNote()).toMatch(/real people/); + }); + + test('the hosted demo says "fictional people" instead', async () => { + const { launchPlan } = await load('?demo=1'); + launchPlan.renderLaunchPlan(); + expect(rosterNote()).toMatch(/fictional people/); + expect(rosterNote()).not.toMatch(/real people/); + }); +}); diff --git a/js/launchPlan.js b/js/launchPlan.js index 079d569..6173313 100644 --- a/js/launchPlan.js +++ b/js/launchPlan.js @@ -9,6 +9,7 @@ import { } from './storage.js'; import { escapeHtml, _slugify, getLeaseStatus, getDelinquencyStatus, formatCurrency } from './utils.js'; import { ROLE_TEMPLATES, buildDemoWorkspace } from './templates.js'; +import { DEMO_MODE } from './demoMode.js'; // Maintainer note: // This module powers the top-of-dashboard Launch Plan and "Start Here" next @@ -265,6 +266,12 @@ const SEVEN_DAY_PLAN = [ }, ]; +// "Compliance needs…" for one weak lane, "Compliance, Leasing need…" for several (first three named). +export function coverageRiskDetail(areas) { + const verb = areas.length === 1 ? 'needs' : 'need'; + return `${areas.map(area => area.label).slice(0, 3).join(', ')} ${verb} clearer ownership.`; +} + export function renderLaunchPlan() { // Called by app init, ui.updateStats(), onboarding submit, checklist changes, // and guided action handlers. Keep this render idempotent and derived from @@ -306,7 +313,7 @@ export function renderLaunchPlan() {
${renderMetricCard('Portfolio', String(snapshot.propertyCount), `${snapshot.unitCount} units / ${snapshot.tenantCount} tenants / ${snapshot.vendorCount} vendors`, snapshot.propertyCount && snapshot.tenantCount && snapshot.vendorCount ? 'good' : 'warn')} ${renderMetricCard('Owned tasks', `${snapshot.assignedTasks}/${snapshot.totalTasks}`, `${snapshot.unownedTasks} unowned`, snapshot.unownedTasks ? 'warn' : 'good')} - ${renderMetricCard('Team roster', String(snapshot.employeeCount), snapshot.isStarterTeam ? 'sample roster' : 'real people', snapshot.isStarterTeam ? 'warn' : 'good')} + ${renderMetricCard('Team roster', String(snapshot.employeeCount), snapshot.isStarterTeam ? 'sample roster' : DEMO_MODE ? 'fictional people' : 'real people', snapshot.isStarterTeam ? 'warn' : 'good')} ${renderMetricCard('Open repairs', String(snapshot.openWorkOrders), `${snapshot.unassignedWorkOrders} unassigned`, snapshot.unassignedWorkOrders ? 'warn' : 'good')} ${renderMetricCard('Blocked work', String(snapshot.blockedTasks), `${snapshot.overdueTasks} overdue`, snapshot.blockedTasks || snapshot.overdueTasks ? 'danger' : 'good')}
@@ -945,7 +952,7 @@ function getRiskQueue(snapshot) { risks.push({ label: 'Ownership Map', title: `${weakCoverage.length} critical coverage lane${weakCoverage.length === 1 ? '' : 's'} under 70%`, - detail: `${weakCoverage.map(area => area.label).slice(0, 3).join(', ')} need clearer ownership.`, + detail: coverageRiskDetail(weakCoverage), tone: 'danger', onclick: `focusCoverageArea('${weakCoverage[0].id}')`, }); From abcafd2a6a274e3cafe2f4be0be6dfbba4a3b7fc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:26:40 +0000 Subject: [PATCH 4/7] feat(schema): hand-rolled schema layer; the import review names the field and record it repairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of Phase 1 (part 1). js/schema.js is a small, dependency-free schema layer (file://-friendly): field helpers, validateRecord and validateCollection, and error objects { path, field, code, message } such as Work order 3 ("Fix sink") — priority: "urgent!!" is not allowed; reset to "medium". at path workOrders[2].priority. - normalize.js keeps its public API and now builds on the layer; team members, work orders, properties, tenants, vendors and saved task rows all report what they repaired, dropped (missing required field, duplicate, reserved name, over the limit) or unlinked (unknown property / department). Result objects gain `errors`; kept/dropped/repaired stats are unchanged. - validateImportedState adds report.issues (+ issueCount, capped at 100) and also reports task rows with no matching task and departments this version doesn't have. formatImportReport prints them. - The import review modal shows a "What will be repaired" list (escaped; hostile values are shortened to one quoted line). All 346 pre-existing tests passed unchanged through the refactor. Failing tests first; mutation-checked (field errors, sanitizer errors in the report, modal rendering, unmatched-row reporting). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GXAgt2SAabUwyuTA9TbMsr --- js/__tests__/import-issues.test.js | 170 +++++++++++++++++ js/__tests__/schema.test.js | 139 ++++++++++++++ js/io.js | 11 ++ js/normalize.js | 291 ++++++++++++++--------------- js/schema.js | 203 ++++++++++++++++++++ js/stateSchema.js | 33 +++- 6 files changed, 689 insertions(+), 158 deletions(-) create mode 100644 js/__tests__/import-issues.test.js create mode 100644 js/__tests__/schema.test.js create mode 100644 js/schema.js diff --git a/js/__tests__/import-issues.test.js b/js/__tests__/import-issues.test.js new file mode 100644 index 0000000..af51339 --- /dev/null +++ b/js/__tests__/import-issues.test.js @@ -0,0 +1,170 @@ +// The import review must say WHICH field of WHICH record was repaired. +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, test, expect, beforeEach } from 'vitest'; +import config from '../../config.json'; +import * as state from '../state.js'; +import { sanitizeWorkspace, normalizeTeam, normalizeSavedTask } from '../normalize.js'; +import { validateImportedState, formatImportReport } from '../stateSchema.js'; +import { openImportReview, cancelPendingImport } from '../io.js'; +import { stampTaskIdentity } from '../taskIdentity.js'; +import { shorten } from '../schema.js'; + +const html = fs.readFileSync(path.join(process.cwd(), 'index.html'), 'utf8'); +const EVIL = '">'; +const DEPT_IDS = config.orgData.departments.map(d => d.id); + +function liveOrg() { + return { departments: stampTaskIdentity(structuredClone(config.orgData.departments)) }; +} + +describe('sanitizeWorkspace reports field-level errors', () => { + const dirty = () => ({ + team: { employees: [ + { name: 'Maria', hex: '#336699', affinities: ['leasing'] }, + { name: 'Maria', hex: '#000000', affinities: [] }, + { name: 'Sam', hex: 'purple', affinities: ['leasing', 'not-a-dept'] }, + { name: 'UNOWNED', hex: '#000000', affinities: [] }, + ] }, + workOrders: [ + { id: 'w1', title: 'Fix sink', priority: 'urgent!!', status: 'submitted' }, + { id: 'w2', priority: 'high' }, + { id: 'w3', title: 'Paint', dueDate: '2026-02-30', cost: 'lots' }, + ], + portfolio: { + properties: [{ id: 'p1', name: 'Oak Duplex', units: 2, documentUrl: 'javascript:alert(1)' }], + tenants: [{ id: 't1', name: 'Lee', propertyId: 'ghost-property', status: 'banished' }], + vendors: [{ id: 'v1', name: 'Ace', trade: 'plumbing' }], + }, + }); + + test('every repair names its path and record', () => { + const { errors } = sanitizeWorkspace(dirty(), { knownDeptIds: DEPT_IDS }); + const byPath = Object.fromEntries(errors.map(e => [e.path, e])); + expect(byPath['workOrders[0].priority'].message).toBe('Work order 1 ("Fix sink") — priority: "urgent!!" is not allowed; reset to "medium".'); + expect(byPath['workOrders[1].title'].code).toBe('missing_required'); + expect(byPath['workOrders[2].dueDate'].message).toMatch(/Work order 3 \("Paint"\) — dueDate: "2026-02-30"/); + expect(byPath['workOrders[2].cost'].message).toMatch(/cost: "lots" is not allowed; reset to 0/); + expect(byPath['portfolio.properties[0].documentUrl'].message).toMatch(/Property 1 \("Oak Duplex"\) — documentUrl: "javascript:alert\(1\)" is not allowed/); + expect(byPath['portfolio.tenants[0].propertyId'].code).toBe('unknown_reference'); + expect(byPath['portfolio.tenants[0].status'].code).toBe('invalid_value'); + expect(byPath['team.employees[1].name'].message).toMatch(/Team member 2 \("Maria"\)/); + expect(byPath['team.employees[2].hex'].message).toMatch(/hex: "purple" is not a valid color; reset to "#607d8b"/); + expect(byPath['team.employees[2].affinities[1]'].message).toMatch(/"not-a-dept" is not a department in this app; removed/); + expect(byPath['team.employees[3].name'].message).toMatch(/reserved/); + }); + + test('the data that survives is unchanged by adding the error list', () => { + const r = sanitizeWorkspace(dirty(), { knownDeptIds: DEPT_IDS }); + expect(r.workOrders.map(w => w.title)).toEqual(['Fix sink', 'Paint']); + expect(r.team.employees.map(e => e.name)).toEqual(['Maria', 'Sam']); + expect(r.stats.workOrders).toEqual({ kept: 2, dropped: 1, repaired: 2 }); + }); + + test('a clean workspace has no errors', () => { + const clean = { team: { employees: [{ name: 'Maria', hex: '#336699', affinities: ['leasing'] }] }, workOrders: [{ id: 'w1', title: 'Fix', priority: 'low', status: 'submitted', assignee: 'Maria', createdAt: '2026-01-01T00:00:00Z' }] }; + expect(sanitizeWorkspace(clean, { knownDeptIds: DEPT_IDS }).errors).toEqual([]); + }); + + test('normalizeTeam on its own reports too (used by storage load)', () => { + expect(normalizeTeam({ employees: [{ name: 'A', hex: 'nope', affinities: [] }] }).errors.map(e => e.path)).toEqual(['team.employees[0].hex']); + }); +}); + +describe('normalizeSavedTask reports field-level errors when asked', () => { + test('lists each unusable field of a task row', () => { + const issues = []; + const patch = normalizeSavedTask({ name: 'Show units', owner: 'Maria', status: 'nope', priority: 'urgent', dueDate: '2026-02-30', blockedBy: { deptId: 'x' } }, + { issues, path: 'departments[0].tasks[1]', label: 'Task "Show units" in Leasing' }); + expect(patch).toMatchObject({ name: 'Show units', owner: 'Maria' }); + expect(issues.map(i => i.path)).toEqual([ + 'departments[0].tasks[1].status', 'departments[0].tasks[1].priority', 'departments[0].tasks[1].dueDate', 'departments[0].tasks[1].blockedBy', + ]); + expect(issues[0].message).toBe('Task "Show units" in Leasing — status: "nope" is not allowed; left unchanged.'); + }); + + test('without the option it behaves exactly as before', () => { + expect(normalizeSavedTask({ name: 'x', owner: 'y', status: 'nope' })).toEqual({ name: 'x', owner: 'y' }); + }); +}); + +describe('the import review', () => { + const fileWith = org => ({ + schema: 'pm-ops-map-state', schemaVersion: 4, company: 'Co', + departments: org.departments.slice(0, 2).map(d => ({ id: d.id, name: d.name, tasks: d.tasks.slice(0, 3).map(t => ({ id: t.id, _configName: t.name, name: t.name, owner: 'Maria', status: 'todo', priority: 'medium' })) })), + workOrders: [{ id: 'w1', title: 'Fix sink', priority: 'urgent!!', status: 'submitted' }], + }); + + test('validateImportedState lists task, work order and unmatched-row problems with paths', () => { + const org = liveOrg(); + const file = fileWith(org); + file.departments[0].tasks[1].dueDate = '2026-02-30'; + file.departments[1].tasks[0].status = 'on fire'; + file.departments[1].tasks.push({ _configName: 'A task from another version', name: 'A task from another version', owner: 'Sam' }); + file.departments.push({ id: 'made-up-dept', tasks: [{ name: 'x', owner: 'y' }] }); + const report = validateImportedState(file, org); + const messages = report.issues.map(i => i.message); + const lead = org.departments[0].name; + expect(report.issues.map(i => i.path)).toEqual(expect.arrayContaining([ + 'departments[0].tasks[1].dueDate', 'departments[1].tasks[0].status', 'departments[1].tasks[3]', 'departments[2]', 'workOrders[0].priority', + ])); + expect(messages).toContain(`Task ${shorten(org.departments[0].tasks[1].name)} in ${lead} — dueDate: "2026-02-30" is not allowed; cleared.`); + expect(messages.some(m => /A task from another version/.test(m) && /no matching task/.test(m))).toBe(true); + expect(messages.some(m => /made-up-dept/.test(m) && /not in this version/.test(m))).toBe(true); + expect(report.issueCount).toBe(report.issues.length); + }); + + test('a clean file has no issues and the old counters are unchanged', () => { + const org = liveOrg(); + const file = fileWith(org); + delete file.workOrders; + const report = validateImportedState(file, org); + expect(report.issues).toEqual([]); + expect(report.ok).toBe(true); + expect(report.matchedTasks).toBe(6); + }); + + test('the list is capped and says how many more there are', () => { + const org = liveOrg(); + const file = fileWith(org); + file.workOrders = Array.from({ length: 150 }, (_, i) => ({ id: `w${i}`, title: `T${i}`, priority: 'nope', status: 'submitted' })); + const report = validateImportedState(file, org); + expect(report.issues.length).toBeLessThanOrEqual(100); + expect(report.issueCount).toBeGreaterThanOrEqual(150); + expect(formatImportReport(report)).toMatch(/and \d+ more/); + }); + + test('formatImportReport prints the issues in plain text', () => { + const org = liveOrg(); + const report = validateImportedState(fileWith(org), org); + expect(formatImportReport(report)).toContain('Work order 1 ("Fix sink") — priority: "urgent!!" is not allowed; reset to "medium".'); + }); + + describe('modal', () => { + beforeEach(() => { + document.body.innerHTML = html.match(/]*>([\s\S]*)<\/body>/)[1]; + state.setOrgData(liveOrg()); + }); + + test('shows the repairs as a list, escaped', () => { + const file = fileWith(state.orgData); + file.workOrders = [{ id: 'w1', title: EVIL, priority: EVIL, status: 'submitted' }]; + openImportReview(file, validateImportedState(file, state.orgData), 'file'); + const body = document.getElementById('import-review-body'); + expect(body.textContent).toMatch(/What will be repaired/); + expect(body.textContent).toMatch(/Work order 1/); + expect(body.textContent).toMatch(/priority:/); + expect(body.querySelector('[data-pwn]')).toBeNull(); + expect(body.querySelector('img')).toBeNull(); + cancelPendingImport(); + }); + + test('a clean file shows no repair section', () => { + const file = fileWith(state.orgData); + delete file.workOrders; + openImportReview(file, validateImportedState(file, state.orgData), 'file'); + expect(document.getElementById('import-review-body').textContent).not.toMatch(/What will be repaired/); + cancelPendingImport(); + }); + }); +}); diff --git a/js/__tests__/schema.test.js b/js/__tests__/schema.test.js new file mode 100644 index 0000000..3d7fa92 --- /dev/null +++ b/js/__tests__/schema.test.js @@ -0,0 +1,139 @@ +import { describe, test, expect } from 'vitest'; +import { + line, text, choice, date, optionalDate, stamp, url, money, count, + validateRecord, validateCollection, shorten, +} from '../schema.js'; + +const SPEC = { + title: { ...line(20), required: true }, + priority: choice(['low', 'medium', 'high'], 'medium'), + dueDate: date(null), + cost: money(0), + link: url(), + seen: stamp(), +}; +const EVIL = '">'; + +describe('validateRecord', () => { + test('a clean record has no errors and is not "repaired"', () => { + const r = validateRecord({ title: 'Fix sink', priority: 'high', dueDate: '2026-01-02', cost: 12.5, link: 'https://example.com' }, SPEC, { path: 'workOrders[0]' }); + expect(r.errors).toEqual([]); + expect(r.repaired).toBe(false); + expect(r.value).toMatchObject({ title: 'Fix sink', priority: 'high', dueDate: '2026-01-02', cost: 12.5 }); + }); + + test('an unusable value falls back and the error names the exact field path', () => { + const r = validateRecord({ title: 'Fix sink', priority: 'urgent!!', cost: 'lots' }, SPEC, { path: 'workOrders[2]', label: 'Work order 3 ("Fix sink")' }); + expect(r.value.priority).toBe('medium'); + expect(r.value.cost).toBe(0); + expect(r.repaired).toBe(true); + expect(r.errors.map(e => e.path)).toEqual(['workOrders[2].priority', 'workOrders[2].cost']); + expect(r.errors[0]).toMatchObject({ code: 'invalid_value', field: 'priority' }); + expect(r.errors[0].message).toBe('Work order 3 ("Fix sink") — priority: "urgent!!" is not allowed; reset to "medium".'); + }); + + test('absent optional fields are not errors; absent defaulted fields are not errors either', () => { + const r = validateRecord({ title: 'x' }, SPEC, { path: 'p' }); + expect(r.errors).toEqual([]); + expect(r.value).toMatchObject({ priority: 'medium', dueDate: null, cost: 0, link: '' }); + expect(r.value).not.toHaveProperty('seen'); + }); + + test('text that had to be cleaned or shortened is reported as adjusted, not invalid', () => { + const r = validateRecord({ title: 'x'.repeat(50) }, SPEC, { path: 'p', label: 'Thing' }); + expect(r.value.title).toHaveLength(20); + expect(r.errors).toHaveLength(1); + expect(r.errors[0]).toMatchObject({ code: 'adjusted', field: 'title' }); + expect(r.errors[0].message).toMatch(/^Thing — title: /); + }); + + test('plain trimming and URL canonicalisation are not reported', () => { + expect(validateRecord({ title: ' padded ', link: 'https://example.com' }, SPEC, { path: 'p' }).errors).toEqual([]); + }); + + test('a missing required field drops the record and says which field', () => { + const r = validateRecord({ priority: 'low' }, SPEC, { path: 'workOrders[4]', label: 'Work order 5' }); + expect(r.value).toBeNull(); + expect(r.errors).toEqual([expect.objectContaining({ path: 'workOrders[4].title', code: 'missing_required' })]); + expect(r.errors[0].message).toBe('Work order 5 — title: required, so the record is skipped.'); + }); + + test('non-objects are rejected with the record path', () => { + [null, 5, 'x', [], undefined].forEach(bad => { + const r = validateRecord(bad, SPEC, { path: 'workOrders[1]', label: 'Work order 2' }); + expect(r.value).toBeNull(); + expect(r.errors[0]).toMatchObject({ path: 'workOrders[1]', code: 'not_an_object' }); + }); + }); + + test('hostile input is shortened, never executed or interpolated raw into a path', () => { + const r = validateRecord({ title: 'ok', priority: EVIL.repeat(10) }, SPEC, { path: 'p' }); + expect(r.errors[0].message.length).toBeLessThan(160); + expect(r.errors[0].path).toBe('p.priority'); + }); +}); + +describe('shorten', () => { + test('quotes strings, strips control characters, caps length', () => { + expect(shorten('abc')).toBe('"abc"'); + expect(shorten('a\nb\u0000c')).toBe('"a b c"'); + expect(shorten('x'.repeat(100))).toBe(`"${'x'.repeat(40)}…"`); + expect(shorten(42)).toBe('42'); + expect(shorten({ a: 1 })).toBe('an object'); + expect(shorten([1])).toBe('a list'); + expect(shorten(true)).toBe('true'); + }); +}); + +describe('validateCollection', () => { + const run = (list, extra = {}) => validateCollection(list, SPEC, { path: 'workOrders', noun: 'Work order', idPrefix: 'wo', max: 3, titleKey: 'title', ...extra }); + + test('keeps good records, drops bad ones, and reports every problem by path', () => { + const r = run([{ id: 'a', title: 'A' }, { id: 'b', priority: 'x' }, { id: 'c', title: 'C', priority: 'nope' }]); + expect(r.items.map(i => i.title)).toEqual(['A', 'C']); + expect(r.stats).toEqual({ kept: 2, dropped: 1, repaired: 1 }); + expect(r.errors.map(e => e.path)).toEqual(['workOrders[1].title', 'workOrders[2].priority']); + expect(r.errors[1].message).toBe('Work order 3 ("C") — priority: "nope" is not allowed; reset to "medium".'); + }); + + test('missing and duplicate ids are replaced and reported', () => { + const r = run([{ id: 'a', title: 'A' }, { id: 'a', title: 'B' }, { id: EVIL, title: 'C' }]); + expect(new Set(r.items.map(i => i.id)).size).toBe(3); + expect(r.errors.map(e => [e.path, e.code])).toEqual([['workOrders[1].id', 'duplicate_id'], ['workOrders[2].id', 'bad_id']]); + expect(r.stats.repaired).toBe(2); + }); + + test('records past the limit are dropped and reported once per record', () => { + const r = run([1, 2, 3, 4].map(n => ({ id: `t${n}`, title: `T${n}` }))); + expect(r.items).toHaveLength(3); + expect(r.errors).toEqual([expect.objectContaining({ path: 'workOrders[3]', code: 'over_limit' })]); + }); + + test('the error list is capped but the omitted count is kept', () => { + const r = validateCollection(Array.from({ length: 300 }, () => ({})), SPEC, { path: 'w', noun: 'W', idPrefix: 'w', max: 1000, titleKey: 'title' }); + expect(r.stats.dropped).toBe(300); + expect(r.errors).toHaveLength(100); + expect(r.omitted).toBe(200); + }); + + test('finish() can add its own errors', () => { + const r = run([{ id: 'a', title: 'A' }], { finish: (rec, raw, ctx) => ({ value: rec, repaired: true, errors: [{ path: `${ctx.path}.x`, field: 'x', code: 'unknown_reference', message: 'custom' }] }) }); + expect(r.errors.map(e => e.message)).toEqual(['custom']); + expect(r.stats.repaired).toBe(1); + }); + + test('a non-array input is an empty collection', () => { + expect(run(undefined).items).toEqual([]); + expect(run('x').errors).toEqual([]); + }); +}); + +describe('field builders', () => { + test('count, text, optionalDate and stamp clean as before', () => { + expect(count().clean('7')).toBe(7); + expect(count().clean(-1)).toBeUndefined(); + expect(text(5).clean('abcdefgh\n')).toBe('abcde'); + expect(optionalDate().clean('2026-02-30')).toBeUndefined(); + expect(stamp().clean('not a time')).toBeUndefined(); + }); +}); diff --git a/js/io.js b/js/io.js index 936ace6..0f63baa 100644 --- a/js/io.js +++ b/js/io.js @@ -354,6 +354,7 @@ function buildImportReviewHTML(report, source) { ${report.warnings.length ? buildImportList('Warnings', report.warnings, 'warn') : ''} ${report.errors.length ? buildImportList('Errors', report.errors, 'error') : ''} + ${buildRepairList(report)}

Matching summary

    @@ -369,6 +370,16 @@ function buildImportReviewHTML(report, source) { `; } +// "What will be repaired": one line per field that was reset, cleared or skipped, naming the +// record and field. Capped by validateImportedState; the rest are summarized. +function buildRepairList(report) { + if (!report.issues || !report.issues.length) return ''; + const more = report.issueCount - report.issues.length; + const items = report.issues.map(issue => issue.message); + if (more > 0) items.push(`…and ${more} more.`); + return buildImportList('What will be repaired', items, 'warn'); +} + function buildImportMetric(label, value) { return `
    diff --git a/js/normalize.js b/js/normalize.js index fa8cc27..af4e705 100644 --- a/js/normalize.js +++ b/js/normalize.js @@ -7,12 +7,24 @@ // on: known enums, #rrggbb colors, safe ids, valid dates, finite numbers, // http(s)-only links, and length-capped text. // +// The generic machinery (field helpers, record/collection validation, error +// objects with field paths) lives in schema.js; this file says what a work order, +// property, tenant, vendor, employee and task look like. Every validator returns +// its sanitized data plus `errors`: what was repaired or dropped, by field path. +// // This is a safety net, not a replacement for escaping: free text (names, // notes) legitimately contains characters like < and ", so views must still // escape it at render time. Pure functions only — no DOM, no shared state. import { STATUS_CYCLE, PRIORITY_CYCLE, WO_STATUS_CYCLE } from './state.js'; -import { isValidISODate, normalizeUrl } from './utils.js'; +import { isValidISODate } from './utils.js'; import { findConfigTask } from './taskIdentity.js'; +import { + cleanText, cleanId, isHexColor, cleanTimestamp, cleanNumber, + line, text, choice, date, optionalDate, stamp, url, money, count, + validateCollection, makeIssue, shorten, wasProvided, +} from './schema.js'; + +export { cleanText, cleanId, isHexColor, cleanTimestamp, cleanNumber }; export const TENANT_STATUSES = ['active', 'applicant', 'notice', 'past']; export const DEFAULT_EMPLOYEE_HEX = '#607d8b'; @@ -34,68 +46,9 @@ export const LIMITS = { vendor: { name: 200, trade: 100, phone: 50, email: 200 }, }; -// Letters, digits and . _ : - only — safe in an attribute, a selector, or a URL fragment. -const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/; const DEPT_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/; -const HEX_RE = /^#[0-9a-fA-F]{6}$/; -const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:?\d{2})?)?$/; -// C0 controls except \t \n \r, DEL, and the Unicode line/paragraph separators. -const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u2028\u2029]/g; - -// ── Primitive cleaners (return undefined when the input is unusable) ────────── - -function asString(value) { - if (typeof value === 'string') return value; - if (typeof value === 'number' && Number.isFinite(value)) return String(value); - return ''; -} - -export function cleanText(value, max, { multiline = false } = {}) { - let s = asString(value).replace(CONTROL_CHARS, ''); - if (!multiline) s = s.replace(/[\r\n\t]+/g, ' '); - return s.trim().slice(0, max); -} -export function cleanId(value) { - return typeof value === 'string' && ID_RE.test(value) ? value : undefined; -} - -export function isHexColor(value) { - return typeof value === 'string' && HEX_RE.test(value); -} - -export function cleanTimestamp(value) { - if (typeof value !== 'string' || !TIMESTAMP_RE.test(value)) return undefined; - return Number.isNaN(Date.parse(value)) ? undefined : value; -} - -export function cleanNumber(value, { min = 0, max = 1e9, integer = false } = {}) { - let n = NaN; - if (typeof value === 'number') n = value; - else if (typeof value === 'string' && value.trim() !== '') n = Number(value); - if (!Number.isFinite(n) || n < min || n > max) return undefined; - return integer ? Math.trunc(n) : Math.round(n * 100) / 100; -} - -function generateId(prefix) { - return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; -} - -// ── Declarative field specs ─────────────────────────────────────────────────── -// Each field: { clean(input) -> value | undefined, fallback, required? } - -const line = (max, fallback = '') => ({ clean: v => cleanText(v, max), fallback }); -const text = (max, fallback = '') => ({ clean: v => cleanText(v, max, { multiline: true }), fallback }); -const choice = (list, fallback) => ({ clean: v => (list.includes(v) ? v : undefined), fallback }); -const date = (fallback = null) => ({ clean: v => (isValidISODate(v) ? v : undefined), fallback }); -// Optional fields stay absent when the input is absent or unusable (no empty-string placeholders). -const stamp = () => ({ clean: cleanTimestamp, optional: true }); -const optionalDate = () => ({ clean: v => (isValidISODate(v) ? v : undefined), optional: true }); -// A valid link counts as unchanged even when the parser canonicalizes it -// (https://example.com -> https://example.com/); only invalid links are "repairs". -const url = (fallback = '') => ({ clean: v => normalizeUrl(v) || undefined, fallback, sameAs: () => true }); -const money = (fallback = 0) => ({ clean: v => cleanNumber(v), fallback, numeric: true }); -const count = (fallback = 0) => ({ clean: v => cleanNumber(v, { max: 1e6, integer: true }), fallback, numeric: true }); +// ── Record specs ────────────────────────────────────────────────────────────── const WORK_ORDER_SPEC = { property: line(LIMITS.workOrder.property), @@ -149,129 +102,130 @@ const VENDOR_SPEC = { updatedAt: stamp(), }; -function wasProvided(input) { - return !(input === undefined || input === null || input === ''); -} - -function unchanged(input, cleaned, field) { - if (field.sameAs) return field.sameAs(input, cleaned); - if (field.numeric) return Number(input) === cleaned; - return typeof input === 'string' ? input.trim() === cleaned : input === cleaned; -} - -// Applies a spec to one raw record. Returns { value, repaired } or null when a -// required field is unusable (the record should be dropped). -function normalizeRecord(raw, spec) { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; - const value = {}; - let repaired = false; - for (const [key, field] of Object.entries(spec)) { - const input = raw[key]; - const cleaned = field.clean(input); - if (cleaned === undefined || (field.required && cleaned === '')) { - if (field.required) return null; - if (wasProvided(input)) repaired = true; - if (!field.optional) value[key] = field.fallback; - } else { - value[key] = cleaned; - if (wasProvided(input) && !unchanged(input, cleaned, field)) repaired = true; - } - } - return { value, repaired }; -} - -// Normalizes a list of records, assigning safe unique ids. `finish(record, raw)` -// runs cross-reference checks and returns { value, repaired } (or null to drop). -function normalizeCollection(rawList, spec, { idPrefix, max, finish } = {}) { - const stats = { kept: 0, dropped: 0, repaired: 0 }; - const items = []; - if (!Array.isArray(rawList)) return { items, stats }; - const seen = new Set(); - rawList.forEach((raw, index) => { - if (items.length >= max) { stats.dropped++; return; } - const result = normalizeRecord(raw, spec); - if (!result) { stats.dropped++; return; } - let { value, repaired } = result; - let id = cleanId(raw.id); - if (!id || seen.has(id)) { - id = generateId(`${idPrefix}-${index}`); - repaired = true; - } - seen.add(id); - value = { id, ...value }; - if (finish) { - const finished = finish(value, raw); - if (!finished) { stats.dropped++; return; } - value = finished.value; - repaired = repaired || finished.repaired; - } - if (repaired) stats.repaired++; - stats.kept++; - items.push(value); - }); - return { items, stats }; -} - // ── Work orders and portfolio ───────────────────────────────────────────────── export function normalizeWorkOrders(rawList) { - return normalizeCollection(rawList, WORK_ORDER_SPEC, { idPrefix: 'wo', max: LIMITS.records.workOrders }); + return validateCollection(rawList, WORK_ORDER_SPEC, { + path: 'workOrders', noun: 'Work order', titleKey: 'title', idPrefix: 'wo', max: LIMITS.records.workOrders, + }); } export function normalizePortfolio(raw) { const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; - const properties = normalizeCollection(source.properties, PROPERTY_SPEC, { idPrefix: 'property', max: LIMITS.records.properties }); + const properties = validateCollection(source.properties, PROPERTY_SPEC, { + path: 'portfolio.properties', noun: 'Property', titleKey: 'name', idPrefix: 'property', max: LIMITS.records.properties, + }); const propertyIds = new Set(properties.items.map(p => p.id)); - const tenants = normalizeCollection(source.tenants, TENANT_SPEC, { - idPrefix: 'tenant', - max: LIMITS.records.tenants, + const tenants = validateCollection(source.tenants, TENANT_SPEC, { + path: 'portfolio.tenants', noun: 'Tenant', titleKey: 'name', idPrefix: 'tenant', max: LIMITS.records.tenants, // A tenant only keeps its propertyId if that property exists in this workspace. - finish: (record, rawTenant) => { + finish: (record, rawTenant, ctx) => { const candidate = cleanId(rawTenant.propertyId); const linked = candidate && propertyIds.has(candidate) ? candidate : ''; - return { value: { ...record, propertyId: linked }, repaired: wasProvided(rawTenant.propertyId) && !linked }; + const broken = wasProvided(rawTenant.propertyId) && !linked; + return { + value: { ...record, propertyId: linked }, + repaired: broken, + errors: broken ? [makeIssue({ + path: `${ctx.path}.propertyId`, label: ctx.label, field: 'propertyId', code: 'unknown_reference', + detail: `${shorten(rawTenant.propertyId)} is not a property in this workspace; link cleared.`, + })] : [], + }; }, }); - const vendors = normalizeCollection(source.vendors, VENDOR_SPEC, { idPrefix: 'vendor', max: LIMITS.records.vendors }); + const vendors = validateCollection(source.vendors, VENDOR_SPEC, { + path: 'portfolio.vendors', noun: 'Vendor', titleKey: 'name', idPrefix: 'vendor', max: LIMITS.records.vendors, + }); return { portfolio: { properties: properties.items, tenants: tenants.items, vendors: vendors.items }, stats: { properties: properties.stats, tenants: tenants.stats, vendors: vendors.stats }, + errors: [...properties.errors, ...tenants.errors, ...vendors.errors], + omitted: properties.omitted + tenants.omitted + vendors.omitted, }; } // ── Team ────────────────────────────────────────────────────────────────────── -export function normalizeEmployee(raw, { knownDeptIds } = {}) { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; +// Returns { employee, errors, repaired }; `employee` is null when the row is unusable. +function checkEmployee(raw, { knownDeptIds, path = 'team.employees[0]', label = 'Team member' } = {}) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { employee: null, repaired: false, errors: [makeIssue({ path, label, code: 'not_an_object', detail: 'is not an object, so it is skipped.' })] }; + } const name = cleanText(raw.name, LIMITS.employeeName); - if (!name || name.toUpperCase() === 'UNOWNED' || name.toUpperCase() === 'UNASSIGNED') return null; + const named = name ? `${label} (${shorten(name)})` : label; + if (!name) { + return { employee: null, repaired: false, errors: [makeIssue({ path: `${path}.name`, label, field: 'name', code: 'missing_required', detail: 'required, so the member is skipped.' })] }; + } + if (name.toUpperCase() === 'UNOWNED' || name.toUpperCase() === 'UNASSIGNED') { + return { employee: null, repaired: false, errors: [makeIssue({ path: `${path}.name`, label: named, field: 'name', code: 'reserved_name', detail: `${shorten(name)} is a reserved name, so the member is skipped.` })] }; + } + const errors = []; + if (raw.name !== name) { + errors.push(makeIssue({ path: `${path}.name`, label: named, field: 'name', code: 'adjusted', detail: 'was cleaned up (extra spaces or characters removed, or shortened).' })); + } + let hex = DEFAULT_EMPLOYEE_HEX; + if (isHexColor(raw.hex)) hex = raw.hex; + else { + errors.push(makeIssue({ + path: `${path}.hex`, label: named, field: 'hex', code: 'invalid_value', + detail: wasProvided(raw.hex) ? `${shorten(raw.hex)} is not a valid color; reset to ${shorten(DEFAULT_EMPLOYEE_HEX)}.` : `missing; using the default color ${shorten(DEFAULT_EMPLOYEE_HEX)}.`, + })); + } const known = knownDeptIds ? new Set(knownDeptIds) : null; const affinities = []; if (Array.isArray(raw.affinities)) { - raw.affinities.forEach(a => { - if (typeof a !== 'string' || !DEPT_ID_RE.test(a)) return; - if (known && !known.has(a)) return; - if (!affinities.includes(a) && affinities.length < LIMITS.affinities) affinities.push(a); + raw.affinities.forEach((a, i) => { + const where = { path: `${path}.affinities[${i}]`, label: named, field: `affinities[${i}]` }; + if (typeof a !== 'string' || !DEPT_ID_RE.test(a)) { + errors.push(makeIssue({ ...where, code: 'invalid_value', detail: `${shorten(a)} is not a valid department id; removed.` })); + } else if (known && !known.has(a)) { + errors.push(makeIssue({ ...where, code: 'unknown_reference', detail: `${shorten(a)} is not a department in this app; removed.` })); + } else if (affinities.includes(a)) { + errors.push(makeIssue({ ...where, code: 'adjusted', detail: `${shorten(a)} is listed twice; duplicate removed.` })); + } else if (affinities.length >= LIMITS.affinities) { + errors.push(makeIssue({ ...where, code: 'over_limit', detail: `over the limit of ${LIMITS.affinities} departments; removed.` })); + } else { + affinities.push(a); + } }); } - return { name, hex: isHexColor(raw.hex) ? raw.hex : DEFAULT_EMPLOYEE_HEX, affinities }; + return { employee: { name, hex, affinities }, errors, repaired: errors.length > 0 }; +} + +export function normalizeEmployee(raw, options = {}) { + return checkEmployee(raw, options).employee; } export function normalizeTeam(raw, options = {}) { const stats = { kept: 0, dropped: 0, repaired: 0 }; const employees = []; + const errors = []; const list = raw && Array.isArray(raw.employees) ? raw.employees : []; const seen = new Set(); - list.forEach(item => { - const employee = normalizeEmployee(item, options); - const key = employee?.name.toLowerCase(); - if (!employee || seen.has(key) || employees.length >= LIMITS.records.employees) { stats.dropped++; return; } + list.forEach((item, index) => { + const path = `team.employees[${index}]`; + const label = `Team member ${index + 1}`; + const result = checkEmployee(item, { ...options, path, label }); + const employee = result.employee; + if (!employee) { stats.dropped++; errors.push(...result.errors); return; } + const key = employee.name.toLowerCase(); + if (seen.has(key)) { + stats.dropped++; + errors.push(makeIssue({ path: `${path}.name`, label: `${label} (${shorten(employee.name)})`, field: 'name', code: 'duplicate', detail: 'repeats an earlier team member, so it is skipped.' })); + return; + } + if (employees.length >= LIMITS.records.employees) { + stats.dropped++; + errors.push(makeIssue({ path, label, code: 'over_limit', detail: `over the limit of ${LIMITS.records.employees} team members, so it is skipped.` })); + return; + } seen.add(key); - if (item.name !== employee.name || item.hex !== employee.hex || (item.affinities || []).length !== employee.affinities.length) stats.repaired++; + errors.push(...result.errors); + if (result.repaired) stats.repaired++; stats.kept++; employees.push(employee); }); - return { team: { employees }, stats }; + return { team: { employees }, stats, errors }; } // ── Tasks ───────────────────────────────────────────────────────────────────── @@ -306,22 +260,46 @@ export function normalizeCustomFields(raw) { // Converts one saved/imported task into a patch to merge onto the matching // config task. Returns null if the row is unusable. With { fill: true } (used by // backup restore) absent fields are reset to their defaults instead of being left alone. -export function normalizeSavedTask(saved, { fill = false } = {}) { - if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return null; +// With { issues, path, label } every field that had to be dropped or reset is also +// described in `issues` (an array the caller owns). +export function normalizeSavedTask(saved, { fill = false, issues = null, path = '', label = '' } = {}) { + const note = (field, code, detail) => { + if (issues) issues.push(makeIssue({ path: path ? `${path}.${field}` : field, label, field, code, detail })); + }; + if (!saved || typeof saved !== 'object' || Array.isArray(saved)) { + if (issues) issues.push(makeIssue({ path, label, code: 'not_an_object', detail: 'is not an object, so it is ignored.' })); + return null; + } const name = cleanText(saved.name, LIMITS.taskName); const owner = cleanText(saved.owner, LIMITS.ownerName); - if (!name || !owner) return null; + if (!name || !owner) { + if (!name) note('name', 'missing_required', 'required, so the task row is ignored.'); + if (!owner) note('owner', 'missing_required', 'required, so the task row is ignored.'); + return null; + } const patch = { name, owner }; if (STATUS_CYCLE.includes(saved.status)) patch.status = saved.status; - else if (fill) patch.status = 'todo'; + else { + if (wasProvided(saved.status)) note('status', 'invalid_value', `${shorten(saved.status)} is not allowed; ${fill ? 'reset to "todo"' : 'left unchanged'}.`); + if (fill) patch.status = 'todo'; + } if (PRIORITY_CYCLE.includes(saved.priority)) patch.priority = saved.priority; - else if (fill) patch.priority = 'medium'; + else { + if (wasProvided(saved.priority)) note('priority', 'invalid_value', `${shorten(saved.priority)} is not allowed; ${fill ? 'reset to "medium"' : 'left unchanged'}.`); + if (fill) patch.priority = 'medium'; + } - if (saved.dueDate !== undefined || fill) patch.dueDate = isValidISODate(saved.dueDate) ? saved.dueDate : null; - if (saved.blockedBy !== undefined || fill) patch.blockedBy = normalizeBlockedBy(saved.blockedBy); + if (saved.dueDate !== undefined || fill) { + patch.dueDate = isValidISODate(saved.dueDate) ? saved.dueDate : null; + if (patch.dueDate === null && wasProvided(saved.dueDate)) note('dueDate', 'invalid_value', `${shorten(saved.dueDate)} is not allowed; cleared.`); + } + if (saved.blockedBy !== undefined || fill) { + patch.blockedBy = normalizeBlockedBy(saved.blockedBy); + if (patch.blockedBy === null && wasProvided(saved.blockedBy)) note('blockedBy', 'invalid_value', `${shorten(saved.blockedBy)} is not a usable dependency; cleared.`); + } // null means "no notes" (a note cleared on another device); undefined means "this file predates notes" — leave alone. if (typeof saved.notes === 'string' || saved.notes === null || fill) patch.notes = cleanText(saved.notes, LIMITS.taskNotes, { multiline: true }) || null; if (saved.customFields !== undefined || fill) patch.customFields = normalizeCustomFields(saved.customFields); @@ -393,16 +371,19 @@ export function normalizeCompany(value) { // Sections absent from the payload come back as null so callers leave them alone. export function sanitizeWorkspace(data, { knownDeptIds } = {}) { const src = data && typeof data === 'object' ? data : {}; - const result = { company: normalizeCompany(src.company), team: null, workOrders: null, portfolio: null, stats: {} }; + const result = { company: normalizeCompany(src.company), team: null, workOrders: null, portfolio: null, stats: {}, errors: [], omitted: 0 }; if (src.team && Array.isArray(src.team.employees)) { const t = normalizeTeam(src.team, { knownDeptIds }); result.team = t.team; result.stats.employees = t.stats; + result.errors.push(...t.errors); } if (Array.isArray(src.workOrders)) { const w = normalizeWorkOrders(src.workOrders); result.workOrders = w.items; result.stats.workOrders = w.stats; + result.errors.push(...w.errors); + result.omitted += w.omitted; } if (src.portfolio && typeof src.portfolio === 'object') { const p = normalizePortfolio(src.portfolio); @@ -410,6 +391,8 @@ export function sanitizeWorkspace(data, { knownDeptIds } = {}) { result.stats.properties = p.stats.properties; result.stats.tenants = p.stats.tenants; result.stats.vendors = p.stats.vendors; + result.errors.push(...p.errors); + result.omitted += p.omitted; } return result; } diff --git a/js/schema.js b/js/schema.js new file mode 100644 index 0000000..c0377c6 --- /dev/null +++ b/js/schema.js @@ -0,0 +1,203 @@ +// A small hand-rolled schema layer (no dependencies, so the client keeps working +// from file://). It turns untrusted data into trusted records AND says exactly what +// it had to change: every validator returns { value, errors[] } where each error +// names the field path ("workOrders[2].priority"), a machine code, and a sentence a +// person can read in the import review. +// +// Pure functions only — no DOM, no shared state. Domain specs (which fields a work +// order has, which values are allowed) live in normalize.js and are built from the +// field helpers below. +import { isValidISODate, normalizeUrl } from './utils.js'; + +// Letters, digits and . _ : - only — safe in an attribute, a selector, or a URL fragment. +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/; +const HEX_RE = /^#[0-9a-fA-F]{6}$/; +const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:?\d{2})?)?$/; +// C0 controls except \t \n \r, DEL, and the Unicode line/paragraph separators. +const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u2028\u2029]/g; +// Every control character, for turning a hostile value into one safe display line. +const DISPLAY_CONTROL = /[\u0000-\u001F\u007F\u2028\u2029]/g; + +export const MAX_ISSUES_PER_COLLECTION = 100; + +// ── Primitive cleaners (return undefined when the input is unusable) ────────── + +export function asString(value) { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return ''; +} + +export function cleanText(value, max, { multiline = false } = {}) { + let s = asString(value).replace(CONTROL_CHARS, ''); + if (!multiline) s = s.replace(/[\r\n\t]+/g, ' '); + return s.trim().slice(0, max); +} + +export function cleanId(value) { + return typeof value === 'string' && ID_RE.test(value) ? value : undefined; +} + +export function isHexColor(value) { + return typeof value === 'string' && HEX_RE.test(value); +} + +export function cleanTimestamp(value) { + if (typeof value !== 'string' || !TIMESTAMP_RE.test(value)) return undefined; + return Number.isNaN(Date.parse(value)) ? undefined : value; +} + +export function cleanNumber(value, { min = 0, max = 1e9, integer = false } = {}) { + let n = NaN; + if (typeof value === 'number') n = value; + else if (typeof value === 'string' && value.trim() !== '') n = Number(value); + if (!Number.isFinite(n) || n < min || n > max) return undefined; + return integer ? Math.trunc(n) : Math.round(n * 100) / 100; +} + +export function generateId(prefix) { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; +} + +// A short, single-line, quoted rendering of an untrusted value for error messages. +// (Still escaped by the view that displays it — this only keeps messages readable.) +export function shorten(value) { + if (typeof value === 'string') { + const flat = value.replace(DISPLAY_CONTROL, ' '); + return `"${flat.length > 40 ? `${flat.slice(0, 40)}…` : flat}"`; + } + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return 'a list'; + if (value && typeof value === 'object') return 'an object'; + return 'nothing'; +} + +// ── Field helpers ───────────────────────────────────────────────────────────── +// Each field: { clean(input) -> value | undefined, fallback, required?, optional?, numeric?, sameAs? } + +export const line = (max, fallback = '') => ({ clean: v => cleanText(v, max), fallback }); +export const text = (max, fallback = '') => ({ clean: v => cleanText(v, max, { multiline: true }), fallback }); +export const choice = (list, fallback) => ({ clean: v => (list.includes(v) ? v : undefined), fallback }); +export const date = (fallback = null) => ({ clean: v => (isValidISODate(v) ? v : undefined), fallback }); +// Optional fields stay absent when the input is absent or unusable (no empty-string placeholders). +export const stamp = () => ({ clean: cleanTimestamp, optional: true }); +export const optionalDate = () => ({ clean: v => (isValidISODate(v) ? v : undefined), optional: true }); +// A valid link counts as unchanged even when the parser canonicalizes it +// (https://example.com -> https://example.com/); only invalid links are "repairs". +export const url = (fallback = '') => ({ clean: v => normalizeUrl(v) || undefined, fallback, sameAs: () => true }); +export const money = (fallback = 0) => ({ clean: v => cleanNumber(v), fallback, numeric: true }); +export const count = (fallback = 0) => ({ clean: v => cleanNumber(v, { max: 1e6, integer: true }), fallback, numeric: true }); + +export function wasProvided(input) { + return !(input === undefined || input === null || input === ''); +} + +function unchanged(input, cleaned, field) { + if (field.sameAs) return field.sameAs(input, cleaned); + if (field.numeric) return Number(input) === cleaned; + return typeof input === 'string' ? input.trim() === cleaned : input === cleaned; +} + +function describeFallback(field) { + if (field.optional) return 'cleared'; + const f = field.fallback; + return f === '' || f === null || f === undefined ? 'cleared' : `reset to ${shorten(f)}`; +} + +const isPlainObject = v => !!v && typeof v === 'object' && !Array.isArray(v); + +// ── Records ─────────────────────────────────────────────────────────────────── + +// Builds an error. `label` names the record in the sentence ("Work order 3 (\"Fix sink\")"); +// without one the path is used. +export function makeIssue({ path, label, field, code, detail }) { + const who = label || path; + return { path, field, code, message: field ? `${who} — ${field}: ${detail}` : `${who} — ${detail}` }; +} + +// Applies a spec to one raw record. Returns { value, errors, repaired }; `value` is +// null when the record is unusable (not an object, or a required field is unusable). +export function validateRecord(raw, spec, { path = '', label = '' } = {}) { + if (!isPlainObject(raw)) { + return { value: null, repaired: false, errors: [makeIssue({ path, label, code: 'not_an_object', detail: 'is not an object, so it is skipped.' })] }; + } + const value = {}; + const errors = []; + for (const [key, field] of Object.entries(spec)) { + const input = raw[key]; + const fieldPath = path ? `${path}.${key}` : key; + const cleaned = field.clean(input); + if (cleaned === undefined || (field.required && cleaned === '')) { + if (field.required) { + return { value: null, repaired: false, errors: [makeIssue({ path: fieldPath, label, field: key, code: 'missing_required', detail: 'required, so the record is skipped.' })] }; + } + if (wasProvided(input)) { + errors.push(makeIssue({ path: fieldPath, label, field: key, code: 'invalid_value', detail: `${shorten(input)} is not allowed; ${describeFallback(field)}.` })); + } + if (!field.optional) value[key] = field.fallback; + } else { + value[key] = cleaned; + if (wasProvided(input) && !unchanged(input, cleaned, field)) { + errors.push(makeIssue({ path: fieldPath, label, field: key, code: 'adjusted', detail: 'was cleaned up (unsupported characters removed or shortened to the length limit).' })); + } + } + } + return { value, errors, repaired: errors.length > 0 }; +} + +// ── Collections ─────────────────────────────────────────────────────────────── + +// Validates a list of records, assigning safe unique ids. `finish(record, raw, ctx)` +// runs cross-reference checks and returns { value, repaired, errors? } (or null to +// drop). Returns { items, stats: { kept, dropped, repaired }, errors, omitted }; +// `errors` is capped so a hostile file can't build a giant list (`omitted` counts the rest). +export function validateCollection(rawList, spec, { path = '', noun = 'Record', idPrefix = 'item', max = Infinity, titleKey, finish } = {}) { + const stats = { kept: 0, dropped: 0, repaired: 0 }; + const items = []; + const errors = []; + let omitted = 0; + const report = list => list.forEach(e => { if (errors.length < MAX_ISSUES_PER_COLLECTION) errors.push(e); else omitted++; }); + if (!Array.isArray(rawList)) return { items, stats, errors, omitted }; + + const seen = new Set(); + rawList.forEach((raw, index) => { + const recordPath = `${path}[${index}]`; + const title = titleKey && isPlainObject(raw) && typeof raw[titleKey] === 'string' && raw[titleKey].trim() ? ` (${shorten(raw[titleKey])})` : ''; + const label = `${noun} ${index + 1}${title}`; + + if (items.length >= max) { + stats.dropped++; + report([makeIssue({ path: recordPath, label, code: 'over_limit', detail: `over the limit of ${max} records, so it is skipped.` })]); + return; + } + const result = validateRecord(raw, spec, { path: recordPath, label }); + if (!result.value) { stats.dropped++; report(result.errors); return; } + + let { value, repaired } = result; + const recordErrors = [...result.errors]; + let id = cleanId(raw.id); + if (!id || seen.has(id)) { + recordErrors.push(makeIssue({ + path: `${recordPath}.id`, label, field: 'id', + code: id ? 'duplicate_id' : 'bad_id', + detail: id ? 'repeats an earlier record, so a new id was generated.' : 'is missing or unsafe, so a new id was generated.', + })); + id = generateId(`${idPrefix}-${index}`); + repaired = true; + } + seen.add(id); + value = { id, ...value }; + if (finish) { + const finished = finish(value, raw, { path: recordPath, label, index }); + if (!finished) { stats.dropped++; report(recordErrors); return; } + value = finished.value; + repaired = repaired || !!finished.repaired; + if (finished.errors) recordErrors.push(...finished.errors); + } + report(recordErrors); + if (repaired) stats.repaired++; + stats.kept++; + items.push(value); + }); + return { items, stats, errors, omitted }; +} diff --git a/js/stateSchema.js b/js/stateSchema.js index d20ab09..b651ad7 100644 --- a/js/stateSchema.js +++ b/js/stateSchema.js @@ -1,10 +1,12 @@ import { normalizeSavedTask, sanitizeWorkspace } from './normalize.js'; +import { makeIssue, shorten } from './schema.js'; import { findConfigTask } from './taskIdentity.js'; // v3 added per-task notes and customFields. v4 adds each task's permanent `id` and a // `taskId` on dependencies; v2 and v3 files (no ids) still import by starter name. export const STATE_SCHEMA_VERSION = 4; export const STATE_SCHEMA_NAME = 'pm-ops-map-state'; +export const MAX_REVIEW_ISSUES = 100; export function buildStatePayload({ company, @@ -57,6 +59,10 @@ export function validateImportedState(data, orgData) { invalidTasks: 0, repairedRecords: 0, droppedRecords: 0, + // What will be repaired or skipped, field by field: [{ path, code, message }], capped at + // MAX_REVIEW_ISSUES; issueCount is the true total. + issues: [], + issueCount: 0, teamMembers: Array.isArray(data?.team?.employees) ? data.team.employees.length : 0, workOrders: Array.isArray(data?.workOrders) ? data.workOrders.length : 0, properties: Array.isArray(data?.portfolio?.properties) ? data.portfolio.properties.length : 0, @@ -82,10 +88,17 @@ export function validateImportedState(data, orgData) { return report; } - data.departments.forEach(savedDept => { - const dept = orgData.departments.find(item => item.id === savedDept.id); + const allIssues = []; + data.departments.forEach((savedDept, deptIndex) => { + const deptPath = `departments[${deptIndex}]`; + const dept = orgData.departments.find(item => item.id === savedDept?.id); if (!dept) { report.skippedDepartments++; + const taskCount = Array.isArray(savedDept?.tasks) ? savedDept.tasks.length : 0; + allIssues.push(makeIssue({ + path: deptPath, label: `Department ${shorten(savedDept?.id)}`, code: 'unknown_department', + detail: `is not in this version of the app, so ${taskCount} task${taskCount === 1 ? '' : 's'} will be skipped.`, + })); return; } report.matchedDepartments++; @@ -93,8 +106,11 @@ export function validateImportedState(data, orgData) { report.warnings.push(`Department "${savedDept.id}" has no task array.`); return; } - savedDept.tasks.forEach(savedTask => { - if (!normalizeSavedTask(savedTask)) { + savedDept.tasks.forEach((savedTask, taskIndex) => { + const path = `${deptPath}.tasks[${taskIndex}]`; + const taskName = savedTask && typeof savedTask === 'object' ? savedTask.name : ''; + const label = taskName ? `Task ${shorten(taskName)} in ${dept.name}` : `Task ${taskIndex + 1} in ${dept.name}`; + if (!normalizeSavedTask(savedTask, { issues: allIssues, path, label })) { report.invalidTasks++; return; } @@ -102,6 +118,7 @@ export function validateImportedState(data, orgData) { const task = findConfigTask(orgData.departments, savedTask, { deptId: dept.id }); if (!task) { report.skippedTasks++; + allIssues.push(makeIssue({ path, label, code: 'no_match', detail: 'has no matching task in this version of the app, so it is skipped.' })); return; } report.matchedTasks++; @@ -118,6 +135,9 @@ export function validateImportedState(data, orgData) { report.repairedRecords += stat.repaired; report.droppedRecords += stat.dropped; }); + allIssues.push(...clean.errors); + report.issueCount = allIssues.length + clean.omitted; + report.issues = allIssues.slice(0, MAX_REVIEW_ISSUES).map(({ path, code, message }) => ({ path, code, message })); if (report.invalidTasks) { report.warnings.push(`${report.invalidTasks} task row${report.invalidTasks === 1 ? ' is' : 's are'} missing a name or owner and will be ignored.`); } @@ -160,6 +180,11 @@ export function formatImportReport(report) { if (report.errors.length) { lines.push('', 'Errors:', ...report.errors.map(item => `- ${item}`)); } + if (report.issues?.length) { + lines.push('', 'What will be repaired or skipped:', ...report.issues.map(item => `- ${item.message}`)); + const more = report.issueCount - report.issues.length; + if (more > 0) lines.push(`- ...and ${more} more.`); + } return lines.join('\n'); } From aea6ab7948628af489804a95ea4d6faef14314d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:30:35 +0000 Subject: [PATCH 5/7] feat(schema): validate sync replies on the client, saved data on load, and pushes on the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of Phase 1 (part 2). Client - js/syncEnvelope.js: pull / version / push / 409-conflict replies are checked (object, state is an object, version is a safe non-negative integer) before use; a malformed reply now gives "The sync server sent an unexpected response: …" instead of a TypeError inside the sync loop (a 200 with a non-JSON body parsed to null and crashed on remote.state). - storage.js: every loader records what it had to repair (source, field path, sentence) via getLoadIssues() and warns once in the console. No UI change. - applySavedTasks labels rows by path when asked for issues. Server (CommonJS, minimal) - server/src/validate.js: state must be an object with the right section types, bounded list sizes, no __proto__ keys, depth <= 20, optional schema name/version checks; expectedVersion must be a whole number; passphrase <= 200 chars. 400 with a message naming the problem; nothing is stored. Unknown keys stay allowed. Minimum passphrase length etc. are untouched (Phase 5). Tests first for each; mutation-checked (envelope parser, storage recorder, server state validator and expectedVersion check). Server suite 19 -> 33. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GXAgt2SAabUwyuTA9TbMsr --- CHANGELOG.md | 2 + js/__tests__/storage-issues.test.js | 77 ++++++++++++++++++ js/__tests__/sync-envelope.test.js | 47 +++++++++++ js/__tests__/sync-wiring.test.js | 72 +++++++++++++++++ js/normalize.js | 12 ++- js/storage.js | 40 +++++++++- js/sync.js | 29 ++++--- js/syncEnvelope.js | 48 +++++++++++ server/README.md | 14 +++- server/__tests__/validate.test.js | 118 ++++++++++++++++++++++++++++ server/src/store.js | 12 ++- server/src/validate.js | 110 ++++++++++++++++++++++++++ 12 files changed, 562 insertions(+), 19 deletions(-) create mode 100644 js/__tests__/storage-issues.test.js create mode 100644 js/__tests__/sync-envelope.test.js create mode 100644 js/__tests__/sync-wiring.test.js create mode 100644 js/syncEnvelope.js create mode 100644 server/__tests__/validate.test.js create mode 100644 server/src/validate.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b3d6a..e1a1ec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ ### Added - **Permanent task ids.** Every starter task in `config.json` now has an id such as `maintenance-014`, assigned once by `npm run ids:assign` (it only adds missing ids, never changes one). Saved progress, imports, backups, undo and task dependencies match by id first, then by starter name, then by a task's `aliases` (former names), so rewording a task in `config.json` no longer orphans anyone's progress. A task whose name you never edited follows the new wording; one you renamed keeps your name. Export schema is now **v4** (adds `id` per task and `taskId` on dependencies); v2 and v3 files still import. +- **Field-level import review.** A small hand-rolled schema layer (`js/schema.js`, no dependencies) now powers the sanitizers. The import review lists exactly what will be repaired or skipped, by record and field — for example *Work order 3 ("Fix sink") — priority: "urgent!!" is not allowed; reset to "medium"* — including task rows that match nothing in this version and unknown departments. Repairs made while loading saved data are recorded and logged to the console instead of happening silently. +- **Sync hardening (client and server).** The client checks the server's reply envelope (`state`, `version`) and shows a clear message instead of a TypeError when the reply is malformed. The sync server now rejects pushes that are not shaped like a workspace (wrong section types, oversized lists, `__proto__` keys, extreme nesting, a non-integer `expectedVersion`, a passphrase over 200 characters). - **Live demo** at `?demo=1`: a fictional company with gaps, an overloaded teammate, blocked work, and work orders in every stage, with a permanent "Fictional data only" banner, one-click reset, isolated storage (`pm-ops-demo-*` keys), and Team Sync disabled. Deployed to GitHub Pages by `.github/workflows/pages.yml` after the production build passes its own browser tests. - **In-product privacy messaging**: a Portfolio notice, a *Data & Privacy* dialog, a first-run note, a once-per-session confirmation before exporting resident data, and honest *Beta · trusted-team sync* labelling for Team Sync. - Playwright browser suite (security fuzzing, CSV, data safety, privacy, demo, layout, smoke, and built-site tests) running in CI; Dependabot and a weekly production-dependency audit. diff --git a/js/__tests__/storage-issues.test.js b/js/__tests__/storage-issues.test.js new file mode 100644 index 0000000..fcc71a5 --- /dev/null +++ b/js/__tests__/storage-issues.test.js @@ -0,0 +1,77 @@ +// Saved data that had to be repaired on load is no longer repaired silently: each +// loader records what it changed (with field paths) and warns once in the console. +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import config from '../../config.json'; +import * as state from '../state.js'; +import * as storage from '../storage.js'; +import { stampTaskIdentity } from '../taskIdentity.js'; + +beforeEach(() => { + localStorage.clear(); + const orgData = structuredClone(config.orgData); + stampTaskIdentity(orgData.departments); + state.setOrgData(orgData); + state.setOwnerColors(config.ownerColors); + state.setDefaultAffinities(config.defaultAffinities); + state.setTeamData({ employees: [] }); + state.setWorkOrders([]); + state.setPortfolio({ properties: [], tenants: [], vendors: [] }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + storage.clearLoadIssues(); +}); +afterEach(() => vi.restoreAllMocks()); + +const put = (key, value) => localStorage.setItem(key, JSON.stringify(value)); + +describe('load issues', () => { + test('clean saved data records nothing and does not warn', () => { + put(storage.WORKORDERS_KEY, [{ id: 'w1', title: 'Fix', priority: 'low', status: 'submitted', assignee: 'Maria' }]); + put(storage.TEAM_KEY, { employees: [{ name: 'Maria', hex: '#336699', affinities: ['leasing'] }] }); + storage.loadWorkOrders(); + storage.loadTeamData(); + expect(storage.getLoadIssues()).toEqual([]); + expect(console.warn).not.toHaveBeenCalled(); + }); + + test('work orders: each repair is recorded with source and path, and warned about once', () => { + put(storage.WORKORDERS_KEY, [{ id: 'w1', title: 'Fix sink', priority: 'urgent!!', status: 'submitted', assignee: 'Maria' }, { id: 'w2' }]); + storage.loadWorkOrders(); + expect(state.workOrders.map(w => w.title)).toEqual(['Fix sink']); + const issues = storage.getLoadIssues(); + expect(issues.map(i => [i.source, i.path])).toEqual([['workOrders', 'workOrders[0].priority'], ['workOrders', 'workOrders[1].title']]); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(String(console.warn.mock.calls[0][0])).toMatch(/2 saved values.*work orders/i); + }); + + test('team, portfolio and tasks are recorded under their own source', () => { + put(storage.TEAM_KEY, { employees: [{ name: 'Sam', hex: 'purple', affinities: [] }] }); + put(storage.PORTFOLIO_KEY, { properties: [{ id: 'p1', name: 'Oak', documentUrl: 'javascript:alert(1)' }], tenants: [], vendors: [] }); + const first = state.orgData.departments[0].tasks[0]; + put(storage.STORAGE_KEY, [{ id: 'leasing', tasks: [{ id: first.id, _configName: first.name, name: first.name, owner: 'Maria', dueDate: '2026-02-30' }] }]); + storage.loadTeamData(); + storage.loadPortfolio(); + storage.loadFromStorage(); + const bySource = Object.fromEntries(storage.getLoadIssues().map(i => [i.source, i.path])); + expect(bySource).toEqual({ + team: 'team.employees[0].hex', + portfolio: 'portfolio.properties[0].documentUrl', + tasks: 'departments[0].tasks[0].dueDate', + }); + }); + + test('reloading a source replaces its earlier issues instead of piling up', () => { + put(storage.WORKORDERS_KEY, [{ id: 'w1', title: 'A', priority: 'nope' }]); + storage.loadWorkOrders(); + storage.loadWorkOrders(); + expect(storage.getLoadIssues()).toHaveLength(1); + put(storage.WORKORDERS_KEY, [{ id: 'w1', title: 'A', priority: 'low' }]); + storage.loadWorkOrders(); + expect(storage.getLoadIssues()).toEqual([]); + }); + + test('what the loaders do with the data is unchanged', () => { + put(storage.WORKORDERS_KEY, [{ id: 'w1', title: 'Fix', priority: 'nope', status: 'bogus', assignee: { x: 1 } }]); + storage.loadWorkOrders(); + expect(state.workOrders[0]).toMatchObject({ id: 'w1', title: 'Fix', priority: 'medium', status: 'submitted' }); + }); +}); diff --git a/js/__tests__/sync-envelope.test.js b/js/__tests__/sync-envelope.test.js new file mode 100644 index 0000000..1de6f03 --- /dev/null +++ b/js/__tests__/sync-envelope.test.js @@ -0,0 +1,47 @@ +import { describe, test, expect } from 'vitest'; +import { parseVersionedState, parseVersionInfo, parsePushResult, SyncResponseError } from '../syncEnvelope.js'; + +const bad = (fn, pattern) => { + let err; + try { fn(); } catch (e) { err = e; } + expect(err, 'should have thrown').toBeInstanceOf(SyncResponseError); + expect(err.message).toMatch(pattern); +}; + +describe('parseVersionedState (pull responses and 409 "current" payloads)', () => { + test('accepts a well-formed response and returns only the fields the client uses', () => { + const r = parseVersionedState({ state: { company: 'Acme' }, version: 3, updatedAt: '2026-01-02T03:04:05.000Z', extra: 'ignored' }); + expect(r).toEqual({ state: { company: 'Acme' }, version: 3, updatedAt: '2026-01-02T03:04:05.000Z' }); + }); + + test('a missing or unparsable updatedAt becomes an empty string, never a crash', () => { + expect(parseVersionedState({ state: {}, version: 1 }).updatedAt).toBe(''); + expect(parseVersionedState({ state: {}, version: 1, updatedAt: '' }).updatedAt).toBe(''); + }); + + test('rejects non-objects (a 200 with an HTML or empty body parses to null)', () => { + [null, undefined, 'ok', 5, [], true].forEach(body => bad(() => parseVersionedState(body), /not a JSON object/)); + }); + + test('rejects a state that is not an object', () => { + [undefined, null, 'x', 4, [], [{}]].forEach(state => bad(() => parseVersionedState({ state, version: 1 }), /"state" must be an object/)); + }); + + test('rejects versions that are not non-negative whole numbers', () => { + [undefined, '3', -1, 1.5, NaN, Infinity, null, 2 ** 60].forEach(version => bad(() => parseVersionedState({ state: {}, version }), /"version" must be a whole number/)); + }); +}); + +describe('parseVersionInfo and parsePushResult', () => { + test('version info needs just a version', () => { + expect(parseVersionInfo({ version: 7, updatedAt: '2026-01-02T03:04:05Z' })).toEqual({ version: 7, updatedAt: '2026-01-02T03:04:05Z' }); + bad(() => parseVersionInfo({}), /"version" must be a whole number/); + bad(() => parseVersionInfo(null), /not a JSON object/); + }); + + test('push result needs a version and reports whether the workspace was created', () => { + expect(parsePushResult({ version: 1, updatedAt: '2026-01-02T03:04:05Z', created: true })).toEqual({ version: 1, updatedAt: '2026-01-02T03:04:05Z', created: true }); + expect(parsePushResult({ version: 2 }).created).toBe(false); + bad(() => parsePushResult({ version: 'two' }), /"version" must be a whole number/); + }); +}); diff --git a/js/__tests__/sync-wiring.test.js b/js/__tests__/sync-wiring.test.js new file mode 100644 index 0000000..1d7bee6 --- /dev/null +++ b/js/__tests__/sync-wiring.test.js @@ -0,0 +1,72 @@ +// Team Sync must refuse a malformed server reply with a clear message, not a TypeError. +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import config from '../../config.json'; +import * as state from '../state.js'; +import { connectSync } from '../sync.js'; +import { stampTaskIdentity } from '../taskIdentity.js'; + +const html = fs.readFileSync(path.join(process.cwd(), 'index.html'), 'utf8'); + +function serverReplies(status, body) { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: status >= 200 && status < 300, status, json: async () => body }))); +} + +beforeEach(() => { + document.body.innerHTML = html.match(/]*>([\s\S]*)<\/body>/)[1]; + localStorage.clear(); + const orgData = structuredClone(config.orgData); + stampTaskIdentity(orgData.departments); + state.setOrgData(orgData); + state.setOwnerColors(config.ownerColors); + state.setDefaultAffinities(config.defaultAffinities); + state.setTeamData({ employees: [] }); + state.setWorkOrders([]); + state.setPortfolio({ properties: [], tenants: [], vendors: [] }); + document.getElementById('sync-server-url').value = 'http://localhost:4000'; + document.getElementById('sync-workspace').value = 'acme-pm'; + document.getElementById('sync-passphrase').value = 'secret1'; +}); +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + +describe('connecting to a server that sends a malformed reply', () => { + const connectAndGetAlert = async () => { + const alert = vi.fn(); + vi.stubGlobal('alert', alert); + await connectSync(); + return alert.mock.calls.map(c => String(c[0])).join('\n'); + }; + + test('a 200 reply with no JSON body is reported as an unexpected response', async () => { + serverReplies(200, null); + const shown = await connectAndGetAlert(); + expect(shown).toMatch(/unexpected response/); + expect(shown).toMatch(/not a JSON object/); + expect(shown).not.toMatch(/Cannot read properties|undefined|null\b.*of/); + }); + + test('a reply with a missing version never reaches the import review', async () => { + serverReplies(200, { state: { departments: [] } }); + const shown = await connectAndGetAlert(); + expect(shown).toMatch(/"version" must be a whole number/); + expect(document.getElementById('import-review-modal').classList.contains('visible')).toBe(false); + }); + + test('a well-formed reply still opens the import review, with field-level repairs listed', async () => { + const name = config.orgData.departments[0].tasks[0].name; + serverReplies(200, { + version: 4, updatedAt: '2026-01-02T03:04:05.000Z', + state: { + schema: 'pm-ops-map-state', schemaVersion: 4, company: 'Co', + departments: [{ id: 'leasing', tasks: [{ id: 'leasing-001', _configName: name, name, owner: 'Maria', status: 'on fire' }] }], + }, + }); + const shown = await connectAndGetAlert(); + expect(shown).toBe(''); + const review = document.getElementById('import-review-body').textContent; + expect(document.getElementById('import-review-modal').classList.contains('visible')).toBe(true); + expect(review).toMatch(/What will be repaired/); + expect(review).toMatch(/status: "on fire" is not allowed/); + }); +}); diff --git a/js/normalize.js b/js/normalize.js index af4e705..8cc8b16 100644 --- a/js/normalize.js +++ b/js/normalize.js @@ -316,12 +316,18 @@ export function normalizeSavedTask(saved, { fill = false, issues = null, path = export function applySavedTasks(departments, savedDepartments, options = {}) { const result = { matched: 0, skipped: 0 }; if (!Array.isArray(savedDepartments)) return result; - savedDepartments.forEach(savedDept => { + savedDepartments.forEach((savedDept, deptIndex) => { if (!savedDept || typeof savedDept.id !== 'string') return; const dept = departments.find(d => d.id === savedDept.id); if (!dept || !Array.isArray(savedDept.tasks)) return; - savedDept.tasks.forEach(savedTask => { - const patch = normalizeSavedTask(savedTask, options); + savedDept.tasks.forEach((savedTask, taskIndex) => { + // With options.issues the caller wants to know what was repaired, so say where each row is. + const rowOptions = options.issues ? { + ...options, + path: `departments[${deptIndex}].tasks[${taskIndex}]`, + label: `Task ${shorten(savedTask && savedTask.name)} in ${dept.name}`, + } : options; + const patch = normalizeSavedTask(savedTask, rowOptions); const task = patch && findConfigTask(departments, savedTask, { deptId: dept.id }); if (!task) { result.skipped++; return; } // A name the user never edited (it still equals the starter name the row was saved diff --git a/js/storage.js b/js/storage.js index 6d243f4..1737719 100644 --- a/js/storage.js +++ b/js/storage.js @@ -28,6 +28,28 @@ export const BACKUP_KEY = namespacedKey('pm-ops-backups-v1'); export const SYNC_CONFIG_KEY = namespacedKey('pm-ops-sync-config-v1'); const MAX_BACKUPS = 5; +// ── Repairs made while loading saved data ───────────────────────────────────── +// Everything read back from localStorage goes through normalize.js. When that had to +// reset, clear or drop something, the loader records it here (source + field path + +// sentence) and warns once in the console, so a silent repair can still be diagnosed. +let loadIssues = []; + +export function getLoadIssues() { return loadIssues.map(issue => ({ ...issue })); } +export function clearLoadIssues() { loadIssues = []; } + +const LOAD_SOURCE_LABELS = { tasks: 'tasks', team: 'team', workOrders: 'work orders', portfolio: 'portfolio' }; + +function recordLoadIssues(source, errors, omitted = 0) { + loadIssues = loadIssues.filter(issue => issue.source !== source); + if (!errors.length) return; + errors.forEach(({ path, code, message }) => loadIssues.push({ source, path, code, message })); + const total = errors.length + omitted; + console.warn( + `PM Ops Map repaired ${total} saved value${total === 1 ? '' : 's'} while loading ${LOAD_SOURCE_LABELS[source]}:\n` + + errors.slice(0, 20).map(e => ` - ${e.message}`).join('\n') + (total > 20 ? `\n …and ${total - 20} more.` : ''), + ); +} + // ── Toast notifications ─────────────────────────────────────────────────────── let _toastTimer = null; @@ -92,7 +114,9 @@ export function loadFromStorage() { if (!raw) return; const saved = JSON.parse(raw); if (!Array.isArray(saved)) return; - applySavedTasks(orgData.departments, saved); + const issues = []; + applySavedTasks(orgData.departments, saved, { issues }); + recordLoadIssues('tasks', issues); } catch (e) { console.error('Failed to parse saved task data — data preserved in storage:', e); _showActionToast('⚠ Could not load saved data — try exporting a backup', 'save-toast--error', 6000); @@ -195,7 +219,9 @@ export function loadTeamData() { } const parsed = JSON.parse(raw); if (parsed && Array.isArray(parsed.employees)) { - setTeamData(normalizeTeam(parsed, { knownDeptIds: knownDeptIds() }).team); + const result = normalizeTeam(parsed, { knownDeptIds: knownDeptIds() }); + setTeamData(result.team); + recordLoadIssues('team', result.errors); } else { seedDefaultTeam(); } @@ -216,7 +242,11 @@ export function saveWorkOrders() { export function loadWorkOrders() { try { const raw = localStorage.getItem(WORKORDERS_KEY); - if (raw) setWorkOrders(normalizeWorkOrders(JSON.parse(raw)).items); + if (raw) { + const result = normalizeWorkOrders(JSON.parse(raw)); + setWorkOrders(result.items); + recordLoadIssues('workOrders', result.errors, result.omitted); + } } catch (e) { setWorkOrders([]); } @@ -235,7 +265,9 @@ export function loadPortfolio() { try { const raw = localStorage.getItem(PORTFOLIO_KEY); if (!raw) return; - setPortfolio(normalizePortfolio(JSON.parse(raw)).portfolio); + const result = normalizePortfolio(JSON.parse(raw)); + setPortfolio(result.portfolio); + recordLoadIssues('portfolio', result.errors, result.omitted); } catch (e) { setPortfolio({ properties: [], vendors: [], tenants: [] }); } diff --git a/js/sync.js b/js/sync.js index 926807f..162ed4f 100644 --- a/js/sync.js +++ b/js/sync.js @@ -8,6 +8,7 @@ import { } from './storage.js'; import { buildStatePayload, validateImportedState, formatImportReport } from './stateSchema.js'; import { openImportReview, _applyImportedState, _saveUndoSnapshot } from './io.js'; +import { parseVersionedState, parseVersionInfo, parsePushResult } from './syncEnvelope.js'; import { escapeHtml } from './utils.js'; import { DEMO_MODE } from './demoMode.js'; @@ -89,7 +90,9 @@ function currentSnapshot() { // targets the live workspace. connectSync()/createWorkspace() pass an explicit // candidate URL instead, so a reconnect attempt never touches `sync`'s fields // (and can't race the background loop) until it actually succeeds. -async function apiCall(path, body, baseUrl = sync.serverUrl) { +// `parse` checks the shape of a successful reply (see syncEnvelope.js) and throws a +// SyncResponseError for anything else, so callers never touch a malformed response. +async function apiCall(path, body, baseUrl = sync.serverUrl, parse = reply => reply) { const res = await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -103,7 +106,7 @@ async function apiCall(path, body, baseUrl = sync.serverUrl) { err.body = json; throw err; } - return json; + return parse(json); } // ── Public: connect / disconnect ────────────────────────────────────────────── @@ -159,7 +162,7 @@ export async function connectSync() { setSyncModalBusy(true, 'Connecting…'); try { - const remote = await apiCall(`/api/workspaces/${workspace}/pull`, { passphrase }, serverUrl); + const remote = await apiCall(`/api/workspaces/${workspace}/pull`, { passphrase }, serverUrl, parseVersionedState); setSyncModalBusy(false); // Existing workspace — let the user review before it overwrites this device. const report = validateImportedState(remote.state, orgData); @@ -188,7 +191,7 @@ async function createWorkspace(serverUrl, workspace, passphrase) { passphrase, state: currentPayload(), expectedVersion: 0, - }, serverUrl); + }, serverUrl, parsePushResult); setSyncModalBusy(false); closeSyncModal(); commitConnection({ serverUrl, workspace, passphrase, version: result.version }); @@ -284,7 +287,7 @@ async function pushTick(snapshot, manual) { passphrase: sync.passphrase, state: JSON.parse(snapshot), expectedVersion: sync.version, - }); + }, undefined, parsePushResult); sync.version = result.version; sync.lastSyncedSnapshot = snapshot; sync.lastSyncedAt = Date.now(); @@ -304,8 +307,16 @@ async function pushTick(snapshot, manual) { } } -function handlePushConflict(current, localSnapshot, manual) { - if (!current) return; +function handlePushConflict(rawCurrent, localSnapshot, manual) { + if (!rawCurrent) return; + let current; + try { + current = parseVersionedState(rawCurrent); + } catch (err) { + sync.lastError = err.message; + if (manual) alert(`Sync conflict could not be shown: ${err.message}`); + return; + } const alreadySeen = sync.lastConflict && sync.lastConflict.version === current.version && sync.lastConflict.localSnapshot === localSnapshot; @@ -325,7 +336,7 @@ function handlePushConflict(current, localSnapshot, manual) { } async function pullIfNewerTick(manual) { - const versionInfo = await apiCall(`/api/workspaces/${sync.workspace}/version`, { passphrase: sync.passphrase }) + const versionInfo = await apiCall(`/api/workspaces/${sync.workspace}/version`, { passphrase: sync.passphrase }, undefined, parseVersionInfo) .catch(err => { if (err.status === 403) { disconnectOnAuthFailure(); return null; } throw err; @@ -338,7 +349,7 @@ async function pullIfNewerTick(manual) { return; } - const remote = await apiCall(`/api/workspaces/${sync.workspace}/pull`, { passphrase: sync.passphrase }); + const remote = await apiCall(`/api/workspaces/${sync.workspace}/pull`, { passphrase: sync.passphrase }, undefined, parseVersionedState); // The version check and this pull were two separate round trips. If the // user edited local state while either was in flight, applying the remote diff --git a/js/syncEnvelope.js b/js/syncEnvelope.js new file mode 100644 index 0000000..cc0b136 --- /dev/null +++ b/js/syncEnvelope.js @@ -0,0 +1,48 @@ +// Validates what the Team Sync server sends back before the client acts on it. +// The workspace *contents* are checked field by field by validateImportedState(); +// this only checks the envelope around them ({ state, version, updatedAt }), so a +// misbehaving or misconfigured server (an HTML error page served with status 200, +// a missing version, a state that isn't an object) fails with a clear message +// instead of a TypeError deep inside the sync loop. Pure; no DOM, no shared state. +import { cleanTimestamp } from './schema.js'; + +export class SyncResponseError extends Error { + constructor(message) { + super(`The sync server sent an unexpected response: ${message}`); + this.name = 'SyncResponseError'; + } +} + +const isObject = v => !!v && typeof v === 'object' && !Array.isArray(v); + +function requireObject(body) { + if (!isObject(body)) throw new SyncResponseError('the reply was not a JSON object.'); + return body; +} + +function requireVersion(body) { + const v = body.version; + if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0) { + throw new SyncResponseError('"version" must be a whole number of 0 or more.'); + } + return v; +} + +const stampOrEmpty = value => cleanTimestamp(value) || ''; + +// A pull response, or the `current` workspace attached to a 409 conflict. +export function parseVersionedState(body) { + requireObject(body); + if (!isObject(body.state)) throw new SyncResponseError('"state" must be an object.'); + return { state: body.state, version: requireVersion(body), updatedAt: stampOrEmpty(body.updatedAt) }; +} + +export function parseVersionInfo(body) { + requireObject(body); + return { version: requireVersion(body), updatedAt: stampOrEmpty(body.updatedAt) }; +} + +export function parsePushResult(body) { + requireObject(body); + return { version: requireVersion(body), updatedAt: stampOrEmpty(body.updatedAt), created: body.created === true }; +} diff --git a/server/README.md b/server/README.md index 5667a63..c568697 100644 --- a/server/README.md +++ b/server/README.md @@ -105,4 +105,16 @@ npm test Covers workspace claim-on-first-write, passphrase verification, version conflict detection (including a concurrent-write race), payload size limits, -and the HTTP API layer end-to-end. +request/state validation, and the HTTP API layer end-to-end. + +## What the server validates + +The server stores a workspace as an opaque blob and never interprets its +contents — the app checks every record itself before applying anything. It does +refuse (HTTP 400, with a message naming the problem) a push whose `state` is not +a JSON object, has a `schema` other than `pm-ops-map-state`, has the wrong type +for `departments` / `team` / `workOrders` / `portfolio` / `company`, has an +oversized list (about twice what the app keeps), contains a `__proto__` key, or +is nested more than 20 levels deep; an `expectedVersion` that is not a whole +number; or a passphrase over 200 characters. Unknown extra keys are accepted so +newer app versions keep working against an older server. diff --git a/server/__tests__/validate.test.js b/server/__tests__/validate.test.js new file mode 100644 index 0000000..d474d44 --- /dev/null +++ b/server/__tests__/validate.test.js @@ -0,0 +1,118 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const request = require('supertest'); +const { createApp } = require('../src/app'); +const { validateState, validatePassphrase, validateExpectedVersion } = require('../src/validate'); + +const app = () => createApp({ dataDir: fs.mkdtempSync(path.join(os.tmpdir(), 'pm-ops-sync-validate-')), allowedOrigins: ['*'] }); +const push = (a, state, extra = {}) => request(a).post('/api/workspaces/acme-pm/push').send({ passphrase: 'secret1', state, expectedVersion: 0, ...extra }); + +describe('validateState', () => { + test('accepts a realistic client payload and the tiny payloads other tests use', () => { + expect(validateState({ + schema: 'pm-ops-map-state', schemaVersion: 4, app: 'PM Ops Map', company: 'Acme', + departments: [{ id: 'leasing', name: 'Leasing', tasks: [{ id: 'leasing-001', name: 'List', owner: 'Maria' }] }], + team: { employees: [{ name: 'Maria', hex: '#336699', affinities: ['leasing'] }] }, + workOrders: [{ id: 'w1', title: 'Fix' }], + portfolio: { properties: [], tenants: [], vendors: [] }, + })).toEqual([]); + expect(validateState({ company: 'Acme' })).toEqual([]); + expect(validateState({ a: 1 })).toEqual([]); + expect(validateState({})).toEqual([]); + }); + + test('rejects anything that is not a plain object', () => { + [null, undefined, [], 'x', 5, true].forEach(v => expect(validateState(v)[0]).toMatch(/must be a JSON object/)); + }); + + test('rejects an unexpected schema name or a bad schemaVersion', () => { + expect(validateState({ schema: 'something-else' })[0]).toMatch(/schema/); + ['4', 0, -1, 1.5, 1001].forEach(v => expect(validateState({ schemaVersion: v })[0]).toMatch(/schemaVersion/)); + }); + + test('rejects wrong types for the known sections and says which', () => { + expect(validateState({ departments: {} })[0]).toMatch(/departments must be a list/); + expect(validateState({ departments: [{ id: 'a', tasks: 'x' }] })[0]).toMatch(/departments\[0\]\.tasks must be a list/); + expect(validateState({ departments: [5] })[0]).toMatch(/departments\[0\] must be an object/); + expect(validateState({ team: [] })[0]).toMatch(/team must be an object/); + expect(validateState({ team: { employees: 'x' } })[0]).toMatch(/team\.employees must be a list/); + expect(validateState({ workOrders: {} })[0]).toMatch(/workOrders must be a list/); + expect(validateState({ portfolio: { tenants: 7 } })[0]).toMatch(/portfolio\.tenants must be a list/); + expect(validateState({ company: { a: 1 } })[0]).toMatch(/company must be text/); + }); + + test('enforces list-size limits', () => { + expect(validateState({ departments: Array.from({ length: 101 }, () => ({ id: 'a', tasks: [] })) })[0]).toMatch(/departments has too many/); + expect(validateState({ workOrders: Array.from({ length: 10001 }, () => ({})) })[0]).toMatch(/workOrders has too many/); + expect(validateState({ departments: [{ id: 'a', tasks: Array.from({ length: 2001 }, () => ({})) }] })[0]).toMatch(/tasks has too many/); + }); + + test('rejects prototype-pollution keys and excessive nesting anywhere', () => { + expect(validateState(JSON.parse('{"a":{"__proto__":{"x":1}}}'))[0]).toMatch(/__proto__/); + let deep = {}; + const root = deep; + for (let i = 0; i < 40; i++) { deep.n = {}; deep = deep.n; } + expect(validateState(root)[0]).toMatch(/nested too deeply/); + }); + + test('reports at most a handful of problems', () => { + const errors = validateState({ departments: 1, team: 1, workOrders: 1, portfolio: 1, company: 1, schema: 1 }); + expect(errors.length).toBeGreaterThan(1); + expect(errors.length).toBeLessThanOrEqual(5); + }); +}); + +describe('request fields', () => { + test('passphrase length cap and expectedVersion type', () => { + expect(validatePassphrase('x'.repeat(201))).toMatch(/too long/); + expect(validatePassphrase('x'.repeat(200))).toBeNull(); + expect(validateExpectedVersion(undefined)).toBeNull(); + expect(validateExpectedVersion(0)).toBeNull(); + ['1', -1, 1.5, null, {}].forEach(v => expect(validateExpectedVersion(v)).toMatch(/expectedVersion/)); + }); +}); + +describe('HTTP behaviour', () => { + test('a well-formed workspace still pushes and pulls', async () => { + const a = app(); + const state = { schema: 'pm-ops-map-state', schemaVersion: 4, departments: [{ id: 'leasing', tasks: [] }], workOrders: [] }; + expect((await push(a, state)).status).toBe(200); + const pull = await request(a).post('/api/workspaces/acme-pm/pull').send({ passphrase: 'secret1' }); + expect(pull.body.state).toEqual(state); + }); + + test('a malformed state is a 400 that names the problem, and nothing is stored', async () => { + const a = app(); + const res = await push(a, { departments: { not: 'a list' } }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid workspace data: departments must be a list/); + expect((await request(a).post('/api/workspaces/acme-pm/pull').send({ passphrase: 'secret1' })).status).toBe(404); + }); + + test('an array, string or missing state is a 400', async () => { + const a = app(); + for (const state of [[], 'x', null]) expect((await push(a, state)).status).toBe(400); + }); + + test('an invalid expectedVersion is a 400, not a confusing conflict', async () => { + const a = app(); + await push(a, { a: 1 }); + const res = await push(a, { a: 2 }, { expectedVersion: '1' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/expectedVersion/); + }); + + test('an enormous passphrase is rejected before any hashing', async () => { + const a = app(); + const res = await request(a).post('/api/workspaces/acme-pm/pull').send({ passphrase: 'x'.repeat(5000) }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/too long/); + }); + + test('a push of the wrong schema name is refused', async () => { + const res = await push(app(), { schema: 'some-other-app', departments: [] }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/schema/); + }); +}); diff --git a/server/src/store.js b/server/src/store.js index e1ac570..2bb129a 100644 --- a/server/src/store.js +++ b/server/src/store.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const { validateState, validatePassphrase, validateExpectedVersion } = require('./validate'); const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/; const MAX_STATE_BYTES = 2 * 1024 * 1024; // 2MB — generous for this app's JSON state, cheap to enforce. @@ -66,6 +67,8 @@ class WorkspaceStore { if (typeof passphrase !== 'string' || passphrase.length === 0) { throw new HttpError('Passphrase is required.', 400); } + const tooLong = validatePassphrase(passphrase); + if (tooLong) throw new HttpError(tooLong, 400); const existing = this._readRaw(slug); if (!existing) throw new HttpError('No workspace with that name exists yet.', 404); if (!verifyPassphrase(passphrase, existing.salt, existing.hash)) { @@ -86,8 +89,13 @@ class WorkspaceStore { if (typeof passphrase !== 'string' || passphrase.length < MIN_PASSPHRASE_LENGTH) { throw new HttpError(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters.`, 400); } - if (state === undefined || state === null || typeof state !== 'object') { - throw new HttpError('State payload must be a JSON object.', 400); + const tooLong = validatePassphrase(passphrase); + if (tooLong) throw new HttpError(tooLong, 400); + const versionError = validateExpectedVersion(expectedVersion); + if (versionError) throw new HttpError(versionError, 400); + const stateErrors = validateState(state); + if (stateErrors.length) { + throw new HttpError(`Invalid workspace data: ${stateErrors.join(' ')}`, 400); } const size = Buffer.byteLength(JSON.stringify(state)); if (size > MAX_STATE_BYTES) { diff --git a/server/src/validate.js b/server/src/validate.js new file mode 100644 index 0000000..33391f2 --- /dev/null +++ b/server/src/validate.js @@ -0,0 +1,110 @@ +// Minimal shape checks for what the sync server accepts. The server stores the +// workspace as an opaque blob and never interprets it, so this does NOT try to +// validate every field — the client does that, record by record, before applying +// anything (js/normalize.js). What it refuses is data that is clearly not a PM Ops +// Map workspace, or that is built to be expensive or hostile to store and re-serve: +// the wrong top-level types, absurd list sizes, prototype-pollution keys, and +// extreme nesting. Everything is permissive about unknown keys so newer clients +// keep working against an older server. +const SCHEMA_NAME = 'pm-ops-map-state'; +const MAX_DEPTH = 20; +const MAX_PASSPHRASE_LENGTH = 200; +const MAX_REPORTED_ERRORS = 5; + +// Roughly twice what the client itself will keep (see LIMITS in js/normalize.js). +const MAX = { + departments: 100, + tasksPerDepartment: 2000, + employees: 500, + workOrders: 10000, + properties: 10000, + tenants: 10000, + vendors: 10000, + text: 200, +}; + +const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v); + +// Walks the whole value iteratively (no recursion, so a deeply nested payload can't +// overflow the stack). Returns an error string or null. +function checkStructure(root) { + const stack = [[root, 1]]; + while (stack.length) { + const [node, depth] = stack.pop(); + if (depth > MAX_DEPTH) return `data is nested too deeply (more than ${MAX_DEPTH} levels).`; + if (Array.isArray(node)) { + for (const item of node) if (item && typeof item === 'object') stack.push([item, depth + 1]); + } else if (isObject(node)) { + for (const key of Object.keys(node)) { + if (key === '__proto__') return 'data contains a "__proto__" key.'; + const value = node[key]; + if (value && typeof value === 'object') stack.push([value, depth + 1]); + } + } + } + return null; +} + +// Returns a list of problems (empty when the state is acceptable), at most MAX_REPORTED_ERRORS. +function validateState(state) { + if (!isObject(state)) return ['state must be a JSON object.']; + const errors = []; + const add = message => { if (errors.length < MAX_REPORTED_ERRORS) errors.push(message); }; + + const structure = checkStructure(state); + if (structure) return [structure]; + + if (state.schema !== undefined && state.schema !== SCHEMA_NAME) add(`schema must be "${SCHEMA_NAME}" when present.`); + if (state.schemaVersion !== undefined && !(Number.isInteger(state.schemaVersion) && state.schemaVersion >= 1 && state.schemaVersion <= 1000)) { + add('schemaVersion must be a whole number between 1 and 1000.'); + } + if (state.company !== undefined && (typeof state.company !== 'string' || state.company.length > MAX.text)) { + add(`company must be text of at most ${MAX.text} characters.`); + } + + const list = (value, name, limit) => { + if (value === undefined) return false; + if (!Array.isArray(value)) { add(`${name} must be a list.`); return false; } + if (value.length > limit) { add(`${name} has too many entries (limit ${limit}).`); return false; } + return true; + }; + + if (list(state.departments, 'departments', MAX.departments)) { + state.departments.forEach((dept, i) => { + if (!isObject(dept)) return add(`departments[${i}] must be an object.`); + if (dept.id !== undefined && (typeof dept.id !== 'string' || dept.id.length > 80)) add(`departments[${i}].id must be short text.`); + list(dept.tasks, `departments[${i}].tasks`, MAX.tasksPerDepartment); + }); + } + + if (state.team !== undefined) { + if (!isObject(state.team)) add('team must be an object.'); + else list(state.team.employees, 'team.employees', MAX.employees); + } + list(state.workOrders, 'workOrders', MAX.workOrders); + + if (state.portfolio !== undefined) { + if (!isObject(state.portfolio)) add('portfolio must be an object.'); + else { + list(state.portfolio.properties, 'portfolio.properties', MAX.properties); + list(state.portfolio.tenants, 'portfolio.tenants', MAX.tenants); + list(state.portfolio.vendors, 'portfolio.vendors', MAX.vendors); + } + } + return errors; +} + +// The minimum length stays in store.js; this only caps the top end so a multi-megabyte +// "passphrase" can't be fed to the key-derivation function. +function validatePassphrase(passphrase) { + return typeof passphrase === 'string' && passphrase.length > MAX_PASSPHRASE_LENGTH + ? `Passphrase is too long (limit ${MAX_PASSPHRASE_LENGTH} characters).` + : null; +} + +function validateExpectedVersion(value) { + if (value === undefined) return null; + return Number.isSafeInteger(value) && value >= 0 ? null : 'expectedVersion must be a whole number of 0 or more.'; +} + +module.exports = { validateState, validatePassphrase, validateExpectedVersion, MAX_PASSPHRASE_LENGTH }; From 49fa9899aa2b63717f2c39243823ae29f4db6321 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:31:14 +0000 Subject: [PATCH 6/7] test(e2e): hostile values inside the import-review repair list stay inert Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GXAgt2SAabUwyuTA9TbMsr --- e2e/security-xss.spec.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/e2e/security-xss.spec.js b/e2e/security-xss.spec.js index 34270e2..a8d6243 100644 --- a/e2e/security-xss.spec.js +++ b/e2e/security-xss.spec.js @@ -175,6 +175,24 @@ test.describe('hostile text is displayed, not executed', () => { await expect(page.locator('a.doc-link[href^="javascript"]')).toHaveCount(0); }); + test('import review names the repaired fields, and the hostile values inside those messages are inert', async ({ page }, testInfo) => { + page.on('dialog', d => d.dismiss()); + await prepare(page); + await page.goto('/index.html'); + await page.waitForSelector('#departments .department'); + const file = writeTemp('evil.json', JSON.stringify(buildEvilWorkspace())); + await page.setInputFiles('#import-file-input', file); + await page.waitForSelector('#import-review-modal.visible'); + const section = page.locator('#import-review-body .import-review-section', { hasText: 'What will be repaired' }); + await expect(section).toHaveCount(1); + const text = await section.innerText(); + expect(text).toMatch(/Work order 1/); + expect(text).toMatch(/priority: .* is not allowed; reset to "medium"/); + expect(text).toMatch(/Team member/); + expect(await section.locator('img, script, [data-pwn]').count()).toBe(0); + await expectClean(page, 'import review repair list'); + }); + test('import review tells the user how much was cleaned', async ({ page }, testInfo) => { page.on('dialog', d => d.dismiss()); await prepare(page); From 7fda340ac2e7ab0ca895448ce2723a8e007f3fbe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:38:51 +0000 Subject: [PATCH 7/7] feat(types): JSDoc types + `npm run typecheck` in CI (strict on the data/boundary modules) Step 3 of Phase 1. TypeScript is a devDependency only; the client stays plain JavaScript with no build step and no migration to .ts. - tsconfig.json: allowJs, checkJs, strict, noEmit over js/ (tests excluded). - js/types.js: shared typedefs - Task, Department, Employee, Team, WorkOrder, Property, Tenant, Vendor, Portfolio, AuditEntry, BlockedBy, WorkspacePayload, Issue, ImportReport (no runtime code). - Strict and clean: state, storage, stateSchema, normalize, schema, taskIdentity, syncEnvelope, privacy, showcase, demo, demoMode, templates, utils, types. Untrusted input is `unknown` and narrowed; the only explicit `any` (the imported-file view in sanitizeWorkspace) says why. - Everything else starts with `// @ts-nocheck - TODO(types)`; a test pins the list so the converted set cannot shrink and new files are checked by default. - CI runs `npm run typecheck` before the tests. Found by the checker: the demo's tenants/vendors lacked `documentUrl` (every sanitized record has it). A failing test (showcase equals its sanitized self) came first. Only runtime edits besides that: utils getLeaseStatus uses end.getTime() - today.getTime(), formatWODate uses `isoStr ?? NaN`, getOpsProfile parses `?? 'null'` - each identical in behavior. Mutation-checked: wrong argument types and a typo'd property are both rejected by tsc. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GXAgt2SAabUwyuTA9TbMsr --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 2 + CONTRIBUTING.md | 15 +- js/__tests__/showcase.test.js | 23 ++ js/__tests__/typecheck-guard.test.js | 49 ++++ js/app.js | 1 + js/demo.js | 3 +- js/demoMode.js | 2 + js/handbook.js | 1 + js/io.js | 1 + js/launchPlan.js | 1 + js/normalize.js | 121 +++++++-- js/privacy.js | 3 + js/schema.js | 83 +++++- js/showcase.js | 25 +- js/state.js | 40 ++- js/stateSchema.js | 39 ++- js/storage.js | 41 ++- js/sync.js | 1 + js/syncEnvelope.js | 22 +- js/taskIdentity.js | 24 +- js/templates.js | 1 + js/types.js | 213 +++++++++++++++ js/ui.js | 1 + js/utils.js | 35 ++- js/views/map.js | 1 + js/views/portfolio.js | 1 + js/views/recurring.js | 1 + js/views/team.js | 1 + js/views/tracking.js | 1 + js/views/workorders.js | 1 + package-lock.json | 376 +++++++++++++++++++++++++++ package.json | 2 + tsconfig.json | 20 ++ 34 files changed, 1094 insertions(+), 58 deletions(-) create mode 100644 js/__tests__/typecheck-guard.test.js create mode 100644 js/types.js create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa02da7..3603369 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: node-version: '22' cache: npm - run: npm ci + - run: npm run typecheck - run: npm test - run: npm audit --audit-level=moderate --omit=dev - run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a1ec0..72a40ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Server: `npm audit fix` clears a critical advisory in `proxy-addr` (a production dependency of Express); the sync server's Docker image moves from Node 20 (end-of-life) to Node 22. ### Fixed +- The demo's tenants and vendors were missing the `documentUrl` field every real record has (found by the new type check). - Per-task **notes and custom fields** are now part of the portable workspace (schema v3): they were missing from JSON export, Copy State, and Team Sync, and were not restored from backups. Version 2 files still import. - **Reset now deletes automatic backups** (full workspace copies, tenant records included) with "Operating workspace" and "Everything"; its confirmation says what a reset does not touch. Team Sync is stopped first so it cannot re-save the passphrase. - Risk Queue grammar: "Compliance **needs** clearer ownership" when exactly one lane is weak (was "need"). The demo's team card now says "fictional people" instead of "real people". @@ -18,6 +19,7 @@ ### Added - **Permanent task ids.** Every starter task in `config.json` now has an id such as `maintenance-014`, assigned once by `npm run ids:assign` (it only adds missing ids, never changes one). Saved progress, imports, backups, undo and task dependencies match by id first, then by starter name, then by a task's `aliases` (former names), so rewording a task in `config.json` no longer orphans anyone's progress. A task whose name you never edited follows the new wording; one you renamed keeps your name. Export schema is now **v4** (adds `id` per task and `taskId` on dependencies); v2 and v3 files still import. - **Field-level import review.** A small hand-rolled schema layer (`js/schema.js`, no dependencies) now powers the sanitizers. The import review lists exactly what will be repaired or skipped, by record and field — for example *Work order 3 ("Fix sink") — priority: "urgent!!" is not allowed; reset to "medium"* — including task rows that match nothing in this version and unknown departments. Repairs made while loading saved data are recorded and logged to the console instead of happening silently. +- **Type checking.** `npm run typecheck` (TypeScript over JSDoc, `strict`, no build step, no migration to `.ts`) now runs in CI. Shared shapes live in `js/types.js`. The state, storage, import/export schema, sanitizers, task identity, sync envelope, demo and utility modules are fully checked; the views and a few large modules are marked `// @ts-nocheck` until converted, and a test stops the checked set from shrinking. - **Sync hardening (client and server).** The client checks the server's reply envelope (`state`, `version`) and shows a clear message instead of a TypeError when the reply is malformed. The sync server now rejects pushes that are not shaped like a workspace (wrong section types, oversized lists, `__proto__` keys, extreme nesting, a non-integer `expectedVersion`, a passphrase over 200 characters). - **Live demo** at `?demo=1`: a fictional company with gaps, an overloaded teammate, blocked work, and work orders in every stage, with a permanent "Fictional data only" banner, one-click reset, isolated storage (`pm-ops-demo-*` keys), and Team Sync disabled. Deployed to GitHub Pages by `.github/workflows/pages.yml` after the production build passes its own browser tests. - **In-product privacy messaging**: a Portfolio notice, a *Data & Privacy* dialog, a first-run note, a once-per-session confirmation before exporting resident data, and honest *Beta · trusted-team sync* labelling for Team Sync. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf6eb2b..cdb242f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,10 @@ pm-ops-map/ │ ├── sync.js Optional team sync client — talks to server/ │ ├── launchPlan.js Beginner setup dashboard and readiness checks │ ├── handbook.js Markdown handbook export -│ ├── normalize.js Sanitizers for every path that loads outside data +│ ├── schema.js Hand-rolled schema layer: field helpers, record/collection validation, error paths +│ ├── normalize.js Sanitizers for every path that loads outside data (built on schema.js) +│ ├── syncEnvelope.js Checks the shape of Team Sync server replies +│ ├── types.js Shared JSDoc type definitions (no runtime code) │ ├── taskIdentity.js Permanent task ids: stamping at boot and matching saved rows to tasks │ ├── privacy.js Data-handling guidance and export confirmations │ ├── demoMode.js ?demo=1 detection and storage-key namespacing @@ -115,6 +118,14 @@ PM Ops Map renders data it did not create — imported JSON, pasted clipboard te `npm run test:e2e` runs a browser suite (`e2e/security-xss.spec.js`) that injects a canary payload into every importable field and fails if any view turns it into markup or script. If you add a field or a view, extend `e2e/helpers/security.js`. +## Types (JSDoc + `npm run typecheck`) + +The client is plain JavaScript with no build step, and it is type-checked with JSDoc comments: `npm run typecheck` runs `tsc` over `js/` (`allowJs`, `checkJs`, `strict`, `noEmit`). CI runs it before the tests. + +- Shared shapes (Task, Employee, WorkOrder, Property, Tenant, Vendor, WorkspacePayload, ImportReport, …) live in `js/types.js`. Pull them into a file with `/** @import { Task } from './types.js' */`. +- Annotate with `@param` / `@returns` / `@type`. Don't use `any`: if a value really is untrusted, type it `unknown` and narrow it (see `normalize.js`); if you must reach for `any`, say why in a comment next to it. +- Files that are not converted yet start with `// @ts-nocheck — TODO(types): …`. To convert one, delete that line, fix what `npm run typecheck` reports, and remove it from `NOT_CONVERTED_YET` in `js/__tests__/typecheck-guard.test.js`. New files are checked from the start. + ## Dependency Updates Dependabot opens weekly update PRs (`.github/dependabot.yml`): minor and patch bumps are grouped, every major bump is its own PR, and the sync server's production dependencies (Express, cors) are grouped apart from test tooling. CI audits production dependencies of both packages on every push, and `.github/workflows/audit.yml` repeats that weekly so a newly published advisory fails loudly even when nobody is pushing. When a Dependabot PR is green, merge it; when it is a major bump, read the changelog first. @@ -128,7 +139,7 @@ The client (`index.html`, `js/`, `css/`) has no runtime npm dependencies and sho - If you're editing an HTML-called handler, update both `index.html` and the `Object.assign(window, ...)` block in `js/app.js` - If you're editing a view, keep changes in the relevant `js/views/` module when possible - No new runtime dependencies in the client app (`index.html`, `js/`, `css/`) — it should keep working as browser-native HTML, CSS, and JavaScript with zero installs. `server/` is a separate package and may have its own minimal dependencies. -- Run `npm test` before submitting — tests must pass. If you touched rendering, import/export, or storage code, also run `npm run test:e2e` (one-time setup: `npx playwright install chromium`). If you touched `server/`, also run `npm test` inside `server/`. +- Run `npm run typecheck` and `npm test` before submitting — both must pass. If you touched rendering, import/export, or storage code, also run `npm run test:e2e` (one-time setup: `npx playwright install chromium`). If you touched `server/`, also run `npm test` inside `server/`. - Test the real modules: import from `js/*.js` in your tests. Don't add `.cjs` mirrors or test-only copies of browser code. ## Code Style diff --git a/js/__tests__/showcase.test.js b/js/__tests__/showcase.test.js index fe6e97e..8facf37 100644 --- a/js/__tests__/showcase.test.js +++ b/js/__tests__/showcase.test.js @@ -204,3 +204,26 @@ describe('applyShowcaseTasks', () => { expect(departments[0].tasks[0].owner).toBe('Priya Raman'); }); }); + +describe('showcase records are already in the canonical shape', () => { + // The demo bypasses the import sanitizers, so it must produce exactly what they would: + // otherwise views see fields (documentUrl) that exist on every real record but not on demo ones. + const { team, portfolio, workOrders } = buildShowcase(NOW); + + test('properties, tenants and vendors equal their sanitized selves', () => { + expect(sanitizeWorkspace({ portfolio }, { knownDeptIds: deptIds }).portfolio).toEqual(portfolio); + }); + + test('work orders equal their sanitized selves', () => { + expect(sanitizeWorkspace({ workOrders }, { knownDeptIds: deptIds }).workOrders).toEqual(workOrders); + }); + + test('the team equals its sanitized self', () => { + expect(sanitizeWorkspace({ team }, { knownDeptIds: deptIds }).team).toEqual(team); + }); + + test('importing the showcase reports nothing to repair', () => { + const r = sanitizeWorkspace({ team, workOrders, portfolio }, { knownDeptIds: deptIds }); + expect(r.errors).toEqual([]); + }); +}); diff --git a/js/__tests__/typecheck-guard.test.js b/js/__tests__/typecheck-guard.test.js new file mode 100644 index 0000000..a968f6b --- /dev/null +++ b/js/__tests__/typecheck-guard.test.js @@ -0,0 +1,49 @@ +// `npm run typecheck` (tsc, JSDoc types) is the real check. This test only keeps the converted +// part of the codebase from quietly shrinking: a converted file may not regain `@ts-nocheck`, +// and a new file is checked by default (it must not be added to the unconverted list casually). +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, test, expect } from 'vitest'; + +const JS_DIR = path.join(process.cwd(), 'js'); +const files = fs.readdirSync(JS_DIR, { recursive: true }) + .map(String) + .filter(f => f.endsWith('.js') && !f.includes('__tests__')) + .map(f => f.split(path.sep).join('/')); +const isUnchecked = file => fs.readFileSync(path.join(JS_DIR, file), 'utf8').startsWith('// @ts-nocheck'); + +// Still to convert (delete a name from this list when you delete its `// @ts-nocheck` line). +const NOT_CONVERTED_YET = [ + 'app.js', 'handbook.js', 'io.js', 'launchPlan.js', 'sync.js', 'ui.js', + 'views/map.js', 'views/portfolio.js', 'views/recurring.js', 'views/team.js', 'views/tracking.js', 'views/workorders.js', +]; + +describe('typed JSDoc coverage', () => { + test('the strict set stays strict', () => { + const strict = [ + 'normalize.js', 'state.js', 'stateSchema.js', 'storage.js', 'privacy.js', 'showcase.js', 'demo.js', 'demoMode.js', + 'schema.js', 'taskIdentity.js', 'syncEnvelope.js', 'utils.js', 'templates.js', 'types.js', + ]; + strict.forEach(file => { + expect(files, file).toContain(file); + expect(isUnchecked(file), `${file} must not start with // @ts-nocheck`).toBe(false); + }); + }); + + test('only the known unconverted files opt out of checking', () => { + const optedOut = files.filter(isUnchecked).sort(); + expect(optedOut).toEqual([...NOT_CONVERTED_YET].sort()); + }); + + test('every unconverted file says what to do about it', () => { + NOT_CONVERTED_YET.forEach(file => { + expect(fs.readFileSync(path.join(JS_DIR, file), 'utf8').split('\n')[0], file).toMatch(/TODO\(types\)/); + }); + }); + + test('tsconfig checks JavaScript strictly without emitting', () => { + const raw = fs.readFileSync(path.join(process.cwd(), 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, ''); + const { compilerOptions } = JSON.parse(raw); + expect(compilerOptions).toMatchObject({ allowJs: true, checkJs: true, noEmit: true, strict: true }); + }); +}); diff --git a/js/app.js b/js/app.js index 56ba29a..234dbb6 100644 --- a/js/app.js +++ b/js/app.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. // Entry point for the browser app. // // This is the best file to read first when editing the project because it diff --git a/js/demo.js b/js/demo.js index 27ece0b..7307d5d 100644 --- a/js/demo.js +++ b/js/demo.js @@ -1,3 +1,4 @@ +/** @import { OrgData } from './types.js' */ // Runtime for hosted-demo mode (see demoMode.js): seeds the fictional workspace on // first visit, shows the permanent "fictional data" banner, and resets the demo. // @@ -19,7 +20,7 @@ export function seedShowcaseWorkspace(now = new Date()) { setTeamData(showcase.team); setPortfolio(showcase.portfolio); setWorkOrders(showcase.workOrders); - applyShowcaseTasks(orgData.departments, showcase.team.employees, now); + applyShowcaseTasks(/** @type {OrgData} */ (orgData).departments, showcase.team.employees, now); try { localStorage.setItem(COMPANY_KEY, showcase.company); diff --git a/js/demoMode.js b/js/demoMode.js index 75e5887..5bef982 100644 --- a/js/demoMode.js +++ b/js/demoMode.js @@ -9,6 +9,7 @@ // This module has no dependencies on purpose — storage.js imports it to name // every key, so it must load first and must not import anything back. +/** @param {string} search @returns {boolean} */ function detectDemoMode(search) { try { return new URLSearchParams(search).get('demo') === '1'; @@ -20,6 +21,7 @@ function detectDemoMode(search) { export const DEMO_MODE = detectDemoMode(typeof location === 'undefined' ? '' : location.search); // All real keys start with "pm-ops-"; demo keys become "pm-ops-demo-...". +/** @param {string} key @returns {string} */ export const namespacedKey = key => (DEMO_MODE ? key.replace(/^pm-ops-/, 'pm-ops-demo-') : key); // Exposed for tests, which need to evaluate the same logic for both modes. diff --git a/js/handbook.js b/js/handbook.js index 953896e..1a9d2df 100644 --- a/js/handbook.js +++ b/js/handbook.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. import { orgData, teamData, workOrders, portfolio, STATUS_LABELS, PRIORITY_LABELS, WO_STATUS_LABELS, diff --git a/js/io.js b/js/io.js index 0f63baa..4f36564 100644 --- a/js/io.js +++ b/js/io.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. // Export / Import / Clipboard sync / Undo stack. import { orgData, teamData, setTeamData, workOrders, setWorkOrders, diff --git a/js/launchPlan.js b/js/launchPlan.js index 6173313..04c82e0 100644 --- a/js/launchPlan.js +++ b/js/launchPlan.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. import { orgData, teamData, setTeamData, workOrders, setWorkOrders, portfolio, setPortfolio, ownerColors, countUnowned, diff --git a/js/normalize.js b/js/normalize.js index 8cc8b16..2bbcabb 100644 --- a/js/normalize.js +++ b/js/normalize.js @@ -1,3 +1,31 @@ +/** @import { Employee, Team, WorkOrder, Property, Tenant, Vendor, Portfolio, AuditEntry, BlockedBy, Issue, CollectionStats, Department } from './types.js' */ +/** @import { CollectionResult } from './schema.js' */ + +/** + * The fields of a task row that may be merged onto a starter task (see normalizeSavedTask). + * @typedef {object} SavedTaskPatch + * @property {string} name + * @property {string} owner + * @property {string} [status] + * @property {string} [priority] + * @property {string | null} [dueDate] + * @property {BlockedBy | null} [blockedBy] + * @property {string | null} [notes] + * @property {Record | null} [customFields] + */ + +/** + * Everything in an imported payload except the tasks. Sections absent from the payload are null. + * @typedef {object} SanitizedWorkspace + * @property {string} company + * @property {Team | null} team + * @property {WorkOrder[] | null} workOrders + * @property {Portfolio | null} portfolio + * @property {Record} stats + * @property {Issue[]} errors + * @property {number} omitted + */ + // Boundary sanitizers: turn untrusted data into records the rest of the app can trust. // // Data enters PM Ops Map from places the UI forms never see — an imported JSON @@ -21,7 +49,7 @@ import { findConfigTask } from './taskIdentity.js'; import { cleanText, cleanId, isHexColor, cleanTimestamp, cleanNumber, line, text, choice, date, optionalDate, stamp, url, money, count, - validateCollection, makeIssue, shorten, wasProvided, + validateCollection, makeIssue, shorten, wasProvided, isPlainObject, isOneOf, } from './schema.js'; export { cleanText, cleanId, isHexColor, cleanTimestamp, cleanNumber }; @@ -104,19 +132,25 @@ const VENDOR_SPEC = { // ── Work orders and portfolio ───────────────────────────────────────────────── +/** @param {unknown} rawList @returns {CollectionResult} */ export function normalizeWorkOrders(rawList) { - return validateCollection(rawList, WORK_ORDER_SPEC, { + return /** @type {CollectionResult} */ (validateCollection(rawList, WORK_ORDER_SPEC, { path: 'workOrders', noun: 'Work order', titleKey: 'title', idPrefix: 'wo', max: LIMITS.records.workOrders, - }); + })); } +/** + * @param {unknown} raw + * @returns {{ portfolio: Portfolio, stats: { properties: CollectionStats, tenants: CollectionStats, vendors: CollectionStats }, errors: Issue[], omitted: number }} + */ export function normalizePortfolio(raw) { - const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; - const properties = validateCollection(source.properties, PROPERTY_SPEC, { + /** @type {Record} */ + const source = isPlainObject(raw) ? raw : {}; + const properties = /** @type {CollectionResult} */ (validateCollection(source.properties, PROPERTY_SPEC, { path: 'portfolio.properties', noun: 'Property', titleKey: 'name', idPrefix: 'property', max: LIMITS.records.properties, - }); + })); const propertyIds = new Set(properties.items.map(p => p.id)); - const tenants = validateCollection(source.tenants, TENANT_SPEC, { + const tenants = /** @type {CollectionResult} */ (validateCollection(source.tenants, TENANT_SPEC, { path: 'portfolio.tenants', noun: 'Tenant', titleKey: 'name', idPrefix: 'tenant', max: LIMITS.records.tenants, // A tenant only keeps its propertyId if that property exists in this workspace. finish: (record, rawTenant, ctx) => { @@ -132,10 +166,10 @@ export function normalizePortfolio(raw) { })] : [], }; }, - }); - const vendors = validateCollection(source.vendors, VENDOR_SPEC, { + })); + const vendors = /** @type {CollectionResult} */ (validateCollection(source.vendors, VENDOR_SPEC, { path: 'portfolio.vendors', noun: 'Vendor', titleKey: 'name', idPrefix: 'vendor', max: LIMITS.records.vendors, - }); + })); return { portfolio: { properties: properties.items, tenants: tenants.items, vendors: vendors.items }, stats: { properties: properties.stats, tenants: tenants.stats, vendors: vendors.stats }, @@ -147,8 +181,13 @@ export function normalizePortfolio(raw) { // ── Team ────────────────────────────────────────────────────────────────────── // Returns { employee, errors, repaired }; `employee` is null when the row is unusable. +/** + * @param {unknown} raw + * @param {{ knownDeptIds?: string[], path?: string, label?: string }} [options] + * @returns {{ employee: Employee | null, errors: Issue[], repaired: boolean }} + */ function checkEmployee(raw, { knownDeptIds, path = 'team.employees[0]', label = 'Team member' } = {}) { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + if (!isPlainObject(raw)) { return { employee: null, repaired: false, errors: [makeIssue({ path, label, code: 'not_an_object', detail: 'is not an object, so it is skipped.' })] }; } const name = cleanText(raw.name, LIMITS.employeeName); @@ -159,6 +198,7 @@ function checkEmployee(raw, { knownDeptIds, path = 'team.employees[0]', label = if (name.toUpperCase() === 'UNOWNED' || name.toUpperCase() === 'UNASSIGNED') { return { employee: null, repaired: false, errors: [makeIssue({ path: `${path}.name`, label: named, field: 'name', code: 'reserved_name', detail: `${shorten(name)} is a reserved name, so the member is skipped.` })] }; } + /** @type {Issue[]} */ const errors = []; if (raw.name !== name) { errors.push(makeIssue({ path: `${path}.name`, label: named, field: 'name', code: 'adjusted', detail: 'was cleaned up (extra spaces or characters removed, or shortened).' })); @@ -172,6 +212,7 @@ function checkEmployee(raw, { knownDeptIds, path = 'team.employees[0]', label = })); } const known = knownDeptIds ? new Set(knownDeptIds) : null; + /** @type {string[]} */ const affinities = []; if (Array.isArray(raw.affinities)) { raw.affinities.forEach((a, i) => { @@ -192,15 +233,19 @@ function checkEmployee(raw, { knownDeptIds, path = 'team.employees[0]', label = return { employee: { name, hex, affinities }, errors, repaired: errors.length > 0 }; } +/** @param {unknown} raw @param {{ knownDeptIds?: string[] }} [options] @returns {Employee | null} */ export function normalizeEmployee(raw, options = {}) { return checkEmployee(raw, options).employee; } +/** @param {unknown} raw @param {{ knownDeptIds?: string[] }} [options] @returns {{ team: Team, stats: CollectionStats, errors: Issue[] }} */ export function normalizeTeam(raw, options = {}) { const stats = { kept: 0, dropped: 0, repaired: 0 }; + /** @type {Employee[]} */ const employees = []; + /** @type {Issue[]} */ const errors = []; - const list = raw && Array.isArray(raw.employees) ? raw.employees : []; + const list = isPlainObject(raw) && Array.isArray(raw.employees) ? raw.employees : []; const seen = new Set(); list.forEach((item, index) => { const path = `team.employees[${index}]`; @@ -233,25 +278,27 @@ export function normalizeTeam(raw, options = {}) { // A dependency points at its blocker by `taskId` (permanent) and/or `configName` (the // starter name older versions wrote). Either is enough; both are kept when present so // a file written by this version still works in an older one. +/** @param {unknown} raw @returns {BlockedBy | null} */ export function normalizeBlockedBy(raw) { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + if (!isPlainObject(raw)) return null; const deptId = typeof raw.deptId === 'string' && DEPT_ID_RE.test(raw.deptId) ? raw.deptId : ''; const taskId = cleanId(raw.taskId); const configName = cleanText(raw.configName, LIMITS.taskName); const name = cleanText(raw.name, LIMITS.taskName); if (!deptId || (!taskId && !configName)) return null; - const result = { deptId }; + const result = /** @type {BlockedBy} */ ({ deptId }); if (taskId) result.taskId = taskId; if (configName) result.configName = configName; result.name = name || configName || ''; return result; } +/** @param {unknown} raw @returns {Record | null} */ export function normalizeCustomFields(raw) { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + if (!isPlainObject(raw)) return null; const entries = Object.entries(raw) .filter(([k, v]) => typeof k === 'string' && typeof v === 'string') - .map(([k, v]) => [cleanText(k, LIMITS.customFieldKey), cleanText(v, LIMITS.customFieldValue)]) + .map(([k, v]) => /** @type {[string, string]} */ ([cleanText(k, LIMITS.customFieldKey), cleanText(v, LIMITS.customFieldValue)])) .filter(([k]) => k && k !== '__proto__' && k !== 'constructor' && k !== 'prototype') .slice(0, LIMITS.customFieldCount); return entries.length ? Object.fromEntries(entries) : null; @@ -262,11 +309,17 @@ export function normalizeCustomFields(raw) { // backup restore) absent fields are reset to their defaults instead of being left alone. // With { issues, path, label } every field that had to be dropped or reset is also // described in `issues` (an array the caller owns). +/** + * @param {unknown} saved + * @param {{ fill?: boolean, issues?: Issue[] | null, path?: string, label?: string }} [options] + * @returns {SavedTaskPatch | null} + */ export function normalizeSavedTask(saved, { fill = false, issues = null, path = '', label = '' } = {}) { + /** @param {string} field @param {string} code @param {string} detail */ const note = (field, code, detail) => { if (issues) issues.push(makeIssue({ path: path ? `${path}.${field}` : field, label, field, code, detail })); }; - if (!saved || typeof saved !== 'object' || Array.isArray(saved)) { + if (!isPlainObject(saved)) { if (issues) issues.push(makeIssue({ path, label, code: 'not_an_object', detail: 'is not an object, so it is ignored.' })); return null; } @@ -278,22 +331,23 @@ export function normalizeSavedTask(saved, { fill = false, issues = null, path = return null; } + /** @type {SavedTaskPatch} */ const patch = { name, owner }; - if (STATUS_CYCLE.includes(saved.status)) patch.status = saved.status; + if (isOneOf(STATUS_CYCLE, saved.status)) patch.status = saved.status; else { if (wasProvided(saved.status)) note('status', 'invalid_value', `${shorten(saved.status)} is not allowed; ${fill ? 'reset to "todo"' : 'left unchanged'}.`); if (fill) patch.status = 'todo'; } - if (PRIORITY_CYCLE.includes(saved.priority)) patch.priority = saved.priority; + if (isOneOf(PRIORITY_CYCLE, saved.priority)) patch.priority = saved.priority; else { if (wasProvided(saved.priority)) note('priority', 'invalid_value', `${shorten(saved.priority)} is not allowed; ${fill ? 'reset to "medium"' : 'left unchanged'}.`); if (fill) patch.priority = 'medium'; } if (saved.dueDate !== undefined || fill) { - patch.dueDate = isValidISODate(saved.dueDate) ? saved.dueDate : null; + patch.dueDate = isValidISODate(saved.dueDate) ? /** @type {string} */ (saved.dueDate) : null; if (patch.dueDate === null && wasProvided(saved.dueDate)) note('dueDate', 'invalid_value', `${shorten(saved.dueDate)} is not allowed; cleared.`); } if (saved.blockedBy !== undefined || fill) { @@ -313,6 +367,12 @@ export function normalizeSavedTask(saved, { fill = false, issues = null, path = // which fields survive. Rows are matched to config tasks by permanent `id`, then by // the starter name (`_configName`) or one of the task's `aliases` (see taskIdentity.js). // Returns { matched, skipped } counts. +/** + * @param {Department[]} departments + * @param {unknown} savedDepartments + * @param {{ fill?: boolean, issues?: Issue[] | null }} [options] + * @returns {{ matched: number, skipped: number }} + */ export function applySavedTasks(departments, savedDepartments, options = {}) { const result = { matched: 0, skipped: 0 }; if (!Array.isArray(savedDepartments)) return result; @@ -320,7 +380,7 @@ export function applySavedTasks(departments, savedDepartments, options = {}) { if (!savedDept || typeof savedDept.id !== 'string') return; const dept = departments.find(d => d.id === savedDept.id); if (!dept || !Array.isArray(savedDept.tasks)) return; - savedDept.tasks.forEach((savedTask, taskIndex) => { + savedDept.tasks.forEach((/** @type {{ id?: unknown, _configName?: unknown, name?: unknown } | null | undefined} */ savedTask, /** @type {number} */ taskIndex) => { // With options.issues the caller wants to know what was repaired, so say where each row is. const rowOptions = options.issues ? { ...options, @@ -332,7 +392,7 @@ export function applySavedTasks(departments, savedDepartments, options = {}) { if (!task) { result.skipped++; return; } // A name the user never edited (it still equals the starter name the row was saved // under) follows config.json's current wording; an edited name is the user's and stays. - if (task._configName && patch.name === cleanText(savedTask._configName, LIMITS.taskName)) patch.name = task._configName; + if (task._configName && patch.name === cleanText(savedTask?._configName, LIMITS.taskName)) patch.name = task._configName; Object.assign(task, patch); result.matched++; }); @@ -344,11 +404,13 @@ export function applySavedTasks(departments, savedDepartments, options = {}) { const AUDIT_TEXT_KEYS = ['dept', 'task', 'title', 'label']; +/** @param {unknown} raw @returns {AuditEntry | null} */ export function normalizeAuditEntry(raw) { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + if (!isPlainObject(raw)) return null; const ts = cleanTimestamp(raw.ts); const action = typeof raw.action === 'string' && /^[a-z0-9_]{1,40}$/.test(raw.action) ? raw.action : ''; if (!ts || !action) return null; + /** @type {Record} */ const entry = { ts, action }; AUDIT_TEXT_KEYS.forEach(key => { if (typeof raw[key] === 'string') entry[key] = cleanText(raw[key], 300); @@ -359,15 +421,17 @@ export function normalizeAuditEntry(raw) { else if (typeof raw[key] === 'number' && Number.isFinite(raw[key])) entry[key] = raw[key]; }); if (typeof raw.count === 'number' && Number.isFinite(raw.count)) entry.count = raw.count; - return entry; + return /** @type {AuditEntry} */ (entry); } +/** @param {unknown} rawList @param {number} [max] @returns {AuditEntry[]} */ export function normalizeAuditLog(rawList, max = 500) { - return (Array.isArray(rawList) ? rawList : []).map(normalizeAuditEntry).filter(Boolean).slice(0, max); + return (Array.isArray(rawList) ? rawList : []).map(normalizeAuditEntry).filter(/** @returns {e is AuditEntry} @param {AuditEntry | null} e */ e => e !== null).slice(0, max); } // ── Whole-workspace entry point ─────────────────────────────────────────────── +/** @param {unknown} value @returns {string} */ export function normalizeCompany(value) { return cleanText(value, LIMITS.company); } @@ -375,8 +439,15 @@ export function normalizeCompany(value) { // Sanitizes everything in an imported payload except the tasks (those are // matched against the live config by the caller via normalizeSavedTask). // Sections absent from the payload come back as null so callers leave them alone. +/** + * @param {unknown} data + * @param {{ knownDeptIds?: string[] }} [options] + * @returns {SanitizedWorkspace} + */ export function sanitizeWorkspace(data, { knownDeptIds } = {}) { + /** @type {Record} */ // an untrusted file: every field below is checked before use const src = data && typeof data === 'object' ? data : {}; + /** @type {SanitizedWorkspace} */ const result = { company: normalizeCompany(src.company), team: null, workOrders: null, portfolio: null, stats: {}, errors: [], omitted: 0 }; if (src.team && Array.isArray(src.team.employees)) { const t = normalizeTeam(src.team, { knownDeptIds }); diff --git a/js/privacy.js b/js/privacy.js index 7fba29e..ab32bd1 100644 --- a/js/privacy.js +++ b/js/privacy.js @@ -51,6 +51,7 @@ export const COPIES_YOU_CREATE = [ ['Team Sync (beta)', 'Sends your workspace to the server you connect to, and saves its passphrase unencrypted in this browser.'], ]; +/** @param {string[]} items @returns {string} */ const escapedList = items => items.map(item => `
  • ${escapeHtml(item)}
  • `).join(''); export function renderPrivacyTiers() { @@ -136,6 +137,7 @@ export function _resetExportAcknowledgment() { acknowledged = false; } // Asks once per session before an export that includes tenant information. // Returns false if the user cancels, in which case the caller must not export. +/** @param {string} what @returns {boolean} */ export function confirmSensitiveExport(what) { if (!hasSensitiveRecords() || acknowledged) return true; const ok = confirm( @@ -148,6 +150,7 @@ export function confirmSensitiveExport(what) { } // Confirms a finished export and says what it is: another unencrypted copy. +/** @param {string} filename */ export function announceExport(filename) { const note = hasSensitiveRecords() ? 'is an unencrypted copy with tenant data — keep it somewhere you trust' diff --git a/js/schema.js b/js/schema.js index c0377c6..f006fb6 100644 --- a/js/schema.js +++ b/js/schema.js @@ -1,3 +1,4 @@ +/** @import { Issue, CollectionStats } from './types.js' */ // A small hand-rolled schema layer (no dependencies, so the client keeps working // from file://). It turns untrusted data into trusted records AND says exactly what // it had to change: every validator returns { value, errors[] } where each error @@ -20,33 +21,75 @@ const DISPLAY_CONTROL = /[\u0000-\u001F\u007F\u2028\u2029]/g; export const MAX_ISSUES_PER_COLLECTION = 100; +/** + * One field of a record: how to clean it and what to use when it can't be. + * @typedef {object} Field + * @property {(input: unknown) => unknown} clean The cleaned value, or `undefined` when the input is unusable. + * @property {unknown} [fallback] Used when `clean` returns undefined (unless `optional`). + * @property {boolean} [required] An unusable value drops the whole record. + * @property {boolean} [optional] An unusable value leaves the field absent. + * @property {boolean} [numeric] Compare input and cleaned value as numbers. + * @property {(input: unknown, cleaned: unknown) => boolean} [sameAs] Custom "nothing was changed" test. + */ + +/** @typedef {Record} Spec */ + +/** + * @typedef {(record: Record, raw: Record, ctx: { path: string, label: string, index: number }) => + * { value: Record, repaired?: boolean, errors?: Issue[] } | null} FinishFn + */ + +/** + * @template T + * @typedef {object} CollectionResult + * @property {T[]} items + * @property {CollectionStats} stats + * @property {Issue[]} errors Capped at MAX_ISSUES_PER_COLLECTION. + * @property {number} omitted How many further errors were not kept. + */ + // ── Primitive cleaners (return undefined when the input is unusable) ────────── +/** @param {unknown} value @returns {string} */ export function asString(value) { if (typeof value === 'string') return value; if (typeof value === 'number' && Number.isFinite(value)) return String(value); return ''; } +/** + * @param {unknown} value + * @param {number} max + * @param {{ multiline?: boolean }} [options] + * @returns {string} + */ export function cleanText(value, max, { multiline = false } = {}) { let s = asString(value).replace(CONTROL_CHARS, ''); if (!multiline) s = s.replace(/[\r\n\t]+/g, ' '); return s.trim().slice(0, max); } +/** @param {unknown} value @returns {string | undefined} */ export function cleanId(value) { return typeof value === 'string' && ID_RE.test(value) ? value : undefined; } +/** @param {unknown} value @returns {value is string} */ export function isHexColor(value) { return typeof value === 'string' && HEX_RE.test(value); } +/** @param {unknown} value @returns {string | undefined} */ export function cleanTimestamp(value) { if (typeof value !== 'string' || !TIMESTAMP_RE.test(value)) return undefined; return Number.isNaN(Date.parse(value)) ? undefined : value; } +/** + * @param {unknown} value + * @param {{ min?: number, max?: number, integer?: boolean }} [options] + * @returns {number | undefined} + */ export function cleanNumber(value, { min = 0, max = 1e9, integer = false } = {}) { let n = NaN; if (typeof value === 'number') n = value; @@ -55,12 +98,14 @@ export function cleanNumber(value, { min = 0, max = 1e9, integer = false } = {}) return integer ? Math.trunc(n) : Math.round(n * 100) / 100; } +/** @param {string} prefix @returns {string} */ export function generateId(prefix) { return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; } // A short, single-line, quoted rendering of an untrusted value for error messages. // (Still escaped by the view that displays it — this only keeps messages readable.) +/** @param {unknown} value @returns {string} */ export function shorten(value) { if (typeof value === 'string') { const flat = value.replace(DISPLAY_CONTROL, ' '); @@ -75,41 +120,60 @@ export function shorten(value) { // ── Field helpers ───────────────────────────────────────────────────────────── // Each field: { clean(input) -> value | undefined, fallback, required?, optional?, numeric?, sameAs? } +/** @param {number} max @param {string} [fallback] @returns {Field} */ export const line = (max, fallback = '') => ({ clean: v => cleanText(v, max), fallback }); +/** @param {number} max @param {string} [fallback] @returns {Field} */ export const text = (max, fallback = '') => ({ clean: v => cleanText(v, max, { multiline: true }), fallback }); +/** @param {ReadonlyArray} list @param {unknown} fallback @returns {Field} */ export const choice = (list, fallback) => ({ clean: v => (list.includes(v) ? v : undefined), fallback }); +/** @param {string | null} [fallback] @returns {Field} */ export const date = (fallback = null) => ({ clean: v => (isValidISODate(v) ? v : undefined), fallback }); // Optional fields stay absent when the input is absent or unusable (no empty-string placeholders). +/** @returns {Field} */ export const stamp = () => ({ clean: cleanTimestamp, optional: true }); +/** @returns {Field} */ export const optionalDate = () => ({ clean: v => (isValidISODate(v) ? v : undefined), optional: true }); // A valid link counts as unchanged even when the parser canonicalizes it // (https://example.com -> https://example.com/); only invalid links are "repairs". +/** @param {string} [fallback] @returns {Field} */ export const url = (fallback = '') => ({ clean: v => normalizeUrl(v) || undefined, fallback, sameAs: () => true }); +/** @param {number} [fallback] @returns {Field} */ export const money = (fallback = 0) => ({ clean: v => cleanNumber(v), fallback, numeric: true }); +/** @param {number} [fallback] @returns {Field} */ export const count = (fallback = 0) => ({ clean: v => cleanNumber(v, { max: 1e6, integer: true }), fallback, numeric: true }); +/** True when `value` is one of `list` (a type guard, so callers can narrow an untrusted value). @param {ReadonlyArray} list @param {unknown} value @returns {value is string} */ +export function isOneOf(list, value) { + return list.includes(/** @type {string} */ (value)); +} + +/** @param {unknown} input @returns {boolean} */ export function wasProvided(input) { return !(input === undefined || input === null || input === ''); } +/** @param {unknown} input @param {unknown} cleaned @param {Field} field @returns {boolean} */ function unchanged(input, cleaned, field) { if (field.sameAs) return field.sameAs(input, cleaned); if (field.numeric) return Number(input) === cleaned; return typeof input === 'string' ? input.trim() === cleaned : input === cleaned; } +/** @param {Field} field @returns {string} */ function describeFallback(field) { if (field.optional) return 'cleared'; const f = field.fallback; return f === '' || f === null || f === undefined ? 'cleared' : `reset to ${shorten(f)}`; } -const isPlainObject = v => !!v && typeof v === 'object' && !Array.isArray(v); +/** @param {unknown} v @returns {v is Record} */ +export const isPlainObject = v => !!v && typeof v === 'object' && !Array.isArray(v); // ── Records ─────────────────────────────────────────────────────────────────── // Builds an error. `label` names the record in the sentence ("Work order 3 (\"Fix sink\")"); // without one the path is used. +/** @param {{ path: string, label?: string, field?: string, code: string, detail: string }} parts @returns {Issue} */ export function makeIssue({ path, label, field, code, detail }) { const who = label || path; return { path, field, code, message: field ? `${who} — ${field}: ${detail}` : `${who} — ${detail}` }; @@ -117,11 +181,19 @@ export function makeIssue({ path, label, field, code, detail }) { // Applies a spec to one raw record. Returns { value, errors, repaired }; `value` is // null when the record is unusable (not an object, or a required field is unusable). +/** + * @param {unknown} raw + * @param {Spec} spec + * @param {{ path?: string, label?: string }} [where] + * @returns {{ value: Record | null, errors: Issue[], repaired: boolean }} + */ export function validateRecord(raw, spec, { path = '', label = '' } = {}) { if (!isPlainObject(raw)) { return { value: null, repaired: false, errors: [makeIssue({ path, label, code: 'not_an_object', detail: 'is not an object, so it is skipped.' })] }; } + /** @type {Record} */ const value = {}; + /** @type {Issue[]} */ const errors = []; for (const [key, field] of Object.entries(spec)) { const input = raw[key]; @@ -151,11 +223,20 @@ export function validateRecord(raw, spec, { path = '', label = '' } = {}) { // runs cross-reference checks and returns { value, repaired, errors? } (or null to // drop). Returns { items, stats: { kept, dropped, repaired }, errors, omitted }; // `errors` is capped so a hostile file can't build a giant list (`omitted` counts the rest). +/** + * @param {unknown} rawList + * @param {Spec} spec + * @param {{ path?: string, noun?: string, idPrefix?: string, max?: number, titleKey?: string, finish?: FinishFn }} [options] + * @returns {CollectionResult>} + */ export function validateCollection(rawList, spec, { path = '', noun = 'Record', idPrefix = 'item', max = Infinity, titleKey, finish } = {}) { const stats = { kept: 0, dropped: 0, repaired: 0 }; + /** @type {Record[]} */ const items = []; + /** @type {Issue[]} */ const errors = []; let omitted = 0; + /** @param {Issue[]} list */ const report = list => list.forEach(e => { if (errors.length < MAX_ISSUES_PER_COLLECTION) errors.push(e); else omitted++; }); if (!Array.isArray(rawList)) return { items, stats, errors, omitted }; diff --git a/js/showcase.js b/js/showcase.js index 846fa70..b7ca018 100644 --- a/js/showcase.js +++ b/js/showcase.js @@ -1,3 +1,4 @@ +/** @import { Department, Employee, Property, Tenant, Vendor, WorkOrder } from './types.js' */ // The workspace behind the hosted demo (?demo=1): entirely fictional, deterministic, // and shaped to show what the tool is FOR rather than a wall of identical rows. // @@ -14,11 +15,13 @@ const DAY_MS = 86_400_000; +/** @param {Date} now @param {number} days @returns {string} */ const isoDate = (now, days) => { const d = new Date(now); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); }; +/** @param {Date} now @param {number} days @returns {string} */ const isoStamp = (now, days) => new Date(now.getTime() + days * DAY_MS).toISOString(); export const SHOWCASE_COMPANY = 'Demo Door Property Management'; @@ -26,6 +29,7 @@ export const SHOWCASE_COMPANY = 'Demo Door Property Management'; // Departments with no one assigned on purpose — the gaps the map is meant to expose. export const UNCOVERED_DEPARTMENTS = ['compliance', 'reporting', 'systems']; +/** @type {Employee[]} */ const TEAM = [ { name: 'Dana Whitfield', hex: '#1565c0', @@ -44,6 +48,7 @@ const TEAM = [ export function buildShowcase(now = new Date()) { const team = { employees: TEAM.map(emp => ({ ...emp, affinities: [...emp.affinities] })) }; + /** @type {Property[]} */ const properties = [ { id: 'demo-property-oak', name: 'Oak Street Duplex', units: 2, owner: 'Rivera Family LLC', @@ -57,7 +62,8 @@ export function buildShowcase(now = new Date()) { }, ]; - const tenants = [ + /** @type {(Omit & { documentUrl?: string })[]} */ + const tenantsRows = [ { id: 'demo-tenant-1', name: 'Maya Chen', propertyId: 'demo-property-oak', unit: '2B', status: 'active', phone: '(555) 010-2041', email: 'maya.chen@example.com', rent: 1450, @@ -84,8 +90,12 @@ export function buildShowcase(now = new Date()) { lastPaymentDate: isoDate(now, -9), createdAt: isoStamp(now, -370), }, ]; + // Every real record has a documentUrl (the sanitizers add it), so the demo's must too. + /** @type {Tenant[]} */ + const tenants = tenantsRows.map(row => ({ documentUrl: '', ...row })); - const vendors = [ + /** @type {(Omit & { documentUrl?: string })[]} */ + const vendorsRows = [ { id: 'demo-vendor-1', name: 'Ace Plumbing', trade: 'Plumbing', phone: '(555) 010-1188', email: 'dispatch@aceplumbing.example', documentUrl: 'https://example.com/documents/ace-plumbing-coi.pdf', @@ -100,7 +110,11 @@ export function buildShowcase(now = new Date()) { email: 'help@clearviewhvac.example', createdAt: isoStamp(now, -130), }, ]; + // Every real record has a documentUrl (the sanitizers add it), so the demo's must too. + /** @type {Vendor[]} */ + const vendors = vendorsRows.map(row => ({ documentUrl: '', ...row })); + /** @param {number} n @param {Pick & Partial} fields @returns {WorkOrder} */ const order = (n, fields) => ({ id: `demo-wo-${n}`, unit: '', tenant: '', notes: '', vendor: '', dueDate: null, cost: 0, createdAt: isoStamp(now, -6), updatedAt: isoStamp(now, -1), ...fields, @@ -145,6 +159,7 @@ export function buildShowcase(now = new Date()) { // Sets owners, status, priority, due dates, dependencies, notes and custom fields on // the live config departments (mutating them, like the app's own Auto-Assign does). +/** @param {Department[]} departments @param {Employee[]} employees @param {Date} [now] */ export function applyShowcaseTasks(departments, employees, now = new Date()) { const workload = new Map(employees.map(emp => [emp.name, 0])); const byId = new Map(departments.map(dept => [dept.id, dept])); @@ -167,8 +182,9 @@ export function applyShowcaseTasks(departments, employees, now = new Date()) { } // Least-loaded person among those with affinity — same idea as the app's Auto-Assign. - const owner = [...pool].sort((a, b) => workload.get(a.name) - workload.get(b.name))[0]; - workload.set(owner.name, workload.get(owner.name) + 1); + const load = (/** @type {Employee} */ emp) => workload.get(emp.name) ?? 0; // every employee starts at 0 + const owner = /** @type {Employee} */ ([...pool].sort((a, b) => load(a) - load(b))[0]); // pool is non-empty here + workload.set(owner.name, load(owner) + 1); task.owner = owner.name; // Deterministic spread so the same demo always looks the same. @@ -179,6 +195,7 @@ export function applyShowcaseTasks(departments, employees, now = new Date()) { }); }); + /** @param {string} deptId @param {number} blockedIdx @param {number} blockerIdx */ const link = (deptId, blockedIdx, blockerIdx) => { const tasks = byId.get(deptId)?.tasks; const blocked = tasks?.[blockedIdx]; diff --git a/js/state.js b/js/state.js index ca50d1a..7edfce0 100644 --- a/js/state.js +++ b/js/state.js @@ -1,33 +1,51 @@ +/** @import { OrgData, Team, WorkOrder, AuditEntry, Portfolio, PayloadDepartment } from './types.js' */ // Shared mutable application state and constants. // All modules import state variables from here. // Use the setter functions to reassign top-level lets — ES module importers // get live bindings for reading but cannot reassign across module boundaries. // ── Config (populated once from config.json fetch in app.js) ───────────────── +/** @type {OrgData | null} */ export let orgData = null; +/** @type {Record | null} */ export let defaultAffinities = null; +/** @type {Record | null} */ export let ownerColors = null; +/** @param {OrgData | null} v */ export function setOrgData(v) { orgData = v; } +/** @param {Record | null} v */ export function setDefaultAffinities(v) { defaultAffinities = v; } +/** @param {Record | null} v */ export function setOwnerColors(v) { ownerColors = v; } // ── View state ──────────────────────────────────────────────────────────────── +/** @type {string} */ export let currentView = 'tracking'; +/** @param {string} v */ export function setCurrentView(v) { currentView = v; } // ── Team & task state ───────────────────────────────────────────────────────── +/** @type {Team} */ export let teamData = { employees: [] }; +/** @type {WorkOrder[]} */ export let workOrders = []; +/** @type {AuditEntry[]} */ export let auditLog = []; +/** @type {Portfolio} */ export let portfolio = { properties: [], vendors: [], tenants: [] }; +/** @param {Team} v */ export function setTeamData(v) { teamData = v; } +/** @param {WorkOrder[]} v */ export function setWorkOrders(v) { workOrders = v; } +/** @param {AuditEntry[]} v */ export function setAuditLog(v) { auditLog = v; } +/** @param {Portfolio} v */ export function setPortfolio(v) { portfolio = v; } // ── Map interaction state ───────────────────────────────────────────────────── +/** @type {{ hiddenOwners: Set, focusedOwner: string | null, focusedDept: string | null }} */ export const mapState = { hiddenOwners: new Set(), focusedOwner: null, @@ -35,10 +53,14 @@ export const mapState = { }; // ── Undo & UI transient state ───────────────────────────────────────────────── +/** @type {PayloadDepartment[] | null} */ export let _undoSnapshot = null; +/** @type {string} */ export let _selectedColor = '#e53935'; // default to first palette entry +/** @param {PayloadDepartment[] | null} v */ export function setUndoSnapshot(v) { _undoSnapshot = v; } +/** @param {string} v */ export function setSelectedColor(v) { _selectedColor = v; } // ── Constants ───────────────────────────────────────────────────────────────── @@ -62,14 +84,19 @@ export const PRIORITY_LABELS = { 'low': 'Low' }; +/** @type {ReadonlyArray} */ export const STATUS_CYCLE = ['todo', 'in-progress', 'blocked', 'done']; +/** @type {ReadonlyArray} */ export const PRIORITY_CYCLE = ['high', 'medium', 'low']; // Views interpolate status/priority into class names and data-* attributes, so // they always go through these guards — an unknown value can never reach markup. -export const asStatus = v => (STATUS_CYCLE.includes(v) ? v : 'todo'); -export const asPriority = v => (PRIORITY_CYCLE.includes(v) ? v : 'medium'); +/** @param {unknown} v @returns {string} */ +export const asStatus = v => (STATUS_CYCLE.includes(/** @type {string} */ (v)) ? /** @type {string} */ (v) : 'todo'); +/** @param {unknown} v @returns {string} */ +export const asPriority = v => (PRIORITY_CYCLE.includes(/** @type {string} */ (v)) ? /** @type {string} */ (v) : 'medium'); +/** @type {ReadonlyArray} */ export const WO_STATUS_CYCLE = ['submitted', 'scheduled', 'in-progress', 'completed']; export const WO_STATUS_LABELS = { 'submitted': 'Submitted', @@ -123,8 +150,10 @@ const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; const FALLBACK_HEX = '#607d8b'; // Always a plain #rrggbb value: views put colors straight into style attributes. +/** @param {unknown} v @returns {string} */ export const asHex = v => (typeof v === 'string' && HEX_COLOR.test(v) ? v : FALLBACK_HEX); +/** @param {string} name @returns {string} */ export function getEmployeeHex(name) { if (name === 'UNOWNED') return '#d32f2f'; const emp = teamData.employees.find(e => e.name === name); @@ -137,10 +166,12 @@ export function getEmployeeNames() { } // Tallies tasks per employee. Returns Map. +/** @returns {Map} */ export function buildWorkloadMap() { + /** @type {Map} */ const counts = new Map(); getEmployeeNames().forEach(name => counts.set(name, 0)); - orgData.departments.forEach(dept => { + /** @type {OrgData} */ (orgData).departments.forEach(dept => { // loaded at boot, before any view calls this dept.tasks.forEach(task => { if (task.owner !== 'UNOWNED') { counts.set(task.owner, (counts.get(task.owner) || 0) + 1); @@ -151,8 +182,9 @@ export function buildWorkloadMap() { } // Returns the total number of UNOWNED tasks across all departments. +/** @returns {number} */ export function countUnowned() { - return orgData.departments.reduce( + return /** @type {OrgData} */ (orgData).departments.reduce( (sum, dept) => sum + dept.tasks.filter(t => t.owner === 'UNOWNED').length, 0 ); diff --git a/js/stateSchema.js b/js/stateSchema.js index b651ad7..5d038ee 100644 --- a/js/stateSchema.js +++ b/js/stateSchema.js @@ -1,3 +1,22 @@ +/** @import { Department, Team, WorkOrder, Portfolio, OrgData, WorkspacePayload, ImportReport, Issue } from './types.js' */ + +/** + * A department as it appears in an untrusted file: nothing is known until it is checked. + * @typedef {{ id?: unknown, name?: unknown, tasks?: unknown }} LooseDepartment + */ + +/** + * An untrusted workspace file. Every field is `unknown` until validateImportedState has checked it. + * @typedef {object} LooseFile + * @property {unknown} [schema] + * @property {unknown} [schemaVersion] + * @property {unknown} [company] + * @property {unknown} [departments] + * @property {{ employees?: unknown } | null} [team] + * @property {unknown} [workOrders] + * @property {{ properties?: unknown, tenants?: unknown, vendors?: unknown } | null} [portfolio] + */ + import { normalizeSavedTask, sanitizeWorkspace } from './normalize.js'; import { makeIssue, shorten } from './schema.js'; import { findConfigTask } from './taskIdentity.js'; @@ -8,6 +27,10 @@ export const STATE_SCHEMA_VERSION = 4; export const STATE_SCHEMA_NAME = 'pm-ops-map-state'; export const MAX_REVIEW_ISSUES = 100; +/** + * @param {{ company: string, departments: Department[], team: Team, workOrders: WorkOrder[], portfolio: Portfolio, exportedAt?: string }} parts + * @returns {WorkspacePayload} + */ export function buildStatePayload({ company, departments, @@ -44,13 +67,19 @@ export function buildStatePayload({ }; } +/** + * @param {LooseFile | null | undefined} data Parsed JSON from a file, the clipboard or a sync server — nothing is trusted yet. + * @param {OrgData} orgData The live config departments the file is matched against. + * @returns {ImportReport} + */ export function validateImportedState(data, orgData) { + /** @type {ImportReport} */ const report = { ok: true, errors: [], warnings: [], schemaVersion: data?.schemaVersion || 1, - company: data?.company || '', + company: String(data?.company || ''), matchedDepartments: 0, matchedTasks: 0, skippedDepartments: 0, @@ -80,7 +109,7 @@ export function validateImportedState(data, orgData) { report.warnings.push(`Unexpected schema "${data.schema}". PM Ops Map will import compatible department data only.`); } if (Number(data?.schemaVersion || 1) > STATE_SCHEMA_VERSION) { - report.warnings.push(`This file was exported by a newer schema version (${data.schemaVersion}). Unknown fields will be ignored.`); + report.warnings.push(`This file was exported by a newer schema version (${data?.schemaVersion}). Unknown fields will be ignored.`); } if (report.errors.length) { @@ -88,8 +117,10 @@ export function validateImportedState(data, orgData) { return report; } + /** @type {Issue[]} */ const allIssues = []; - data.departments.forEach((savedDept, deptIndex) => { + const savedDepartments = /** @type {LooseDepartment[]} */ (data?.departments); // checked by the Array.isArray test above + savedDepartments.forEach((savedDept, deptIndex) => { const deptPath = `departments[${deptIndex}]`; const dept = orgData.departments.find(item => item.id === savedDept?.id); if (!dept) { @@ -162,6 +193,7 @@ export function validateImportedState(data, orgData) { return report; } +/** @param {ImportReport} report @returns {string} */ export function formatImportReport(report) { const lines = [ 'Import validation report', @@ -188,6 +220,7 @@ export function formatImportReport(report) { return lines.join('\n'); } +/** @param {unknown} value @returns {boolean} */ function isValidISODateValue(value) { if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const d = new Date(value + 'T00:00:00'); diff --git a/js/storage.js b/js/storage.js index 1737719..57e79cf 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1,3 +1,4 @@ +/** @import { Issue, AuditEntry, OrgData } from './types.js' */ // localStorage persistence, toast notifications, and company profile helpers. import { orgData, teamData, setTeamData, workOrders, setWorkOrders, @@ -28,17 +29,25 @@ export const BACKUP_KEY = namespacedKey('pm-ops-backups-v1'); export const SYNC_CONFIG_KEY = namespacedKey('pm-ops-sync-config-v1'); const MAX_BACKUPS = 5; +/** + * The company profile saved by onboarding (all fields optional: older saves lack some). + * @typedef {{ company?: string, portfolioSize?: string, focus?: string, configuredAt?: string }} OpsProfile + */ + // ── Repairs made while loading saved data ───────────────────────────────────── // Everything read back from localStorage goes through normalize.js. When that had to // reset, clear or drop something, the loader records it here (source + field path + // sentence) and warns once in the console, so a silent repair can still be diagnosed. +/** @type {{ source: string, path: string, code: string, message: string }[]} */ let loadIssues = []; export function getLoadIssues() { return loadIssues.map(issue => ({ ...issue })); } export function clearLoadIssues() { loadIssues = []; } +/** @type {Record} */ const LOAD_SOURCE_LABELS = { tasks: 'tasks', team: 'team', workOrders: 'work orders', portfolio: 'portfolio' }; +/** @param {string} source @param {Issue[]} errors @param {number} [omitted] */ function recordLoadIssues(source, errors, omitted = 0) { loadIssues = loadIssues.filter(issue => issue.source !== source); if (!errors.length) return; @@ -51,7 +60,8 @@ function recordLoadIssues(source, errors, omitted = 0) { } // ── Toast notifications ─────────────────────────────────────────────────────── -let _toastTimer = null; +/** @type {ReturnType | undefined} */ +let _toastTimer; export function showSaveToast(isError = false, isQuota = false) { const toast = document.getElementById('save-toast'); @@ -69,6 +79,7 @@ export function showSaveToast(isError = false, isQuota = false) { _toastTimer = setTimeout(() => toast.classList.remove('visible'), isQuota ? 6000 : 2000); } +/** @param {string} msg @param {string} cls @param {number} [duration] */ export function _showActionToast(msg, cls, duration = 3000) { const toast = document.getElementById('save-toast'); if (!toast) return; @@ -83,7 +94,7 @@ export function _showActionToast(msg, cls, duration = 3000) { // ── Task data persistence ───────────────────────────────────────────────────── export function saveToStorage() { try { - const payload = orgData.departments.map(dept => ({ + const payload = liveDepartments().map(dept => ({ id: dept.id, tasks: dept.tasks.map(t => ({ id: t.id, @@ -114,8 +125,9 @@ export function loadFromStorage() { if (!raw) return; const saved = JSON.parse(raw); if (!Array.isArray(saved)) return; + /** @type {Issue[]} */ const issues = []; - applySavedTasks(orgData.departments, saved, { issues }); + applySavedTasks(liveDepartments(), saved, { issues }); recordLoadIssues('tasks', issues); } catch (e) { console.error('Failed to parse saved task data — data preserved in storage:', e); @@ -148,6 +160,7 @@ export const RESET_EVENT = 'pm-ops-reset'; // Every localStorage key the app writes. The "Everything" reset is derived from // this list, and a unit test checks that every exported *_KEY constant is in it, // so a newly added key cannot be forgotten and silently survive a reset. +/** @type {string[]} */ export const ALL_STORAGE_KEYS = [ STORAGE_KEY, COMPANY_KEY, OPS_PROFILE_KEY, NAV_COMPACT_KEY, TEAM_KEY, WORKORDERS_KEY, PORTFOLIO_KEY, AUDIT_KEY, GUIDE_KEY, NOTIF_DATE_KEY, LAUNCH_CHECKLIST_KEY, BACKUP_KEY, @@ -156,6 +169,7 @@ export const ALL_STORAGE_KEYS = [ // Automatic backups are full copies of the workspace (tenant records included), // so any reset that claims to remove the workspace must remove them too. +/** @type {Record} */ export const RESET_GROUPS = { '1': { label: 'task names, owners, statuses, due dates, dependencies, notes, and custom fields (automatic backups are kept)', @@ -175,6 +189,7 @@ export const RESET_GROUPS = { }, }; +/** @param {unknown} choice */ export function confirmResetStorage(choice) { const selected = RESET_GROUPS[String(choice).trim()]; if (!selected) { @@ -191,13 +206,18 @@ export function confirmResetStorage(choice) { // ── Team data persistence ───────────────────────────────────────────────────── const knownDeptIds = () => orgData?.departments?.map(d => d.id); +// Config is loaded at boot, before anything saves or restores, so these never run without it. +const liveDepartments = () => /** @type {OrgData} */ (orgData).departments; + export function seedDefaultTeam() { - teamData.employees = Object.entries(ownerColors) + const colors = /** @type {NonNullable} */ (ownerColors); + const affinities = /** @type {NonNullable} */ (defaultAffinities); + teamData.employees = Object.entries(colors) .filter(([name]) => name !== 'UNOWNED') .map(([name, info]) => ({ name, hex: info.hex, - affinities: defaultAffinities[name] ? [...defaultAffinities[name]] : [] + affinities: affinities[name] ? [...affinities[name]] : [] })); } @@ -289,6 +309,7 @@ export function saveAuditLog() { } catch (_) { /* silent fail */ } } +/** @param {string} action @param {Partial>} [details] */ export function logAudit(action, details = {}) { auditLog.unshift({ ts: new Date().toISOString(), action, ...details }); if (auditLog.length > 500) auditLog.length = 500; @@ -300,14 +321,16 @@ export function getCompanyName() { return localStorage.getItem(COMPANY_KEY) || 'Your Company'; } +/** @returns {OpsProfile} */ export function getOpsProfile() { try { - return JSON.parse(localStorage.getItem(OPS_PROFILE_KEY)) || {}; + return JSON.parse(localStorage.getItem(OPS_PROFILE_KEY) ?? 'null') || {}; } catch (_) { return {}; } } +/** @param {string} name */ export function applyCompanyName(name) { const heading = document.getElementById('company-heading'); if (heading) heading.textContent = name.toUpperCase(); @@ -347,7 +370,7 @@ export function saveBackupSnapshot(label = 'Auto-save') { label, company: getCompanyName(), state: JSON.stringify({ - departments: orgData.departments, + departments: liveDepartments(), team: teamData, workOrders, portfolio, @@ -393,6 +416,7 @@ export function closeBackupsModal() { document.getElementById('backups-modal')?.classList.remove('visible'); } +/** @param {number} index */ export function restoreBackupSnapshot(index) { const backups = loadBackupSnapshots(); const backup = backups[index]; @@ -404,7 +428,7 @@ export function restoreBackupSnapshot(index) { // Restore replaces state, so absent task fields reset to defaults (fill) and // notes/customFields come back with everything else. - applySavedTasks(orgData.departments, departments, { fill: true }); + applySavedTasks(liveDepartments(), departments, { fill: true }); const clean = sanitizeWorkspace( { team, workOrders: wos, portfolio: port }, @@ -434,6 +458,7 @@ export function restoreBackupSnapshot(index) { } } +/** @param {unknown} str @returns {string} */ function escapeBackup(str) { return String(str ?? '').replace(/&/g,'&').replace(//g,'>'); } diff --git a/js/sync.js b/js/sync.js index 162ed4f..f82e970 100644 --- a/js/sync.js +++ b/js/sync.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. // Optional team sync: talks to the self-hosted sync server in server/ so a // small team can share one workspace across browsers without a full backend. // The app works exactly as before if this is never connected — everything diff --git a/js/syncEnvelope.js b/js/syncEnvelope.js index cc0b136..aef2710 100644 --- a/js/syncEnvelope.js +++ b/js/syncEnvelope.js @@ -7,19 +7,23 @@ import { cleanTimestamp } from './schema.js'; export class SyncResponseError extends Error { + /** @param {string} message */ constructor(message) { super(`The sync server sent an unexpected response: ${message}`); this.name = 'SyncResponseError'; } } +/** @param {unknown} v @returns {v is Record} */ const isObject = v => !!v && typeof v === 'object' && !Array.isArray(v); +/** @param {unknown} body @returns {Record} */ function requireObject(body) { if (!isObject(body)) throw new SyncResponseError('the reply was not a JSON object.'); return body; } +/** @param {Record} body @returns {number} */ function requireVersion(body) { const v = body.version; if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0) { @@ -28,21 +32,25 @@ function requireVersion(body) { return v; } +/** @param {unknown} value @returns {string} */ const stampOrEmpty = value => cleanTimestamp(value) || ''; // A pull response, or the `current` workspace attached to a 409 conflict. +/** A pull response, or the `current` workspace of a 409. @param {unknown} body @returns {{ state: Record, version: number, updatedAt: string }} */ export function parseVersionedState(body) { - requireObject(body); - if (!isObject(body.state)) throw new SyncResponseError('"state" must be an object.'); - return { state: body.state, version: requireVersion(body), updatedAt: stampOrEmpty(body.updatedAt) }; + const obj = requireObject(body); + if (!isObject(obj.state)) throw new SyncResponseError('"state" must be an object.'); + return { state: obj.state, version: requireVersion(obj), updatedAt: stampOrEmpty(obj.updatedAt) }; } +/** @param {unknown} body @returns {{ version: number, updatedAt: string }} */ export function parseVersionInfo(body) { - requireObject(body); - return { version: requireVersion(body), updatedAt: stampOrEmpty(body.updatedAt) }; + const obj = requireObject(body); + return { version: requireVersion(obj), updatedAt: stampOrEmpty(obj.updatedAt) }; } +/** @param {unknown} body @returns {{ version: number, updatedAt: string, created: boolean }} */ export function parsePushResult(body) { - requireObject(body); - return { version: requireVersion(body), updatedAt: stampOrEmpty(body.updatedAt), created: body.created === true }; + const obj = requireObject(body); + return { version: requireVersion(obj), updatedAt: stampOrEmpty(obj.updatedAt), created: obj.created === true }; } diff --git a/js/taskIdentity.js b/js/taskIdentity.js index a9ee79f..1423a5b 100644 --- a/js/taskIdentity.js +++ b/js/taskIdentity.js @@ -1,3 +1,5 @@ +/** @import { Department, Task } from './types.js' */ +/** @typedef {Omit & { id?: string }} RawTask */ // Task identity: how a saved/imported/backed-up task row finds its starter task. // // Every starter task in config.json has a permanent `id` (e.g. "maintenance-014"). @@ -13,10 +15,12 @@ // Same character set as the other ids that reach attributes and selectors (see normalize.js). const TASK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/; +/** @param {unknown} value @returns {value is string} */ export function isTaskId(value) { return typeof value === 'string' && TASK_ID_RE.test(value); } +/** @param {unknown} name @returns {string} */ function slugify(name) { const slug = String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30).replace(/-+$/, ''); return slug || 'task'; @@ -26,6 +30,11 @@ function slugify(name) { // as `_configName` and makes sure every task has a unique, safe `id`. Tasks from a // custom config without ids get a deterministic fallback derived from their starter // name, so the same config always produces the same ids. +/** + * @template {{ id: string, tasks: RawTask[] }} D + * @param {D[]} departments Departments whose tasks may still lack an id (a config without ids). + * @returns {Department[]} The same departments, now with every task carrying a unique `id`. + */ export function stampTaskIdentity(departments) { const used = new Set(); departments.forEach(dept => dept.tasks.forEach(task => { @@ -41,15 +50,23 @@ export function stampTaskIdentity(departments) { used.add(id); task.id = id; })); - return departments; + // The loop above gave every task an id, which the RawTask input type could not promise. + return /** @type {Department[]} */ (/** @type {unknown} */ (departments)); } +/** @param {Task} task @param {string} key @returns {boolean} */ function nameMatches(task, key) { return task._configName === key || (Array.isArray(task.aliases) && task.aliases.includes(key)); } // Finds the live config task a saved row refers to, or null. // `saved` may carry { id, _configName, name }; `deptId` is the department it was saved under. +/** + * @param {Department[]} departments + * @param {{ id?: unknown, _configName?: unknown, name?: unknown } | null | undefined} saved + * @param {{ deptId?: string }} [where] + * @returns {Task | null} + */ export function findConfigTask(departments, saved, { deptId } = {}) { if (!saved || typeof saved !== 'object') return null; if (isTaskId(saved.id)) { @@ -65,6 +82,11 @@ export function findConfigTask(departments, saved, { deptId } = {}) { } // Resolves a task's `blockedBy` reference ({ deptId, taskId?, configName?, name }) to the blocking task. +/** + * @param {Department[]} departments + * @param {{ deptId?: string, taskId?: string, configName?: string } | null | undefined} blockedBy + * @returns {Task | null} + */ export function findBlockerTask(departments, blockedBy) { if (!blockedBy || typeof blockedBy !== 'object') return null; return findConfigTask(departments, { id: blockedBy.taskId, _configName: blockedBy.configName }, { deptId: blockedBy.deptId }); diff --git a/js/templates.js b/js/templates.js index e29f00b..ad39cb6 100644 --- a/js/templates.js +++ b/js/templates.js @@ -76,6 +76,7 @@ export const SOP_TEMPLATES = [ }, ]; +/** @param {string | number | Date} from @param {number} days @returns {string} */ function _isoDateOffset(from, days) { const d = new Date(from); d.setDate(d.getDate() + days); diff --git a/js/types.js b/js/types.js new file mode 100644 index 0000000..a2e909e --- /dev/null +++ b/js/types.js @@ -0,0 +1,213 @@ +// Shared type definitions (JSDoc). This file has no runtime code: it exists so the +// shapes below can be imported into any module's JSDoc: +// +// /** @import { Task, Employee } from './types.js' */ +// +// `npm run typecheck` checks them. Status / priority fields are plain strings whose +// allowed values live in state.js (STATUS_CYCLE, PRIORITY_CYCLE, WO_STATUS_CYCLE) and +// are enforced at the boundary by normalize.js, so a value of the right *type* is not +// necessarily an allowed *value* until it has been through a sanitizer. + +/** + * A dependency: this task is blocked until another task is done. `taskId` is the + * permanent reference; `configName` is what versions before task ids wrote. + * @typedef {object} BlockedBy + * @property {string} deptId Department that held the blocker when the link was made. + * @property {string} [taskId] Permanent id of the blocking task (schema v4+). + * @property {string} [configName] Starter name of the blocking task (legacy reference). + * @property {string} name Display name of the blocker when the link was made. + */ + +/** + * One responsibility. Starter tasks come from config.json; user edits are merged on top. + * @typedef {object} Task + * @property {string} id Permanent id such as "maintenance-014". + * @property {string} name Visible name; the user may edit it. + * @property {string} owner Employee name, or "UNOWNED". + * @property {string} [status] "todo" | "in-progress" | "blocked" | "done". + * @property {string} [priority] "high" | "medium" | "low". + * @property {string | null} [dueDate] YYYY-MM-DD. + * @property {BlockedBy | null} [blockedBy] + * @property {string | null} [notes] + * @property {Record | null} [customFields] + * @property {string} [_configName] The starter name this task had at boot (legacy match key). + * @property {string[]} [aliases] Former starter names, listed in config.json after a reword. + */ + +/** + * @typedef {object} Department + * @property {string} id + * @property {string} name + * @property {string} color + * @property {Task[]} tasks + */ + +/** + * @typedef {object} OrgData + * @property {string} [company] + * @property {Department[]} departments + */ + +/** + * @typedef {object} Employee + * @property {string} name + * @property {string} hex "#rrggbb". + * @property {string[]} affinities Department ids this person is best placed to own. + */ + +/** @typedef {{ employees: Employee[] }} Team */ + +/** + * @typedef {object} WorkOrder + * @property {string} id + * @property {string} property + * @property {string} unit + * @property {string} tenant + * @property {string} title + * @property {string} notes + * @property {string} priority "high" | "medium" | "low". + * @property {string} status "submitted" | "scheduled" | "in-progress" | "completed". + * @property {string} assignee Employee name or "UNASSIGNED". + * @property {string} vendor + * @property {string | null} dueDate + * @property {number} cost + * @property {string} [createdAt] ISO timestamp. + * @property {string} [updatedAt] + */ + +/** + * @typedef {object} Property + * @property {string} id + * @property {string} name + * @property {number} units + * @property {string} owner Owner / client. + * @property {string} notes + * @property {string} documentUrl http(s) link or "". + * @property {string} [createdAt] + * @property {string} [updatedAt] + */ + +/** + * @typedef {object} Tenant + * @property {string} id + * @property {string} name + * @property {string} propertyId Id of an existing Property, or "". + * @property {string} unit + * @property {string} status "active" | "applicant" | "notice" | "past". + * @property {string} phone + * @property {string} email + * @property {number} rent + * @property {string} leaseStart YYYY-MM-DD or "". + * @property {string} leaseEnd + * @property {number} balanceDue + * @property {string} [lastPaymentDate] + * @property {string} documentUrl + * @property {string} [createdAt] + * @property {string} [updatedAt] + */ + +/** + * @typedef {object} Vendor + * @property {string} id + * @property {string} name + * @property {string} trade + * @property {string} phone + * @property {string} email + * @property {string} documentUrl + * @property {string} [createdAt] + * @property {string} [updatedAt] + */ + +/** @typedef {{ properties: Property[], tenants: Tenant[], vendors: Vendor[] }} Portfolio */ + +/** + * @typedef {object} AuditEntry + * @property {string} ts + * @property {string} action + * @property {string} [dept] + * @property {string} [task] + * @property {string} [title] + * @property {string} [label] + * @property {string | number | null} [from] + * @property {string | number | null} [to] + * @property {number} [count] + */ + +/** + * What a task looks like inside a saved/exported payload. + * @typedef {object} PayloadTask + * @property {string} [id] + * @property {string} [_configName] + * @property {string} name + * @property {string} owner + * @property {string} [status] + * @property {string} [priority] + * @property {string | null} [dueDate] + * @property {BlockedBy | null} [blockedBy] + * @property {string | null} [notes] + * @property {Record | null} [customFields] + */ + +/** + * @typedef {object} PayloadDepartment + * @property {string} id + * @property {string} [name] + * @property {PayloadTask[]} tasks + */ + +/** + * The portable workspace (JSON export, clipboard copy, Team Sync snapshot). Schema v4. + * @typedef {object} WorkspacePayload + * @property {string} schema Always "pm-ops-map-state". + * @property {number} schemaVersion + * @property {string} app + * @property {string} company + * @property {string} exported ISO timestamp. + * @property {PayloadDepartment[]} departments + * @property {Team} team + * @property {WorkOrder[]} workOrders + * @property {Portfolio} portfolio + */ + +/** + * One thing a validator had to repair, drop or reject, located by field path. + * @typedef {object} Issue + * @property {string} path e.g. "workOrders[2].priority". + * @property {string} [field] + * @property {string} code e.g. "invalid_value", "missing_required", "unknown_reference". + * @property {string} message A sentence for the import review. + */ + +/** + * @typedef {object} CollectionStats + * @property {number} kept + * @property {number} dropped + * @property {number} repaired + */ + +/** + * The import review's picture of a file before anything is applied. + * @typedef {object} ImportReport + * @property {boolean} ok + * @property {string[]} errors + * @property {string[]} warnings + * @property {unknown} schemaVersion Exactly as written in the file (untrusted: not necessarily a number); display it with String(). + * @property {string} company + * @property {number} matchedDepartments + * @property {number} matchedTasks + * @property {number} skippedDepartments + * @property {number} skippedTasks + * @property {number} invalidDueDates + * @property {number} invalidTasks + * @property {number} repairedRecords + * @property {number} droppedRecords + * @property {Issue[]} issues Capped; see issueCount for the true total. + * @property {number} issueCount + * @property {number} teamMembers + * @property {number} workOrders + * @property {number} properties + * @property {number} tenants + * @property {number} vendors + */ + +export {}; diff --git a/js/ui.js b/js/ui.js index e058b1d..c565740 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. // Global UI helpers: stats bar, beacons, onboarding, welcome guide, notifications. import { orgData, teamData, workOrders, countUnowned, diff --git a/js/utils.js b/js/utils.js index 1126522..7a86e19 100644 --- a/js/utils.js +++ b/js/utils.js @@ -5,6 +5,7 @@ // exact file — there is no second copy to keep in sync. // Escapes text for use as element content or inside a quoted attribute value. +/** @param {unknown} str @returns {string} */ function escapeHtml(str) { return String(str == null ? '' : str) .replace(/&/g, '&') @@ -20,16 +21,19 @@ function escapeHtml(str) { // inside the value — say """ — stays literal text after the attribute is // parsed instead of turning back into a quote that ends the JS string. // Prefer data-* attributes (see CONTRIBUTING.md) for anything user-controlled. +/** @param {unknown} val @returns {string} */ function jsonAttr(val) { return escapeHtml(JSON.stringify(String(val == null ? '' : val))); } +/** @param {HTMLElement | null | undefined} el */ function shakeInput(el) { if (!el) return; el.classList.add('shake'); setTimeout(() => el.classList.remove('shake'), 400); } +/** @param {unknown} e @returns {boolean} */ function _isQuotaError(e) { return e instanceof DOMException && ( e.code === 22 || e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED' @@ -37,6 +41,7 @@ function _isQuotaError(e) { } // Returns true only for strings that are valid YYYY-MM-DD ISO dates. +/** @param {unknown} str @returns {boolean} */ function isValidISODate(str) { if (typeof str !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(str)) return false; const d = new Date(str + 'T00:00:00'); @@ -53,11 +58,13 @@ function getTodayISO() { } // Returns true if the task has a due date in the past and is not done. +/** @param {{ dueDate?: string | null, status?: string }} task @returns {boolean} */ function isTaskOverdue(task) { return !!(task.dueDate && isValidISODate(task.dueDate) && task.dueDate < getTodayISO() && (task.status || 'todo') !== 'done'); } // Formats YYYY-MM-DD to "Dec 15" without toLocaleDateString for consistency. +/** @param {string | null | undefined} dateStr @returns {string} */ function formatDueChip(dateStr) { if (!dateStr) return ''; const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; @@ -70,6 +77,7 @@ function formatDueChip(dateStr) { } // Returns the inline HTML that shows done and blocked counts in the dept header. +/** @param {number} doneCount @param {number} blockedCount @returns {string} */ function buildDeptCompletionText(doneCount, blockedCount) { const parts = []; if (doneCount > 0) parts.push(`· ✓ ${doneCount} done`); @@ -77,10 +85,12 @@ function buildDeptCompletionText(doneCount, blockedCount) { return parts.join(' '); } +/** @param {unknown} value @returns {string} */ function _slugify(value) { return String(value || 'pm-ops').replace(/[^a-z0-9]+/gi, '-').toLowerCase().replace(/^-|-$/g, '') || 'pm-ops'; } +/** @param {BlobPart} content @param {string} mimeType @param {string} filename */ function _downloadBlob(content, mimeType, filename) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); @@ -93,8 +103,9 @@ function _downloadBlob(content, mimeType, filename) { URL.revokeObjectURL(url); } +/** @param {string | null | undefined} isoStr @returns {string} */ function formatWODate(isoStr) { - const d = new Date(isoStr); + const d = new Date(isoStr ?? NaN); // null/undefined fall through to the !isoStr guard below if (!isoStr || Number.isNaN(d.getTime())) return ''; try { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); @@ -102,6 +113,7 @@ function formatWODate(isoStr) { } // Formats a number as USD, e.g. 1200 -> "$1,200.00". +/** @param {unknown} amount @returns {string} */ function formatCurrency(amount) { return `$${Number(amount || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } @@ -109,11 +121,16 @@ function formatCurrency(amount) { // Returns { tone, label, days } describing how close a tenant's lease end // date is, or null if the tenant has no valid lease end date on file. // todayISO is injectable for testing; defaults to the real current date. +/** + * @param {{ leaseEnd?: string } | null | undefined} tenant + * @param {string} [todayISO] + * @returns {{ tone: string, label: string, days: number } | null} + */ function getLeaseStatus(tenant, todayISO = getTodayISO()) { if (!tenant || !isValidISODate(tenant.leaseEnd)) return null; const today = new Date(todayISO + 'T00:00:00'); const end = new Date(tenant.leaseEnd + 'T00:00:00'); - const days = Math.round((end - today) / 86400000); + const days = Math.round((end.getTime() - today.getTime()) / 86400000); if (days < 0) return { tone: 'danger', label: 'Lease expired', days }; if (days <= 60) return { tone: 'warn', label: days === 0 ? 'Lease ends today' : `Lease ends in ${days}d`, days }; @@ -123,6 +140,10 @@ function getLeaseStatus(tenant, todayISO = getTodayISO()) { // Returns { tone, label, balance } if a tenant has a positive outstanding // balance, or null if they're current. Tone escalates to danger once the // balance reaches a full month's rent. +/** + * @param {{ balanceDue?: number, rent?: number } | null | undefined} tenant + * @returns {{ tone: string, label: string, balance: number } | null} + */ function getDelinquencyStatus(tenant) { const balance = Number(tenant?.balanceDue || 0); if (!(balance > 0)) return null; @@ -142,6 +163,7 @@ const MAX_URL_LENGTH = 2048; // origin, so anything bigger could not be stored anyway — and reading a huge // file into memory would just freeze the tab. const MAX_IMPORT_BYTES = 5 * 1024 * 1024; +/** @param {unknown} url @returns {string} */ function normalizeUrl(url) { if (typeof url !== 'string') return ''; const trimmed = url.trim(); @@ -157,6 +179,7 @@ function normalizeUrl(url) { // Only allows absolute http(s) links — this gets embedded as an , so // rejecting anything else up front means callers never need to think about // scheme-based injection at render time. +/** @param {unknown} url @returns {boolean} */ function isSafeUrl(url) { return normalizeUrl(url) !== ''; } @@ -165,8 +188,11 @@ function isSafeUrl(url) { // embedded newlines inside quotes, escaped "" quotes, and CRLF/LF endings. // Returns an array of row arrays; drops a single trailing blank line so a // file that ends with a newline doesn't produce a phantom empty row. +/** @param {unknown} text @returns {string[][]} */ function parseCSV(text) { + /** @type {string[][]} */ const rows = []; + /** @type {string[]} */ let row = []; let field = ''; let inQuotes = false; @@ -207,6 +233,7 @@ const CSV_FORMULA_TRIGGER = /^'*[=+\-@\t\r]/; const CSV_GUARDED = /^'+[=+\-@\t\r]/; // Encodes one cell as a quoted CSV field. +/** @param {unknown} value @returns {string} */ function csvCell(value) { let text = value == null ? '' : String(value); if (typeof value === 'string' && CSV_FORMULA_TRIGGER.test(text)) text = `'${text}`; @@ -214,19 +241,23 @@ function csvCell(value) { } // Builds a full CSV document (CRLF line endings) from an array of row arrays. +/** @param {unknown[][]} rows @returns {string} */ function toCSV(rows) { return rows.map(row => row.map(csvCell).join(',')).join('\r\n'); } // Reverses the guard added by csvCell so export -> edit -> re-import is lossless // (e.g. a phone number "+1 555 0100" comes back without the apostrophe). +/** @param {string} text @returns {string} */ function unguardCsvCell(text) { return typeof text === 'string' && CSV_GUARDED.test(text) ? text.slice(1) : text; } // Builds a case-insensitive header-name -> column-index map, e.g. for // matching a CSV's own column order against expected field names. +/** @param {unknown[] | null | undefined} headerRow @returns {Record} */ function buildCsvHeaderMap(headerRow) { + /** @type {Record} */ const map = {}; (headerRow || []).forEach((name, idx) => { const key = String(name || '').trim().toLowerCase(); diff --git a/js/views/map.js b/js/views/map.js index 783f59b..b8fb1ca 100644 --- a/js/views/map.js +++ b/js/views/map.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. // Map view: SVG flow diagram, dept panel, tooltips, and map controls. import { orgData, mapState, getEmployeeHex, diff --git a/js/views/portfolio.js b/js/views/portfolio.js index 0dac2a5..835320d 100644 --- a/js/views/portfolio.js +++ b/js/views/portfolio.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. import { portfolio, setPortfolio, workOrders } from '../state.js'; import { savePortfolio, saveWorkOrders, logAudit, _showActionToast, saveBackupSnapshot } from '../storage.js'; import { diff --git a/js/views/recurring.js b/js/views/recurring.js index 2f582b5..91bf884 100644 --- a/js/views/recurring.js +++ b/js/views/recurring.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. // Recurring task templates — generate common PM work-order checklists on demand. import { workOrders, setWorkOrders } from '../state.js'; import { saveWorkOrders, logAudit, _showActionToast } from '../storage.js'; diff --git a/js/views/team.js b/js/views/team.js index 9d57b0a..9abb373 100644 --- a/js/views/team.js +++ b/js/views/team.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. // Team Manager view: roster, auto-assign, workload dashboard, role playbooks, audit log. import { orgData, teamData, setTeamData, workOrders, diff --git a/js/views/tracking.js b/js/views/tracking.js index 08be2ed..a9ff7e9 100644 --- a/js/views/tracking.js +++ b/js/views/tracking.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. // Tracking view: department cards, task editing, owner picker, filter bar, // status/priority cycling, due dates, and task dependencies. import { diff --git a/js/views/workorders.js b/js/views/workorders.js index 52552a2..a69c2db 100644 --- a/js/views/workorders.js +++ b/js/views/workorders.js @@ -1,3 +1,4 @@ +// @ts-nocheck — TODO(types): not converted to checked JSDoc yet. Delete this line and fix what `npm run typecheck` reports. // Work Orders kanban: 4-column pipeline (Submitted → Scheduled → In Progress → Completed). import { workOrders, setWorkOrders, portfolio, diff --git a/package-lock.json b/package-lock.json index aae724c..20fd8b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "copy-webpack-plugin": "^14.0.0", "html-webpack-plugin": "^5.6.0", "jsdom": "^28.1.0", + "typescript": "^7.0.2", "vite": "^6.4.3", "vitest": "^5.0.3", "webpack": "^5.91.0", @@ -2036,6 +2037,346 @@ "@types/node": "*" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.3.tgz", @@ -6459,6 +6800,41 @@ "node": ">= 0.6" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici": { "version": "7.30.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", diff --git a/package.json b/package.json index 2105a2f..7926cd9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "test": "vitest run", + "typecheck": "tsc -p tsconfig.json", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", @@ -25,6 +26,7 @@ "copy-webpack-plugin": "^14.0.0", "html-webpack-plugin": "^5.6.0", "jsdom": "^28.1.0", + "typescript": "^7.0.2", "vite": "^6.4.3", "vitest": "^5.0.3", "webpack": "^5.91.0", diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f2239a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + // Type-checks the browser code with JSDoc types — no migration to .ts, no build step. + // `npm run typecheck`. Files that are not converted yet start with `// @ts-nocheck` + // (grep for it to see what is left); a test keeps the converted set from regressing. + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["js/**/*.js"], + "exclude": ["js/__tests__/**"] +}