Vibecode MaintenEase
track this build4 phases, 12 steps, beginner friendly0%A focused work-order and preventive-maintenance tracker is a weekend build, but dependable field use, notifications, permissions, backups, and long-lived asset history require ongoing operations.
You are building a lean indie version of MaintenEase. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # MaintenEase · indie build A single-site maintenance tracker whose hard problems are recurrence that neither duplicates nor avalanches, meter readings that go backwards when a gauge is replaced, and an append-only service history that is still readable in ten years. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and server-rendered templates, no frontend build step | the phone in a plant room with one bar of signal should get HTML | | Storage | SQLite with better-sqlite3, photographs on disk beside it | one file to back up, and one directory that must travel with it | | Scheduling | An in-process job with its last run persisted, and occurrences keyed by plan and due date | the uniqueness key is what stops a restart creating four copies of the same job | | Notification | One push channel configured by URL, with every failure logged and visible | a notification nobody can confirm arrived is worse than none | | History | Append-only work log rows, never edited, never deleted | the asset history is the part that is worth money in five years | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node 22 or newer** · free - Why: Runs the server, the scheduler and the test suite. - Get it: nodejs.org, or your package manager. - Verify: node --version prints v22 or higher - [ ] **An always-on machine on the site network** · a few pounds a month, or free on hardware you already run - Why: The scheduler has to run whether or not anyone is logged in, and a preventive plan that only generates when someone opens a browser is not a plan. - Get it: A small virtual machine or a box in the plant office, with the service starting at boot. - Verify: Occurrences appear for three consecutive days with nobody logged in - [ ] **A label printer and the stock you will actually use** · the printer, plus consumables - Why: Label size, material and the encoder's margin settings decide whether the system works in the field, and none of it is testable on a screen. - Get it: Any thermal label printer and the sticker stock that survives your environment. - Verify: A production-size label scans on a cheap phone in poor light - [ ] **A push notification endpoint** · free for a public topic; small if self-hosted - Why: Assignment and overdue alerts go through it, and its failures need to be visible rather than assumed. - Get it: A self-hosted or public push service with a topic URL you control. - Verify: A test alert arrives and a deliberately broken URL appears in the failure log - [ ] **Which plans anchor to the due date and which to completion** · free - Why: The two produce visibly different schedules after the first late job, and deciding per plan later means deciding inconsistently across a hundred assets. - Get it: Write the rule down, for example calendar inspections anchored to the due date and lubrication anchored to completion. - Verify: The rule is in the README and each seeded plan matches it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Predictive maintenance from sensor data. - Accounting and purchasing integrations. - Multi-site tenancy and single sign-on. - A native mobile application. - managed backups and hosted reliability - polished mobile field workflows - advanced analytics and predictive features - integrations, API, and SSO - onboarding and data-import support If one of those is essential to you, that is the reason to keep paying for MaintenEase, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · MaintenEase The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a work-order and preventive-maintenance tracker for one site whose asset history outlives the people who wrote it to replace MaintenEase. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and server-rendered templates, no frontend build step · the phone in a plant room with one bar of signal should get HTML - Storage: SQLite with better-sqlite3, photographs on disk beside it · one file to back up, and one directory that must travel with it - Scheduling: An in-process job with its last run persisted, and occurrences keyed by plan and due date · the uniqueness key is what stops a restart creating four copies of the same job - Notification: One push channel configured by URL, with every failure logged and visible · a notification nobody can confirm arrived is worse than none - History: Append-only work log rows, never edited, never deleted · the asset history is the part that is worth money in five years ### Data model (create this before Phase 1) - locations(id, name, parent_id) and assets(id, code, name, location_id, parent_asset_id, status, installed_on, notes) - assets.code is assigned once, printed on the label, and never reused even after the asset is scrapped - meters(id, asset_id, name, unit, kind) · kind is cumulative or resettable, which changes what a lower reading means - readings(id, meter_id, at, value, taken_by, source, offset_applied) · insert only, and a decrease is an event rather than a correction - pm_plans(id, asset_id, title, mode, interval_days, meter_id, interval_units, anchor, lead_days, checklist_json) · anchor is due_date or completion - pm_occurrences(id, plan_id, due_at, generated_at, generated_from_reading_id, work_order_id, skipped_count) · unique on plan_id and due_at - work_orders(id, code, asset_id, plan_id, priority, assignee_id, due_at, status, opened_at, closed_at) - work_log(id, work_order_id, at, actor_id, kind, text, minutes, parts_json, photo_path) · append only, and this is the history ### Phase 1 · Assets, codes and a label that scans in a plant room Build: Assign each asset a code at creation that is independent of any database identifier. Print a label carrying the code as a scannable symbol and as plain text underneath, with a clear margin around the symbol. Scanning opens the asset page. Test the label by printing it at the size you will actually use and scanning it in poor light with a cheap phone. Trap: Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. Trap: Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. Done when: An asset code is assigned at creation and survives an export and reimport unchanged, tested. No code is reused after an asset is scrapped, enforced by a constraint. A printed label at the production size scans on a cheap phone in poor light, recorded as a manual check with the size and the encoder settings written down. The code is legible as text on the label so a damaged symbol is still usable by a human. Do not build yet: No work orders, recurrence, meters or roles yet. ### Phase 2 · Work a technician can complete with one thumb Build: Queue views for My Work, Due Today, Overdue and Completed, with the common status changes as large buttons rather than a dropdown and a save. Accept a photograph straight from the camera. Make every write idempotent with a token so a double tap or a retried request does not create a second log entry. Trap: A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. Trap: A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. Done when: Submitting the same form twice with the same token creates one work log entry, tested by replaying the request. Every field view renders and is usable on a phone-sized viewport with touch targets at or above a stated size, checked. A photograph taken on a phone attaches to a work order and is stored on disk with the row recording its path. A queue view returns within a stated time with a realistic number of open work orders, measured against seeded data. Do not build yet: No recurrence, meters, roles or backups yet. ### Phase 3 · Recurrence that neither duplicates nor avalanches Build: Generate occurrences keyed uniquely on plan and due date so a second run is a no-op. Support both anchors: a plan anchored to the due date keeps its cadence regardless of when the work was done, and a plan anchored to completion schedules from the completion. When catching up after an outage, create one occurrence at the next due date and record how many were skipped. Trap: Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. Trap: Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. Done when: Running the generator twice in succession creates nothing the second time, tested. A plan anchored to the due date does not drift when work is completed late, and one anchored to completion schedules from the completion date, both tested with a fixed clock. Catching up after a stated outage creates one occurrence carrying the number skipped, tested. Closing a work order never generates its successor inside the request, verified by review. Do not build yet: No meter-based plans, roles or backups yet. ### Phase 4 · Meters, and the day a reading goes backwards Build: Record readings as insert-only rows with who took them. Drive meter plans from the difference since the last service reading rather than from any running total the app maintains. When a cumulative reading comes in lower than the previous one, raise a meter event, hold every plan on that meter, and require a human to confirm a replacement or a rollover before continuing. Trap: A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. Trap: Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. Done when: A cumulative reading lower than its predecessor raises a meter event and holds every plan on that meter, tested. Confirming a replacement stores an offset and resumes scheduling from the corrected basis, with the original readings untouched, tested. A meter plan schedules from readings alone with no running total stored on the asset, verified by review. A reading marked mistaken remains in the table and is excluded from calculation with the exclusion visible. Do not build yet: No roles, audit log, import, export or backup yet. ### Phase 5 · Roles, backup and a history worth having Build: Session authentication with an administrator and a technician role, hashed passwords, request forgery protection, upload limits and an audit log of every permission-relevant action. Back up the database using its own backup mechanism and the photograph directory in the same run. Restore both into an empty directory in the test suite. Export assets and full work history as CSV, and never delete a work log row. Trap: Copying the database file while the application is running produces a file that opens, passes a quick look, and is missing the most recent transactions, which is precisely the work you most wanted to keep. Use the database's own backup command, take the photograph directory in the same run, and assert the pair restores together in the suite. Trap: Letting an administrator edit a work log entry to fix a typo turns the asset history into an opinion, and the whole reason to keep ten years of it is that nobody can change it. Append a correction row that references the original instead, and render corrections next to what they correct. Done when: A technician cannot reach administrative pages or another technician's assignments, tested per route rather than assumed from the navigation. A backup taken while the application is under write load restores into an empty directory with every work order, log row and photograph present, exercised in the suite. No code path updates or deletes a work_log row, verified by searching the source. Export produces assets and the complete work history as CSV and reimports with identical row counts. Do not build yet: Nothing after this. ### Out of scope (and why) - Predictive maintenance from sensor data, which needs instrumentation, a data pipeline and enough failures to learn from before it says anything true. - Accounting and purchasing integrations, which are other systems' schemas and other people's approval rules rather than a maintenance problem. - Multi-site tenancy and single sign-on, which change the permission model from a single boundary to a matrix and belong in a different build. - A native mobile application, since a server-rendered page that works on one bar of signal beats an app store release cycle for this job. ### README must contain - That asset codes are assigned once, never reused, and independent of database identifiers - That preventive occurrences are keyed on plan and due date so the generator is safe to run twice - That a catch-up after an outage creates one occurrence and records how many were skipped - That a decreasing cumulative meter reading pauses the plans and needs a human to confirm - The backup command that takes the database and photograph directory together, and the restore that is tested ===== AGENTS.md ===== # Agent instructions · MaintenEase indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and server-rendered templates, no frontend build step, SQLite with better-sqlite3, photographs on disk beside it, An in-process job with its last run persisted, and occurrences keyed by plan and due date, One push channel configured by URL, with every failure logged and visible, Append-only work log rows, never edited, never deleted. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. ===== BUILD_PLAN.md ===== # Build plan · MaintenEase A single-site maintenance tracker whose hard problems are recurrence that neither duplicates nor avalanches, meter readings that go backwards when a gauge is replaced, and an append-only service history that is still readable in ten years. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Assets, codes and a label that scans in a plant room Every asset has a code assigned once, a printable label, and a page that opens from scanning it. ### Steps 1. Assign codes independently of row identifiers An import must not renumber what is stuck to a machine. Files: `src/assets/codes.js`, `tests/codes.test.js` 2. Print the code as text under the symbol Labels get scratched and phones get left in the van. Files: `src/views/label.ejs` 3. Scan a real printed label in bad light The only test that predicts anything about the plant room. Files: `docs/label-test.md` ### Done when - [ ] Codes survive export and reimport unchanged - [ ] A scrapped asset's code is never reused - [ ] A production-size label scans in poor light - [ ] The code is readable as text on the label ### Watch out - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. ## Phase 2 · Work a technician can complete with one thumb The field views are large, fast, and safe to tap twice on a bad connection. ### Steps 1. Put common transitions on large buttons Gloves and a dropdown do not mix. Files: `src/views/queue.ejs` 2. Make every write idempotent with a token The append-only log cannot be tidied up later. Files: `src/middleware/idempotency.js`, `tests/idempotency.test.js` 3. Accept a camera photograph directly Evidence collected later is evidence not collected. Files: `src/work/photos.js` ### Done when - [ ] A replayed submission creates one log entry - [ ] Field views are usable at phone size with large targets - [ ] A camera photograph attaches and is stored on disk - [ ] Queue views return within the stated time on seeded data ### Watch out - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. ## Phase 3 · Recurrence that neither duplicates nor avalanches Preventive plans generate exactly one occurrence per due date, and a scheduler outage does not produce a month of overdue work. ### Steps 1. Key occurrences uniquely on plan and due date Idempotence by constraint, not by hoping the job runs once. Files: `src/pm/generate.js`, `src/db/schema.sql` 2. Support both anchors and test the drift The two behaviours are genuinely different requirements. Files: `tests/pm-anchor.test.js` 3. Collapse a catch-up into one occurrence with a count Forty overdue jobs is the same as no overdue jobs. Files: `src/pm/catchup.js` ### Done when - [ ] A second generator run creates nothing - [ ] Due-date anchoring does not drift; completion anchoring follows - [ ] A catch-up makes one occurrence and records the skips - [ ] No successor is generated inside a close request ### Watch out - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. ## Phase 4 · Meters, and the day a reading goes backwards Meter-based plans work from irregular readings and survive a replaced or rolled-over gauge. ### Steps 1. Compute intervals from the readings table only A cached total is a corruption waiting for a bad reading. Files: `src/meters/interval.js` 2. Treat a decrease as an event needing a human Gauges get replaced and counters roll over. Files: `src/meters/events.js`, `tests/meter-rollover.test.js` 3. Keep mistaken readings and exclude them visibly Deleting the evidence is how history stops being trustworthy. Files: `src/meters/readings.js` ### Done when - [ ] A lower cumulative reading holds the plans and raises an event - [ ] Confirming a replacement resumes from a stored offset - [ ] No running total is stored on the asset - [ ] Mistaken readings are retained and visibly excluded ### Watch out - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. ## Not in this build - Predictive maintenance from sensor data. - Accounting and purchasing integrations. - Multi-site tenancy and single sign-on. - A native mobile application. ## After v1, if you want it - Parts and stock levels, which are a second inventory problem and deserve their own uniqueness thinking - An offline queue for the field views, which is worth doing once you know which three actions actually matter - Compliance reporting on preventive completion, computed from occurrences and closures already recorded ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, work orders and the whole work history. Back up with the photographs. DATABASE_PATH=./data/maintenance.db # Required. Work order attachments. Restored together with the database or not at all. PHOTO_DIR=./data/photos # Required · secret. Signs session cookies. Rotating it logs everyone out. SESSION_SECRET=hex-64-chars-redacted # Required · secret. Push endpoint for assignment and overdue alerts. Failures appear in the admin log. NTFY_URL=https://ntfy.example.com/maintenance # Required. How far ahead occurrences are generated. Longer means a fuller queue, not more work. PM_LOOKAHEAD_DAYS=14 # Required. Occurrences created after an outage. Above one and you get an avalanche. PM_CATCHUP_MAX=1 # Required. Per photograph, checked before anything is written to disk. MAX_UPLOAD_BYTES=8388608 # Required. Database and photograph archive pairs. Keep generations. BACKUP_DIR=/var/backups/maintenance
You are building a lean indie version of MaintenEase. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # MaintenEase · indie build A single-site maintenance tracker whose hard problems are recurrence that neither duplicates nor avalanches, meter readings that go backwards when a gauge is replaced, and an append-only service history that is still readable in ten years. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and server-rendered templates, no frontend build step | the phone in a plant room with one bar of signal should get HTML | | Storage | SQLite with better-sqlite3, photographs on disk beside it | one file to back up, and one directory that must travel with it | | Scheduling | An in-process job with its last run persisted, and occurrences keyed by plan and due date | the uniqueness key is what stops a restart creating four copies of the same job | | Notification | One push channel configured by URL, with every failure logged and visible | a notification nobody can confirm arrived is worse than none | | History | Append-only work log rows, never edited, never deleted | the asset history is the part that is worth money in five years | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node 22 or newer** · free - Why: Runs the server, the scheduler and the test suite. - Get it: nodejs.org, or your package manager. - Verify: node --version prints v22 or higher - [ ] **An always-on machine on the site network** · a few pounds a month, or free on hardware you already run - Why: The scheduler has to run whether or not anyone is logged in, and a preventive plan that only generates when someone opens a browser is not a plan. - Get it: A small virtual machine or a box in the plant office, with the service starting at boot. - Verify: Occurrences appear for three consecutive days with nobody logged in - [ ] **A label printer and the stock you will actually use** · the printer, plus consumables - Why: Label size, material and the encoder's margin settings decide whether the system works in the field, and none of it is testable on a screen. - Get it: Any thermal label printer and the sticker stock that survives your environment. - Verify: A production-size label scans on a cheap phone in poor light - [ ] **A push notification endpoint** · free for a public topic; small if self-hosted - Why: Assignment and overdue alerts go through it, and its failures need to be visible rather than assumed. - Get it: A self-hosted or public push service with a topic URL you control. - Verify: A test alert arrives and a deliberately broken URL appears in the failure log - [ ] **Which plans anchor to the due date and which to completion** · free - Why: The two produce visibly different schedules after the first late job, and deciding per plan later means deciding inconsistently across a hundred assets. - Get it: Write the rule down, for example calendar inspections anchored to the due date and lubrication anchored to completion. - Verify: The rule is in the README and each seeded plan matches it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Predictive maintenance from sensor data. - Accounting and purchasing integrations. - Multi-site tenancy and single sign-on. - A native mobile application. - managed backups and hosted reliability - polished mobile field workflows - advanced analytics and predictive features - integrations, API, and SSO - onboarding and data-import support If one of those is essential to you, that is the reason to keep paying for MaintenEase, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · MaintenEase The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a work-order and preventive-maintenance tracker for one site whose asset history outlives the people who wrote it to replace MaintenEase. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and server-rendered templates, no frontend build step · the phone in a plant room with one bar of signal should get HTML - Storage: SQLite with better-sqlite3, photographs on disk beside it · one file to back up, and one directory that must travel with it - Scheduling: An in-process job with its last run persisted, and occurrences keyed by plan and due date · the uniqueness key is what stops a restart creating four copies of the same job - Notification: One push channel configured by URL, with every failure logged and visible · a notification nobody can confirm arrived is worse than none - History: Append-only work log rows, never edited, never deleted · the asset history is the part that is worth money in five years ### Data model (create this before Phase 1) - locations(id, name, parent_id) and assets(id, code, name, location_id, parent_asset_id, status, installed_on, notes) - assets.code is assigned once, printed on the label, and never reused even after the asset is scrapped - meters(id, asset_id, name, unit, kind) · kind is cumulative or resettable, which changes what a lower reading means - readings(id, meter_id, at, value, taken_by, source, offset_applied) · insert only, and a decrease is an event rather than a correction - pm_plans(id, asset_id, title, mode, interval_days, meter_id, interval_units, anchor, lead_days, checklist_json) · anchor is due_date or completion - pm_occurrences(id, plan_id, due_at, generated_at, generated_from_reading_id, work_order_id, skipped_count) · unique on plan_id and due_at - work_orders(id, code, asset_id, plan_id, priority, assignee_id, due_at, status, opened_at, closed_at) - work_log(id, work_order_id, at, actor_id, kind, text, minutes, parts_json, photo_path) · append only, and this is the history ### Phase 1 · Assets, codes and a label that scans in a plant room Build: Assign each asset a code at creation that is independent of any database identifier. Print a label carrying the code as a scannable symbol and as plain text underneath, with a clear margin around the symbol. Scanning opens the asset page. Test the label by printing it at the size you will actually use and scanning it in poor light with a cheap phone. Trap: Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. Trap: Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. Done when: An asset code is assigned at creation and survives an export and reimport unchanged, tested. No code is reused after an asset is scrapped, enforced by a constraint. A printed label at the production size scans on a cheap phone in poor light, recorded as a manual check with the size and the encoder settings written down. The code is legible as text on the label so a damaged symbol is still usable by a human. Do not build yet: No work orders, recurrence, meters or roles yet. ### Phase 2 · Work a technician can complete with one thumb Build: Queue views for My Work, Due Today, Overdue and Completed, with the common status changes as large buttons rather than a dropdown and a save. Accept a photograph straight from the camera. Make every write idempotent with a token so a double tap or a retried request does not create a second log entry. Trap: A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. Trap: A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. Done when: Submitting the same form twice with the same token creates one work log entry, tested by replaying the request. Every field view renders and is usable on a phone-sized viewport with touch targets at or above a stated size, checked. A photograph taken on a phone attaches to a work order and is stored on disk with the row recording its path. A queue view returns within a stated time with a realistic number of open work orders, measured against seeded data. Do not build yet: No recurrence, meters, roles or backups yet. ### Phase 3 · Recurrence that neither duplicates nor avalanches Build: Generate occurrences keyed uniquely on plan and due date so a second run is a no-op. Support both anchors: a plan anchored to the due date keeps its cadence regardless of when the work was done, and a plan anchored to completion schedules from the completion. When catching up after an outage, create one occurrence at the next due date and record how many were skipped. Trap: Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. Trap: Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. Done when: Running the generator twice in succession creates nothing the second time, tested. A plan anchored to the due date does not drift when work is completed late, and one anchored to completion schedules from the completion date, both tested with a fixed clock. Catching up after a stated outage creates one occurrence carrying the number skipped, tested. Closing a work order never generates its successor inside the request, verified by review. Do not build yet: No meter-based plans, roles or backups yet. ### Phase 4 · Meters, and the day a reading goes backwards Build: Record readings as insert-only rows with who took them. Drive meter plans from the difference since the last service reading rather than from any running total the app maintains. When a cumulative reading comes in lower than the previous one, raise a meter event, hold every plan on that meter, and require a human to confirm a replacement or a rollover before continuing. Trap: A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. Trap: Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. Done when: A cumulative reading lower than its predecessor raises a meter event and holds every plan on that meter, tested. Confirming a replacement stores an offset and resumes scheduling from the corrected basis, with the original readings untouched, tested. A meter plan schedules from readings alone with no running total stored on the asset, verified by review. A reading marked mistaken remains in the table and is excluded from calculation with the exclusion visible. Do not build yet: No roles, audit log, import, export or backup yet. ### Phase 5 · Roles, backup and a history worth having Build: Session authentication with an administrator and a technician role, hashed passwords, request forgery protection, upload limits and an audit log of every permission-relevant action. Back up the database using its own backup mechanism and the photograph directory in the same run. Restore both into an empty directory in the test suite. Export assets and full work history as CSV, and never delete a work log row. Trap: Copying the database file while the application is running produces a file that opens, passes a quick look, and is missing the most recent transactions, which is precisely the work you most wanted to keep. Use the database's own backup command, take the photograph directory in the same run, and assert the pair restores together in the suite. Trap: Letting an administrator edit a work log entry to fix a typo turns the asset history into an opinion, and the whole reason to keep ten years of it is that nobody can change it. Append a correction row that references the original instead, and render corrections next to what they correct. Done when: A technician cannot reach administrative pages or another technician's assignments, tested per route rather than assumed from the navigation. A backup taken while the application is under write load restores into an empty directory with every work order, log row and photograph present, exercised in the suite. No code path updates or deletes a work_log row, verified by searching the source. Export produces assets and the complete work history as CSV and reimports with identical row counts. Do not build yet: Nothing after this. ### Out of scope (and why) - Predictive maintenance from sensor data, which needs instrumentation, a data pipeline and enough failures to learn from before it says anything true. - Accounting and purchasing integrations, which are other systems' schemas and other people's approval rules rather than a maintenance problem. - Multi-site tenancy and single sign-on, which change the permission model from a single boundary to a matrix and belong in a different build. - A native mobile application, since a server-rendered page that works on one bar of signal beats an app store release cycle for this job. ### README must contain - That asset codes are assigned once, never reused, and independent of database identifiers - That preventive occurrences are keyed on plan and due date so the generator is safe to run twice - That a catch-up after an outage creates one occurrence and records how many were skipped - That a decreasing cumulative meter reading pauses the plans and needs a human to confirm - The backup command that takes the database and photograph directory together, and the restore that is tested ===== AGENTS.md ===== # Agent instructions · MaintenEase indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and server-rendered templates, no frontend build step, SQLite with better-sqlite3, photographs on disk beside it, An in-process job with its last run persisted, and occurrences keyed by plan and due date, One push channel configured by URL, with every failure logged and visible, Append-only work log rows, never edited, never deleted. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. ===== BUILD_PLAN.md ===== # Build plan · MaintenEase A single-site maintenance tracker whose hard problems are recurrence that neither duplicates nor avalanches, meter readings that go backwards when a gauge is replaced, and an append-only service history that is still readable in ten years. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Assets, codes and a label that scans in a plant room Every asset has a code assigned once, a printable label, and a page that opens from scanning it. ### Steps 1. Assign codes independently of row identifiers An import must not renumber what is stuck to a machine. Files: `src/assets/codes.js`, `tests/codes.test.js` 2. Print the code as text under the symbol Labels get scratched and phones get left in the van. Files: `src/views/label.ejs` 3. Scan a real printed label in bad light The only test that predicts anything about the plant room. Files: `docs/label-test.md` ### Done when - [ ] Codes survive export and reimport unchanged - [ ] A scrapped asset's code is never reused - [ ] A production-size label scans in poor light - [ ] The code is readable as text on the label ### Watch out - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. ## Phase 2 · Work a technician can complete with one thumb The field views are large, fast, and safe to tap twice on a bad connection. ### Steps 1. Put common transitions on large buttons Gloves and a dropdown do not mix. Files: `src/views/queue.ejs` 2. Make every write idempotent with a token The append-only log cannot be tidied up later. Files: `src/middleware/idempotency.js`, `tests/idempotency.test.js` 3. Accept a camera photograph directly Evidence collected later is evidence not collected. Files: `src/work/photos.js` ### Done when - [ ] A replayed submission creates one log entry - [ ] Field views are usable at phone size with large targets - [ ] A camera photograph attaches and is stored on disk - [ ] Queue views return within the stated time on seeded data ### Watch out - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. ## Phase 3 · Recurrence that neither duplicates nor avalanches Preventive plans generate exactly one occurrence per due date, and a scheduler outage does not produce a month of overdue work. ### Steps 1. Key occurrences uniquely on plan and due date Idempotence by constraint, not by hoping the job runs once. Files: `src/pm/generate.js`, `src/db/schema.sql` 2. Support both anchors and test the drift The two behaviours are genuinely different requirements. Files: `tests/pm-anchor.test.js` 3. Collapse a catch-up into one occurrence with a count Forty overdue jobs is the same as no overdue jobs. Files: `src/pm/catchup.js` ### Done when - [ ] A second generator run creates nothing - [ ] Due-date anchoring does not drift; completion anchoring follows - [ ] A catch-up makes one occurrence and records the skips - [ ] No successor is generated inside a close request ### Watch out - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. ## Phase 4 · Meters, and the day a reading goes backwards Meter-based plans work from irregular readings and survive a replaced or rolled-over gauge. ### Steps 1. Compute intervals from the readings table only A cached total is a corruption waiting for a bad reading. Files: `src/meters/interval.js` 2. Treat a decrease as an event needing a human Gauges get replaced and counters roll over. Files: `src/meters/events.js`, `tests/meter-rollover.test.js` 3. Keep mistaken readings and exclude them visibly Deleting the evidence is how history stops being trustworthy. Files: `src/meters/readings.js` ### Done when - [ ] A lower cumulative reading holds the plans and raises an event - [ ] Confirming a replacement resumes from a stored offset - [ ] No running total is stored on the asset - [ ] Mistaken readings are retained and visibly excluded ### Watch out - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. ## Not in this build - Predictive maintenance from sensor data. - Accounting and purchasing integrations. - Multi-site tenancy and single sign-on. - A native mobile application. ## After v1, if you want it - Parts and stock levels, which are a second inventory problem and deserve their own uniqueness thinking - An offline queue for the field views, which is worth doing once you know which three actions actually matter - Compliance reporting on preventive completion, computed from occurrences and closures already recorded ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, work orders and the whole work history. Back up with the photographs. DATABASE_PATH=./data/maintenance.db # Required. Work order attachments. Restored together with the database or not at all. PHOTO_DIR=./data/photos # Required · secret. Signs session cookies. Rotating it logs everyone out. SESSION_SECRET=hex-64-chars-redacted # Required · secret. Push endpoint for assignment and overdue alerts. Failures appear in the admin log. NTFY_URL=https://ntfy.example.com/maintenance # Required. How far ahead occurrences are generated. Longer means a fuller queue, not more work. PM_LOOKAHEAD_DAYS=14 # Required. Occurrences created after an outage. Above one and you get an avalanche. PM_CATCHUP_MAX=1 # Required. Per photograph, checked before anything is written to disk. MAX_UPLOAD_BYTES=8388608 # Required. Database and photograph archive pairs. Keep generations. BACKUP_DIR=/var/backups/maintenance
You are building a production product version of MaintenEase. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # MaintenEase · product brief ## Problem A focused work-order and preventive-maintenance tracker is a weekend build, but dependable field use, notifications, permissions, backups, and long-lived asset history require ongoing operations. ## Product outcome A maintenance system where running the scheduler twice is harmless, a replaced gauge does not generate work forever, technicians can close a job with one thumb, and ten years of service history cannot be edited by anyone. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - hosted or local database - scheduled background jobs - notification channel - file storage - mobile-friendly web UI ## Explicit non-goals for v1 - Predictive maintenance from sensor data. - Accounting and purchasing integrations. - Multi-site tenancy and single sign-on. - A native mobile application. - managed backups and hosted reliability - polished mobile field workflows - advanced analytics and predictive features - integrations, API, and SSO - onboarding and data-import support ## Success criteria - A second generator run is confirmed to create nothing - A replayed submission is confirmed to create one log entry - A decreasing cumulative reading is confirmed to hold the plans - No code path is confirmed to edit or delete a work log row - A backup under write load is confirmed to restore rows and photographs ===== BRIEF.md ===== # Build brief · MaintenEase The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a work-order and preventive-maintenance tracker for one site whose asset history outlives the people who wrote it to replace MaintenEase. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and server-rendered templates, no frontend build step · the phone in a plant room with one bar of signal should get HTML - Storage: SQLite with better-sqlite3, photographs on disk beside it · one file to back up, and one directory that must travel with it - Scheduling: An in-process job with its last run persisted, and occurrences keyed by plan and due date · the uniqueness key is what stops a restart creating four copies of the same job - Notification: One push channel configured by URL, with every failure logged and visible · a notification nobody can confirm arrived is worse than none - History: Append-only work log rows, never edited, never deleted · the asset history is the part that is worth money in five years ### Data model (create this before Phase 1) - locations(id, name, parent_id) and assets(id, code, name, location_id, parent_asset_id, status, installed_on, notes) - assets.code is assigned once, printed on the label, and never reused even after the asset is scrapped - meters(id, asset_id, name, unit, kind) · kind is cumulative or resettable, which changes what a lower reading means - readings(id, meter_id, at, value, taken_by, source, offset_applied) · insert only, and a decrease is an event rather than a correction - pm_plans(id, asset_id, title, mode, interval_days, meter_id, interval_units, anchor, lead_days, checklist_json) · anchor is due_date or completion - pm_occurrences(id, plan_id, due_at, generated_at, generated_from_reading_id, work_order_id, skipped_count) · unique on plan_id and due_at - work_orders(id, code, asset_id, plan_id, priority, assignee_id, due_at, status, opened_at, closed_at) - work_log(id, work_order_id, at, actor_id, kind, text, minutes, parts_json, photo_path) · append only, and this is the history ### Phase 1 · Assets, codes and a label that scans in a plant room Build: Assign each asset a code at creation that is independent of any database identifier. Print a label carrying the code as a scannable symbol and as plain text underneath, with a clear margin around the symbol. Scanning opens the asset page. Test the label by printing it at the size you will actually use and scanning it in poor light with a cheap phone. Trap: Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. Trap: Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. Done when: An asset code is assigned at creation and survives an export and reimport unchanged, tested. No code is reused after an asset is scrapped, enforced by a constraint. A printed label at the production size scans on a cheap phone in poor light, recorded as a manual check with the size and the encoder settings written down. The code is legible as text on the label so a damaged symbol is still usable by a human. Do not build yet: No work orders, recurrence, meters or roles yet. ### Phase 2 · Work a technician can complete with one thumb Build: Queue views for My Work, Due Today, Overdue and Completed, with the common status changes as large buttons rather than a dropdown and a save. Accept a photograph straight from the camera. Make every write idempotent with a token so a double tap or a retried request does not create a second log entry. Trap: A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. Trap: A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. Done when: Submitting the same form twice with the same token creates one work log entry, tested by replaying the request. Every field view renders and is usable on a phone-sized viewport with touch targets at or above a stated size, checked. A photograph taken on a phone attaches to a work order and is stored on disk with the row recording its path. A queue view returns within a stated time with a realistic number of open work orders, measured against seeded data. Do not build yet: No recurrence, meters, roles or backups yet. ### Phase 3 · Recurrence that neither duplicates nor avalanches Build: Generate occurrences keyed uniquely on plan and due date so a second run is a no-op. Support both anchors: a plan anchored to the due date keeps its cadence regardless of when the work was done, and a plan anchored to completion schedules from the completion. When catching up after an outage, create one occurrence at the next due date and record how many were skipped. Trap: Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. Trap: Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. Done when: Running the generator twice in succession creates nothing the second time, tested. A plan anchored to the due date does not drift when work is completed late, and one anchored to completion schedules from the completion date, both tested with a fixed clock. Catching up after a stated outage creates one occurrence carrying the number skipped, tested. Closing a work order never generates its successor inside the request, verified by review. Do not build yet: No meter-based plans, roles or backups yet. ### Phase 4 · Meters, and the day a reading goes backwards Build: Record readings as insert-only rows with who took them. Drive meter plans from the difference since the last service reading rather than from any running total the app maintains. When a cumulative reading comes in lower than the previous one, raise a meter event, hold every plan on that meter, and require a human to confirm a replacement or a rollover before continuing. Trap: A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. Trap: Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. Done when: A cumulative reading lower than its predecessor raises a meter event and holds every plan on that meter, tested. Confirming a replacement stores an offset and resumes scheduling from the corrected basis, with the original readings untouched, tested. A meter plan schedules from readings alone with no running total stored on the asset, verified by review. A reading marked mistaken remains in the table and is excluded from calculation with the exclusion visible. Do not build yet: No roles, audit log, import, export or backup yet. ### Phase 5 · Roles, backup and a history worth having Build: Session authentication with an administrator and a technician role, hashed passwords, request forgery protection, upload limits and an audit log of every permission-relevant action. Back up the database using its own backup mechanism and the photograph directory in the same run. Restore both into an empty directory in the test suite. Export assets and full work history as CSV, and never delete a work log row. Trap: Copying the database file while the application is running produces a file that opens, passes a quick look, and is missing the most recent transactions, which is precisely the work you most wanted to keep. Use the database's own backup command, take the photograph directory in the same run, and assert the pair restores together in the suite. Trap: Letting an administrator edit a work log entry to fix a typo turns the asset history into an opinion, and the whole reason to keep ten years of it is that nobody can change it. Append a correction row that references the original instead, and render corrections next to what they correct. Done when: A technician cannot reach administrative pages or another technician's assignments, tested per route rather than assumed from the navigation. A backup taken while the application is under write load restores into an empty directory with every work order, log row and photograph present, exercised in the suite. No code path updates or deletes a work_log row, verified by searching the source. Export produces assets and the complete work history as CSV and reimports with identical row counts. Do not build yet: Nothing after this. ### Out of scope (and why) - Predictive maintenance from sensor data, which needs instrumentation, a data pipeline and enough failures to learn from before it says anything true. - Accounting and purchasing integrations, which are other systems' schemas and other people's approval rules rather than a maintenance problem. - Multi-site tenancy and single sign-on, which change the permission model from a single boundary to a matrix and belong in a different build. - A native mobile application, since a server-rendered page that works on one bar of signal beats an app store release cycle for this job. ### README must contain - That asset codes are assigned once, never reused, and independent of database identifiers - That preventive occurrences are keyed on plan and due date so the generator is safe to run twice - That a catch-up after an outage creates one occurrence and records how many were skipped - That a decreasing cumulative meter reading pauses the plans and needs a human to confirm - The backup command that takes the database and photograph directory together, and the restore that is tested ===== ARCHITECTURE.md ===== # Architecture · MaintenEase ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and server-rendered templates, no frontend build step | the phone in a plant room with one bar of signal should get HTML | | Storage | SQLite with better-sqlite3, photographs on disk beside it | one file to back up, and one directory that must travel with it | | Scheduling | An in-process job with its last run persisted, and occurrences keyed by plan and due date | the uniqueness key is what stops a restart creating four copies of the same job | | Notification | One push channel configured by URL, with every failure logged and visible | a notification nobody can confirm arrived is worse than none | | History | Append-only work log rows, never edited, never deleted | the asset history is the part that is worth money in five years | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Assets | Codes, hierarchy, labels and asset pages. | Any label format, provided the code never comes from a row id. | | Work | Queues, transitions, photographs and the log. | Any interface, provided every write is idempotent. | | Scheduling | Plans, occurrences, anchors and catch-up. | Any scheduler, provided occurrences are keyed on plan and due date. | | Meters | Readings, intervals, events and offsets. | Any source of readings, provided no running total is cached. | | Operations | Roles, audit log, backup and export. | Any store, provided database and photographs move together. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Assets, work orders and the whole work history. Back up with the photographs. - `PHOTO_DIR` · required · Work order attachments. Restored together with the database or not at all. - `SESSION_SECRET` · required, secret · Signs session cookies. Rotating it logs everyone out. - `NTFY_URL` · required, secret · Push endpoint for assignment and overdue alerts. Failures appear in the admin log. - `PM_LOOKAHEAD_DAYS` · required · How far ahead occurrences are generated. Longer means a fuller queue, not more work. - `PM_CATCHUP_MAX` · required · Occurrences created after an outage. Above one and you get an avalanche. - `MAX_UPLOAD_BYTES` · required · Per photograph, checked before anything is written to disk. - `BACKUP_DIR` · required · Database and photograph archive pairs. Keep generations. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · MaintenEase product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express and server-rendered templates, no frontend build step, SQLite with better-sqlite3, photographs on disk beside it, An in-process job with its last run persisted, and occurrences keyed by plan and due date, One push channel configured by URL, with every failure logged and visible, Append-only work log rows, never edited, never deleted. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. - Copying the database file while the application is running produces a file that opens, passes a quick look, and is missing the most recent transactions, which is precisely the work you most wanted to keep. Use the database's own backup command, take the photograph directory in the same run, and assert the pair restores together in the suite. - Letting an administrator edit a work log entry to fix a typo turns the asset history into an opinion, and the whole reason to keep ten years of it is that nobody can change it. Append a correction row that references the original instead, and render corrections next to what they correct. ===== MILESTONES.md ===== # Delivery milestones · MaintenEase Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Assets, codes and a label that scans in a plant room Every asset has a code assigned once, a printable label, and a page that opens from scanning it. ### Steps 1. Assign codes independently of row identifiers An import must not renumber what is stuck to a machine. Files: `src/assets/codes.js`, `tests/codes.test.js` 2. Print the code as text under the symbol Labels get scratched and phones get left in the van. Files: `src/views/label.ejs` 3. Scan a real printed label in bad light The only test that predicts anything about the plant room. Files: `docs/label-test.md` ### Done when - [ ] Codes survive export and reimport unchanged - [ ] A scrapped asset's code is never reused - [ ] A production-size label scans in poor light - [ ] The code is readable as text on the label ### Watch out - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. ## M2 · Work a technician can complete with one thumb The field views are large, fast, and safe to tap twice on a bad connection. ### Steps 1. Put common transitions on large buttons Gloves and a dropdown do not mix. Files: `src/views/queue.ejs` 2. Make every write idempotent with a token The append-only log cannot be tidied up later. Files: `src/middleware/idempotency.js`, `tests/idempotency.test.js` 3. Accept a camera photograph directly Evidence collected later is evidence not collected. Files: `src/work/photos.js` ### Done when - [ ] A replayed submission creates one log entry - [ ] Field views are usable at phone size with large targets - [ ] A camera photograph attaches and is stored on disk - [ ] Queue views return within the stated time on seeded data ### Watch out - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. ## M3 · Recurrence that neither duplicates nor avalanches Preventive plans generate exactly one occurrence per due date, and a scheduler outage does not produce a month of overdue work. ### Steps 1. Key occurrences uniquely on plan and due date Idempotence by constraint, not by hoping the job runs once. Files: `src/pm/generate.js`, `src/db/schema.sql` 2. Support both anchors and test the drift The two behaviours are genuinely different requirements. Files: `tests/pm-anchor.test.js` 3. Collapse a catch-up into one occurrence with a count Forty overdue jobs is the same as no overdue jobs. Files: `src/pm/catchup.js` ### Done when - [ ] A second generator run creates nothing - [ ] Due-date anchoring does not drift; completion anchoring follows - [ ] A catch-up makes one occurrence and records the skips - [ ] No successor is generated inside a close request ### Watch out - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. ## M4 · Meters, and the day a reading goes backwards Meter-based plans work from irregular readings and survive a replaced or rolled-over gauge. ### Steps 1. Compute intervals from the readings table only A cached total is a corruption waiting for a bad reading. Files: `src/meters/interval.js` 2. Treat a decrease as an event needing a human Gauges get replaced and counters roll over. Files: `src/meters/events.js`, `tests/meter-rollover.test.js` 3. Keep mistaken readings and exclude them visibly Deleting the evidence is how history stops being trustworthy. Files: `src/meters/readings.js` ### Done when - [ ] A lower cumulative reading holds the plans and raises an event - [ ] Confirming a replacement resumes from a stored offset - [ ] No running total is stored on the asset - [ ] Mistaken readings are retained and visibly excluded ### Watch out - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. ## M5 · Roles, backup and a history worth having (production only) Access is separated, the database and photographs are backed up together, and a restore is exercised rather than described. ### Steps 1. Test permissions per route, not per menu A hidden link is not access control. Files: `tests/permissions.test.js`, `src/auth/roles.js` 2. Back up database and photographs in one run Either alone restores to a system full of missing evidence. Files: `scripts/backup.sh`, `tests/restore.test.js` 3. Correct by appending, never by editing The history is only worth keeping if it cannot be rewritten. Files: `src/work/corrections.js` ### Done when - [ ] Route-level permission tests pass for both roles - [ ] A backup under write load restores rows and photographs - [ ] No code path edits or deletes a work log row - [ ] Export and reimport preserve asset and history counts ### Watch out - Copying the database file while the application is running produces a file that opens, passes a quick look, and is missing the most recent transactions, which is precisely the work you most wanted to keep. Use the database's own backup command, take the photograph directory in the same run, and assert the pair restores together in the suite. - Letting an administrator edit a work log entry to fix a typo turns the asset history into an opinion, and the whole reason to keep ten years of it is that nobody can change it. Append a correction row that references the original instead, and render corrections next to what they correct. ===== OPERATIONS.md ===== # Operations · MaintenEase ## Backup Nightly, using the database's own backup mechanism and archiving the photograph directory in the same run so the two are consistent with each other. Keep generations offsite, because the asset history is the thing that takes a decade to accumulate and an afternoon to lose. ## Restore Restore both into an empty directory, confirm the work order and work log counts against the last recorded figures, and open one work order with a photograph to prove the pairing. Then run the occurrence generator and confirm it creates nothing, which proves the uniqueness keys survived. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Scheduler last-run time against now, occurrences generated per day, notification failures, overdue count trend, and disk used by photographs against free space. A scheduler that silently stopped is the failure mode that costs the most and shows the least. ## Incident checklist The two incidents are duplicate work orders and a preventive plan that stopped generating. The occurrence uniqueness key prevents the first by construction; the last-run timestamp and the skipped counts are what make the second visible within a day rather than a quarter. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A second generator run is confirmed to create nothing - [ ] A replayed submission is confirmed to create one log entry - [ ] A decreasing cumulative reading is confirmed to hold the plans - [ ] No code path is confirmed to edit or delete a work log row - [ ] A backup under write load is confirmed to restore rows and photographs ## Launch constraint Do not market omitted MaintenEase capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, work orders and the whole work history. Back up with the photographs. DATABASE_PATH=./data/maintenance.db # Required. Work order attachments. Restored together with the database or not at all. PHOTO_DIR=./data/photos # Required · secret. Signs session cookies. Rotating it logs everyone out. SESSION_SECRET=hex-64-chars-redacted # Required · secret. Push endpoint for assignment and overdue alerts. Failures appear in the admin log. NTFY_URL=https://ntfy.example.com/maintenance # Required. How far ahead occurrences are generated. Longer means a fuller queue, not more work. PM_LOOKAHEAD_DAYS=14 # Required. Occurrences created after an outage. Above one and you get an avalanche. PM_CATCHUP_MAX=1 # Required. Per photograph, checked before anything is written to disk. MAX_UPLOAD_BYTES=8388608 # Required. Database and photograph archive pairs. Keep generations. BACKUP_DIR=/var/backups/maintenance
# MaintenEase · indie build A single-site maintenance tracker whose hard problems are recurrence that neither duplicates nor avalanches, meter readings that go backwards when a gauge is replaced, and an append-only service history that is still readable in ten years. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and server-rendered templates, no frontend build step | the phone in a plant room with one bar of signal should get HTML | | Storage | SQLite with better-sqlite3, photographs on disk beside it | one file to back up, and one directory that must travel with it | | Scheduling | An in-process job with its last run persisted, and occurrences keyed by plan and due date | the uniqueness key is what stops a restart creating four copies of the same job | | Notification | One push channel configured by URL, with every failure logged and visible | a notification nobody can confirm arrived is worse than none | | History | Append-only work log rows, never edited, never deleted | the asset history is the part that is worth money in five years | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node 22 or newer** · free - Why: Runs the server, the scheduler and the test suite. - Get it: nodejs.org, or your package manager. - Verify: node --version prints v22 or higher - [ ] **An always-on machine on the site network** · a few pounds a month, or free on hardware you already run - Why: The scheduler has to run whether or not anyone is logged in, and a preventive plan that only generates when someone opens a browser is not a plan. - Get it: A small virtual machine or a box in the plant office, with the service starting at boot. - Verify: Occurrences appear for three consecutive days with nobody logged in - [ ] **A label printer and the stock you will actually use** · the printer, plus consumables - Why: Label size, material and the encoder's margin settings decide whether the system works in the field, and none of it is testable on a screen. - Get it: Any thermal label printer and the sticker stock that survives your environment. - Verify: A production-size label scans on a cheap phone in poor light - [ ] **A push notification endpoint** · free for a public topic; small if self-hosted - Why: Assignment and overdue alerts go through it, and its failures need to be visible rather than assumed. - Get it: A self-hosted or public push service with a topic URL you control. - Verify: A test alert arrives and a deliberately broken URL appears in the failure log - [ ] **Which plans anchor to the due date and which to completion** · free - Why: The two produce visibly different schedules after the first late job, and deciding per plan later means deciding inconsistently across a hundred assets. - Get it: Write the rule down, for example calendar inspections anchored to the due date and lubrication anchored to completion. - Verify: The rule is in the README and each seeded plan matches it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Predictive maintenance from sensor data. - Accounting and purchasing integrations. - Multi-site tenancy and single sign-on. - A native mobile application. - managed backups and hosted reliability - polished mobile field workflows - advanced analytics and predictive features - integrations, API, and SSO - onboarding and data-import support If one of those is essential to you, that is the reason to keep paying for MaintenEase, and the README should say so rather than pretend.
# Build brief · MaintenEase The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a work-order and preventive-maintenance tracker for one site whose asset history outlives the people who wrote it to replace MaintenEase. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and server-rendered templates, no frontend build step · the phone in a plant room with one bar of signal should get HTML - Storage: SQLite with better-sqlite3, photographs on disk beside it · one file to back up, and one directory that must travel with it - Scheduling: An in-process job with its last run persisted, and occurrences keyed by plan and due date · the uniqueness key is what stops a restart creating four copies of the same job - Notification: One push channel configured by URL, with every failure logged and visible · a notification nobody can confirm arrived is worse than none - History: Append-only work log rows, never edited, never deleted · the asset history is the part that is worth money in five years ### Data model (create this before Phase 1) - locations(id, name, parent_id) and assets(id, code, name, location_id, parent_asset_id, status, installed_on, notes) - assets.code is assigned once, printed on the label, and never reused even after the asset is scrapped - meters(id, asset_id, name, unit, kind) · kind is cumulative or resettable, which changes what a lower reading means - readings(id, meter_id, at, value, taken_by, source, offset_applied) · insert only, and a decrease is an event rather than a correction - pm_plans(id, asset_id, title, mode, interval_days, meter_id, interval_units, anchor, lead_days, checklist_json) · anchor is due_date or completion - pm_occurrences(id, plan_id, due_at, generated_at, generated_from_reading_id, work_order_id, skipped_count) · unique on plan_id and due_at - work_orders(id, code, asset_id, plan_id, priority, assignee_id, due_at, status, opened_at, closed_at) - work_log(id, work_order_id, at, actor_id, kind, text, minutes, parts_json, photo_path) · append only, and this is the history ### Phase 1 · Assets, codes and a label that scans in a plant room Build: Assign each asset a code at creation that is independent of any database identifier. Print a label carrying the code as a scannable symbol and as plain text underneath, with a clear margin around the symbol. Scanning opens the asset page. Test the label by printing it at the size you will actually use and scanning it in poor light with a cheap phone. Trap: Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. Trap: Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. Done when: An asset code is assigned at creation and survives an export and reimport unchanged, tested. No code is reused after an asset is scrapped, enforced by a constraint. A printed label at the production size scans on a cheap phone in poor light, recorded as a manual check with the size and the encoder settings written down. The code is legible as text on the label so a damaged symbol is still usable by a human. Do not build yet: No work orders, recurrence, meters or roles yet. ### Phase 2 · Work a technician can complete with one thumb Build: Queue views for My Work, Due Today, Overdue and Completed, with the common status changes as large buttons rather than a dropdown and a save. Accept a photograph straight from the camera. Make every write idempotent with a token so a double tap or a retried request does not create a second log entry. Trap: A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. Trap: A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. Done when: Submitting the same form twice with the same token creates one work log entry, tested by replaying the request. Every field view renders and is usable on a phone-sized viewport with touch targets at or above a stated size, checked. A photograph taken on a phone attaches to a work order and is stored on disk with the row recording its path. A queue view returns within a stated time with a realistic number of open work orders, measured against seeded data. Do not build yet: No recurrence, meters, roles or backups yet. ### Phase 3 · Recurrence that neither duplicates nor avalanches Build: Generate occurrences keyed uniquely on plan and due date so a second run is a no-op. Support both anchors: a plan anchored to the due date keeps its cadence regardless of when the work was done, and a plan anchored to completion schedules from the completion. When catching up after an outage, create one occurrence at the next due date and record how many were skipped. Trap: Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. Trap: Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. Done when: Running the generator twice in succession creates nothing the second time, tested. A plan anchored to the due date does not drift when work is completed late, and one anchored to completion schedules from the completion date, both tested with a fixed clock. Catching up after a stated outage creates one occurrence carrying the number skipped, tested. Closing a work order never generates its successor inside the request, verified by review. Do not build yet: No meter-based plans, roles or backups yet. ### Phase 4 · Meters, and the day a reading goes backwards Build: Record readings as insert-only rows with who took them. Drive meter plans from the difference since the last service reading rather than from any running total the app maintains. When a cumulative reading comes in lower than the previous one, raise a meter event, hold every plan on that meter, and require a human to confirm a replacement or a rollover before continuing. Trap: A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. Trap: Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. Done when: A cumulative reading lower than its predecessor raises a meter event and holds every plan on that meter, tested. Confirming a replacement stores an offset and resumes scheduling from the corrected basis, with the original readings untouched, tested. A meter plan schedules from readings alone with no running total stored on the asset, verified by review. A reading marked mistaken remains in the table and is excluded from calculation with the exclusion visible. Do not build yet: No roles, audit log, import, export or backup yet. ### Phase 5 · Roles, backup and a history worth having Build: Session authentication with an administrator and a technician role, hashed passwords, request forgery protection, upload limits and an audit log of every permission-relevant action. Back up the database using its own backup mechanism and the photograph directory in the same run. Restore both into an empty directory in the test suite. Export assets and full work history as CSV, and never delete a work log row. Trap: Copying the database file while the application is running produces a file that opens, passes a quick look, and is missing the most recent transactions, which is precisely the work you most wanted to keep. Use the database's own backup command, take the photograph directory in the same run, and assert the pair restores together in the suite. Trap: Letting an administrator edit a work log entry to fix a typo turns the asset history into an opinion, and the whole reason to keep ten years of it is that nobody can change it. Append a correction row that references the original instead, and render corrections next to what they correct. Done when: A technician cannot reach administrative pages or another technician's assignments, tested per route rather than assumed from the navigation. A backup taken while the application is under write load restores into an empty directory with every work order, log row and photograph present, exercised in the suite. No code path updates or deletes a work_log row, verified by searching the source. Export produces assets and the complete work history as CSV and reimports with identical row counts. Do not build yet: Nothing after this. ### Out of scope (and why) - Predictive maintenance from sensor data, which needs instrumentation, a data pipeline and enough failures to learn from before it says anything true. - Accounting and purchasing integrations, which are other systems' schemas and other people's approval rules rather than a maintenance problem. - Multi-site tenancy and single sign-on, which change the permission model from a single boundary to a matrix and belong in a different build. - A native mobile application, since a server-rendered page that works on one bar of signal beats an app store release cycle for this job. ### README must contain - That asset codes are assigned once, never reused, and independent of database identifiers - That preventive occurrences are keyed on plan and due date so the generator is safe to run twice - That a catch-up after an outage creates one occurrence and records how many were skipped - That a decreasing cumulative meter reading pauses the plans and needs a human to confirm - The backup command that takes the database and photograph directory together, and the restore that is tested
# Agent instructions · MaintenEase indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and server-rendered templates, no frontend build step, SQLite with better-sqlite3, photographs on disk beside it, An in-process job with its last run persisted, and occurrences keyed by plan and due date, One push channel configured by URL, with every failure logged and visible, Append-only work log rows, never edited, never deleted. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken.
# Build plan · MaintenEase A single-site maintenance tracker whose hard problems are recurrence that neither duplicates nor avalanches, meter readings that go backwards when a gauge is replaced, and an append-only service history that is still readable in ten years. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Assets, codes and a label that scans in a plant room Every asset has a code assigned once, a printable label, and a page that opens from scanning it. ### Steps 1. Assign codes independently of row identifiers An import must not renumber what is stuck to a machine. Files: `src/assets/codes.js`, `tests/codes.test.js` 2. Print the code as text under the symbol Labels get scratched and phones get left in the van. Files: `src/views/label.ejs` 3. Scan a real printed label in bad light The only test that predicts anything about the plant room. Files: `docs/label-test.md` ### Done when - [ ] Codes survive export and reimport unchanged - [ ] A scrapped asset's code is never reused - [ ] A production-size label scans in poor light - [ ] The code is readable as text on the label ### Watch out - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. ## Phase 2 · Work a technician can complete with one thumb The field views are large, fast, and safe to tap twice on a bad connection. ### Steps 1. Put common transitions on large buttons Gloves and a dropdown do not mix. Files: `src/views/queue.ejs` 2. Make every write idempotent with a token The append-only log cannot be tidied up later. Files: `src/middleware/idempotency.js`, `tests/idempotency.test.js` 3. Accept a camera photograph directly Evidence collected later is evidence not collected. Files: `src/work/photos.js` ### Done when - [ ] A replayed submission creates one log entry - [ ] Field views are usable at phone size with large targets - [ ] A camera photograph attaches and is stored on disk - [ ] Queue views return within the stated time on seeded data ### Watch out - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. ## Phase 3 · Recurrence that neither duplicates nor avalanches Preventive plans generate exactly one occurrence per due date, and a scheduler outage does not produce a month of overdue work. ### Steps 1. Key occurrences uniquely on plan and due date Idempotence by constraint, not by hoping the job runs once. Files: `src/pm/generate.js`, `src/db/schema.sql` 2. Support both anchors and test the drift The two behaviours are genuinely different requirements. Files: `tests/pm-anchor.test.js` 3. Collapse a catch-up into one occurrence with a count Forty overdue jobs is the same as no overdue jobs. Files: `src/pm/catchup.js` ### Done when - [ ] A second generator run creates nothing - [ ] Due-date anchoring does not drift; completion anchoring follows - [ ] A catch-up makes one occurrence and records the skips - [ ] No successor is generated inside a close request ### Watch out - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. ## Phase 4 · Meters, and the day a reading goes backwards Meter-based plans work from irregular readings and survive a replaced or rolled-over gauge. ### Steps 1. Compute intervals from the readings table only A cached total is a corruption waiting for a bad reading. Files: `src/meters/interval.js` 2. Treat a decrease as an event needing a human Gauges get replaced and counters roll over. Files: `src/meters/events.js`, `tests/meter-rollover.test.js` 3. Keep mistaken readings and exclude them visibly Deleting the evidence is how history stops being trustworthy. Files: `src/meters/readings.js` ### Done when - [ ] A lower cumulative reading holds the plans and raises an event - [ ] Confirming a replacement resumes from a stored offset - [ ] No running total is stored on the asset - [ ] Mistaken readings are retained and visibly excluded ### Watch out - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. ## Not in this build - Predictive maintenance from sensor data. - Accounting and purchasing integrations. - Multi-site tenancy and single sign-on. - A native mobile application. ## After v1, if you want it - Parts and stock levels, which are a second inventory problem and deserve their own uniqueness thinking - An offline queue for the field views, which is worth doing once you know which three actions actually matter - Compliance reporting on preventive completion, computed from occurrences and closures already recorded
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, work orders and the whole work history. Back up with the photographs. DATABASE_PATH=./data/maintenance.db # Required. Work order attachments. Restored together with the database or not at all. PHOTO_DIR=./data/photos # Required · secret. Signs session cookies. Rotating it logs everyone out. SESSION_SECRET=hex-64-chars-redacted # Required · secret. Push endpoint for assignment and overdue alerts. Failures appear in the admin log. NTFY_URL=https://ntfy.example.com/maintenance # Required. How far ahead occurrences are generated. Longer means a fuller queue, not more work. PM_LOOKAHEAD_DAYS=14 # Required. Occurrences created after an outage. Above one and you get an avalanche. PM_CATCHUP_MAX=1 # Required. Per photograph, checked before anything is written to disk. MAX_UPLOAD_BYTES=8388608 # Required. Database and photograph archive pairs. Keep generations. BACKUP_DIR=/var/backups/maintenance
# MaintenEase · product brief ## Problem A focused work-order and preventive-maintenance tracker is a weekend build, but dependable field use, notifications, permissions, backups, and long-lived asset history require ongoing operations. ## Product outcome A maintenance system where running the scheduler twice is harmless, a replaced gauge does not generate work forever, technicians can close a job with one thumb, and ten years of service history cannot be edited by anyone. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - hosted or local database - scheduled background jobs - notification channel - file storage - mobile-friendly web UI ## Explicit non-goals for v1 - Predictive maintenance from sensor data. - Accounting and purchasing integrations. - Multi-site tenancy and single sign-on. - A native mobile application. - managed backups and hosted reliability - polished mobile field workflows - advanced analytics and predictive features - integrations, API, and SSO - onboarding and data-import support ## Success criteria - A second generator run is confirmed to create nothing - A replayed submission is confirmed to create one log entry - A decreasing cumulative reading is confirmed to hold the plans - No code path is confirmed to edit or delete a work log row - A backup under write load is confirmed to restore rows and photographs
# Build brief · MaintenEase The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a work-order and preventive-maintenance tracker for one site whose asset history outlives the people who wrote it to replace MaintenEase. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express and server-rendered templates, no frontend build step · the phone in a plant room with one bar of signal should get HTML - Storage: SQLite with better-sqlite3, photographs on disk beside it · one file to back up, and one directory that must travel with it - Scheduling: An in-process job with its last run persisted, and occurrences keyed by plan and due date · the uniqueness key is what stops a restart creating four copies of the same job - Notification: One push channel configured by URL, with every failure logged and visible · a notification nobody can confirm arrived is worse than none - History: Append-only work log rows, never edited, never deleted · the asset history is the part that is worth money in five years ### Data model (create this before Phase 1) - locations(id, name, parent_id) and assets(id, code, name, location_id, parent_asset_id, status, installed_on, notes) - assets.code is assigned once, printed on the label, and never reused even after the asset is scrapped - meters(id, asset_id, name, unit, kind) · kind is cumulative or resettable, which changes what a lower reading means - readings(id, meter_id, at, value, taken_by, source, offset_applied) · insert only, and a decrease is an event rather than a correction - pm_plans(id, asset_id, title, mode, interval_days, meter_id, interval_units, anchor, lead_days, checklist_json) · anchor is due_date or completion - pm_occurrences(id, plan_id, due_at, generated_at, generated_from_reading_id, work_order_id, skipped_count) · unique on plan_id and due_at - work_orders(id, code, asset_id, plan_id, priority, assignee_id, due_at, status, opened_at, closed_at) - work_log(id, work_order_id, at, actor_id, kind, text, minutes, parts_json, photo_path) · append only, and this is the history ### Phase 1 · Assets, codes and a label that scans in a plant room Build: Assign each asset a code at creation that is independent of any database identifier. Print a label carrying the code as a scannable symbol and as plain text underneath, with a clear margin around the symbol. Scanning opens the asset page. Test the label by printing it at the size you will actually use and scanning it in poor light with a cheap phone. Trap: Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. Trap: Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. Done when: An asset code is assigned at creation and survives an export and reimport unchanged, tested. No code is reused after an asset is scrapped, enforced by a constraint. A printed label at the production size scans on a cheap phone in poor light, recorded as a manual check with the size and the encoder settings written down. The code is legible as text on the label so a damaged symbol is still usable by a human. Do not build yet: No work orders, recurrence, meters or roles yet. ### Phase 2 · Work a technician can complete with one thumb Build: Queue views for My Work, Due Today, Overdue and Completed, with the common status changes as large buttons rather than a dropdown and a save. Accept a photograph straight from the camera. Make every write idempotent with a token so a double tap or a retried request does not create a second log entry. Trap: A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. Trap: A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. Done when: Submitting the same form twice with the same token creates one work log entry, tested by replaying the request. Every field view renders and is usable on a phone-sized viewport with touch targets at or above a stated size, checked. A photograph taken on a phone attaches to a work order and is stored on disk with the row recording its path. A queue view returns within a stated time with a realistic number of open work orders, measured against seeded data. Do not build yet: No recurrence, meters, roles or backups yet. ### Phase 3 · Recurrence that neither duplicates nor avalanches Build: Generate occurrences keyed uniquely on plan and due date so a second run is a no-op. Support both anchors: a plan anchored to the due date keeps its cadence regardless of when the work was done, and a plan anchored to completion schedules from the completion. When catching up after an outage, create one occurrence at the next due date and record how many were skipped. Trap: Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. Trap: Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. Done when: Running the generator twice in succession creates nothing the second time, tested. A plan anchored to the due date does not drift when work is completed late, and one anchored to completion schedules from the completion date, both tested with a fixed clock. Catching up after a stated outage creates one occurrence carrying the number skipped, tested. Closing a work order never generates its successor inside the request, verified by review. Do not build yet: No meter-based plans, roles or backups yet. ### Phase 4 · Meters, and the day a reading goes backwards Build: Record readings as insert-only rows with who took them. Drive meter plans from the difference since the last service reading rather than from any running total the app maintains. When a cumulative reading comes in lower than the previous one, raise a meter event, hold every plan on that meter, and require a human to confirm a replacement or a rollover before continuing. Trap: A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. Trap: Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. Done when: A cumulative reading lower than its predecessor raises a meter event and holds every plan on that meter, tested. Confirming a replacement stores an offset and resumes scheduling from the corrected basis, with the original readings untouched, tested. A meter plan schedules from readings alone with no running total stored on the asset, verified by review. A reading marked mistaken remains in the table and is excluded from calculation with the exclusion visible. Do not build yet: No roles, audit log, import, export or backup yet. ### Phase 5 · Roles, backup and a history worth having Build: Session authentication with an administrator and a technician role, hashed passwords, request forgery protection, upload limits and an audit log of every permission-relevant action. Back up the database using its own backup mechanism and the photograph directory in the same run. Restore both into an empty directory in the test suite. Export assets and full work history as CSV, and never delete a work log row. Trap: Copying the database file while the application is running produces a file that opens, passes a quick look, and is missing the most recent transactions, which is precisely the work you most wanted to keep. Use the database's own backup command, take the photograph directory in the same run, and assert the pair restores together in the suite. Trap: Letting an administrator edit a work log entry to fix a typo turns the asset history into an opinion, and the whole reason to keep ten years of it is that nobody can change it. Append a correction row that references the original instead, and render corrections next to what they correct. Done when: A technician cannot reach administrative pages or another technician's assignments, tested per route rather than assumed from the navigation. A backup taken while the application is under write load restores into an empty directory with every work order, log row and photograph present, exercised in the suite. No code path updates or deletes a work_log row, verified by searching the source. Export produces assets and the complete work history as CSV and reimports with identical row counts. Do not build yet: Nothing after this. ### Out of scope (and why) - Predictive maintenance from sensor data, which needs instrumentation, a data pipeline and enough failures to learn from before it says anything true. - Accounting and purchasing integrations, which are other systems' schemas and other people's approval rules rather than a maintenance problem. - Multi-site tenancy and single sign-on, which change the permission model from a single boundary to a matrix and belong in a different build. - A native mobile application, since a server-rendered page that works on one bar of signal beats an app store release cycle for this job. ### README must contain - That asset codes are assigned once, never reused, and independent of database identifiers - That preventive occurrences are keyed on plan and due date so the generator is safe to run twice - That a catch-up after an outage creates one occurrence and records how many were skipped - That a decreasing cumulative meter reading pauses the plans and needs a human to confirm - The backup command that takes the database and photograph directory together, and the restore that is tested
# Architecture · MaintenEase ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and server-rendered templates, no frontend build step | the phone in a plant room with one bar of signal should get HTML | | Storage | SQLite with better-sqlite3, photographs on disk beside it | one file to back up, and one directory that must travel with it | | Scheduling | An in-process job with its last run persisted, and occurrences keyed by plan and due date | the uniqueness key is what stops a restart creating four copies of the same job | | Notification | One push channel configured by URL, with every failure logged and visible | a notification nobody can confirm arrived is worse than none | | History | Append-only work log rows, never edited, never deleted | the asset history is the part that is worth money in five years | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Assets | Codes, hierarchy, labels and asset pages. | Any label format, provided the code never comes from a row id. | | Work | Queues, transitions, photographs and the log. | Any interface, provided every write is idempotent. | | Scheduling | Plans, occurrences, anchors and catch-up. | Any scheduler, provided occurrences are keyed on plan and due date. | | Meters | Readings, intervals, events and offsets. | Any source of readings, provided no running total is cached. | | Operations | Roles, audit log, backup and export. | Any store, provided database and photographs move together. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Assets, work orders and the whole work history. Back up with the photographs. - `PHOTO_DIR` · required · Work order attachments. Restored together with the database or not at all. - `SESSION_SECRET` · required, secret · Signs session cookies. Rotating it logs everyone out. - `NTFY_URL` · required, secret · Push endpoint for assignment and overdue alerts. Failures appear in the admin log. - `PM_LOOKAHEAD_DAYS` · required · How far ahead occurrences are generated. Longer means a fuller queue, not more work. - `PM_CATCHUP_MAX` · required · Occurrences created after an outage. Above one and you get an avalanche. - `MAX_UPLOAD_BYTES` · required · Per photograph, checked before anything is written to disk. - `BACKUP_DIR` · required · Database and photograph archive pairs. Keep generations. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · MaintenEase product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express and server-rendered templates, no frontend build step, SQLite with better-sqlite3, photographs on disk beside it, An in-process job with its last run persisted, and occurrences keyed by plan and due date, One push channel configured by URL, with every failure logged and visible, Append-only work log rows, never edited, never deleted. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. - Copying the database file while the application is running produces a file that opens, passes a quick look, and is missing the most recent transactions, which is precisely the work you most wanted to keep. Use the database's own backup command, take the photograph directory in the same run, and assert the pair restores together in the suite. - Letting an administrator edit a work log entry to fix a typo turns the asset history into an opinion, and the whole reason to keep ten years of it is that nobody can change it. Append a correction row that references the original instead, and render corrections next to what they correct.
# Delivery milestones · MaintenEase Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Assets, codes and a label that scans in a plant room Every asset has a code assigned once, a printable label, and a page that opens from scanning it. ### Steps 1. Assign codes independently of row identifiers An import must not renumber what is stuck to a machine. Files: `src/assets/codes.js`, `tests/codes.test.js` 2. Print the code as text under the symbol Labels get scratched and phones get left in the van. Files: `src/views/label.ejs` 3. Scan a real printed label in bad light The only test that predicts anything about the plant room. Files: `docs/label-test.md` ### Done when - [ ] Codes survive export and reimport unchanged - [ ] A scrapped asset's code is never reused - [ ] A production-size label scans in poor light - [ ] The code is readable as text on the label ### Watch out - Deriving the label from the database identifier means the first data import renumbers every asset and every label already stuck to a machine now points at the wrong one, which nobody discovers until two technicians file work against the same pump. Assign a code once, never reuse it, and keep it independent of any row identifier. - Encoder libraries differ on whether they emit the clear margin the symbol specification requires, and a label printed edge to edge on a sticker fails to scan for reasons that look like bad lighting. Read your encoder's documentation on the quiet zone, print a real label, and scan it through a smear of grease before you print two hundred. ## M2 · Work a technician can complete with one thumb The field views are large, fast, and safe to tap twice on a bad connection. ### Steps 1. Put common transitions on large buttons Gloves and a dropdown do not mix. Files: `src/views/queue.ejs` 2. Make every write idempotent with a token The append-only log cannot be tidied up later. Files: `src/middleware/idempotency.js`, `tests/idempotency.test.js` 3. Accept a camera photograph directly Evidence collected later is evidence not collected. Files: `src/work/photos.js` ### Done when - [ ] A replayed submission creates one log entry - [ ] Field views are usable at phone size with large targets - [ ] A camera photograph attaches and is stored on disk - [ ] Queue views return within the stated time on seeded data ### Watch out - A dropdown and a save button, on a phone, in gloves, means the work gets written up at the end of the shift from memory or not at all, and a maintenance system nobody updates in the field is a spreadsheet with extra steps. Put the three transitions people actually use on buttons big enough to hit without looking. - A flaky connection turns one status change into two identical log entries, and because the log is append-only you cannot tidy it up afterwards. Issue a token with the form, key the write on it, and make the second submission return the first result. ## M3 · Recurrence that neither duplicates nor avalanches Preventive plans generate exactly one occurrence per due date, and a scheduler outage does not produce a month of overdue work. ### Steps 1. Key occurrences uniquely on plan and due date Idempotence by constraint, not by hoping the job runs once. Files: `src/pm/generate.js`, `src/db/schema.sql` 2. Support both anchors and test the drift The two behaviours are genuinely different requirements. Files: `tests/pm-anchor.test.js` 3. Collapse a catch-up into one occurrence with a count Forty overdue jobs is the same as no overdue jobs. Files: `src/pm/catchup.js` ### Done when - [ ] A second generator run creates nothing - [ ] Due-date anchoring does not drift; completion anchoring follows - [ ] A catch-up makes one occurrence and records the skips - [ ] No successor is generated inside a close request ### Watch out - Creating the next occurrence inside the request that closes a work order means a scheduler that restarts mid-run, or two technicians closing at once, produces duplicate jobs, and duplicates in a maintenance queue destroy trust in the queue faster than anything else. Generate outside the request, key on plan and due date, and let a re-run be a no-op by construction. - Computing every occurrence that was missed while the scheduler was down gives you forty overdue jobs on Monday morning, nobody does them, and the overdue count stops meaning anything. Create the next one, record the skipped count on it, and show that count on the work order so the gap is visible rather than pretended away. ## M4 · Meters, and the day a reading goes backwards Meter-based plans work from irregular readings and survive a replaced or rolled-over gauge. ### Steps 1. Compute intervals from the readings table only A cached total is a corruption waiting for a bad reading. Files: `src/meters/interval.js` 2. Treat a decrease as an event needing a human Gauges get replaced and counters roll over. Files: `src/meters/events.js`, `tests/meter-rollover.test.js` 3. Keep mistaken readings and exclude them visibly Deleting the evidence is how history stops being trustworthy. Files: `src/meters/readings.js` ### Done when - [ ] A lower cumulative reading holds the plans and raises an event - [ ] Confirming a replacement resumes from a stored offset - [ ] No running total is stored on the asset - [ ] Mistaken readings are retained and visibly excluded ### Watch out - A run-hours gauge that is replaced reads from zero, and a plan that subtracts the last reading from the current one sees a large negative interval, decides the service is overdue by thousands of hours, and generates work forever. Treat a decrease on a cumulative meter as an event, not a data entry error, pause the plans, and store an offset once a human has confirmed what happened. - Maintaining a running total in the asset row means one bad reading corrupts every future calculation and there is no way back. Compute from the readings table every time, and keep the readings even when one is later marked as mistaken. ## M5 · Roles, backup and a history worth having (production only) Access is separated, the database and photographs are backed up together, and a restore is exercised rather than described. ### Steps 1. Test permissions per route, not per menu A hidden link is not access control. Files: `tests/permissions.test.js`, `src/auth/roles.js` 2. Back up database and photographs in one run Either alone restores to a system full of missing evidence. Files: `scripts/backup.sh`, `tests/restore.test.js` 3. Correct by appending, never by editing The history is only worth keeping if it cannot be rewritten. Files: `src/work/corrections.js` ### Done when - [ ] Route-level permission tests pass for both roles - [ ] A backup under write load restores rows and photographs - [ ] No code path edits or deletes a work log row - [ ] Export and reimport preserve asset and history counts ### Watch out - Copying the database file while the application is running produces a file that opens, passes a quick look, and is missing the most recent transactions, which is precisely the work you most wanted to keep. Use the database's own backup command, take the photograph directory in the same run, and assert the pair restores together in the suite. - Letting an administrator edit a work log entry to fix a typo turns the asset history into an opinion, and the whole reason to keep ten years of it is that nobody can change it. Append a correction row that references the original instead, and render corrections next to what they correct.
# Operations · MaintenEase ## Backup Nightly, using the database's own backup mechanism and archiving the photograph directory in the same run so the two are consistent with each other. Keep generations offsite, because the asset history is the thing that takes a decade to accumulate and an afternoon to lose. ## Restore Restore both into an empty directory, confirm the work order and work log counts against the last recorded figures, and open one work order with a photograph to prove the pairing. Then run the occurrence generator and confirm it creates nothing, which proves the uniqueness keys survived. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Scheduler last-run time against now, occurrences generated per day, notification failures, overdue count trend, and disk used by photographs against free space. A scheduler that silently stopped is the failure mode that costs the most and shows the least. ## Incident checklist The two incidents are duplicate work orders and a preventive plan that stopped generating. The occurrence uniqueness key prevents the first by construction; the last-run timestamp and the skipped counts are what make the second visible within a day rather than a quarter. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A second generator run is confirmed to create nothing - [ ] A replayed submission is confirmed to create one log entry - [ ] A decreasing cumulative reading is confirmed to hold the plans - [ ] No code path is confirmed to edit or delete a work log row - [ ] A backup under write load is confirmed to restore rows and photographs ## Launch constraint Do not market omitted MaintenEase capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, work orders and the whole work history. Back up with the photographs. DATABASE_PATH=./data/maintenance.db # Required. Work order attachments. Restored together with the database or not at all. PHOTO_DIR=./data/photos # Required · secret. Signs session cookies. Rotating it logs everyone out. SESSION_SECRET=hex-64-chars-redacted # Required · secret. Push endpoint for assignment and overdue alerts. Failures appear in the admin log. NTFY_URL=https://ntfy.example.com/maintenance # Required. How far ahead occurrences are generated. Longer means a fuller queue, not more work. PM_LOOKAHEAD_DAYS=14 # Required. Occurrences created after an outage. Above one and you get an avalanche. PM_CATCHUP_MAX=1 # Required. Per photograph, checked before anything is written to disk. MAX_UPLOAD_BYTES=8388608 # Required. Database and photograph archive pairs. Keep generations. BACKUP_DIR=/var/backups/maintenance
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay for a dependable shared system that keeps technicians, schedules, evidence, and years of asset history available without maintaining the infrastructure themselves.
xmanaged backups and hosted reliability
xpolished mobile field workflows
xadvanced analytics and predictive features
xintegrations, API, and SSO
xonboarding and data-import support
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
MaintenEase pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $49 | — | 2 seats; work orders, assets, and preventive maintenance. |
| pro | $129 | — | 4 seats; expanded workflows and reporting. |
| business | $299 | — | 4 seats included; unlimited assets and work orders. |
free tierno free tier
billingmonthly only; no annual plan was published
hidden costsBusiness includes 4 seats, then charges $15/month for each additional seat.
verified 2026-08-14 · source ↗
Vibecode MaintenEase
Kinda. The core of MaintenEase is buildable in a weekend with the prompt on this page, but there are real gaps: managed backups and hosted reliability, polished mobile field workflows. Read the honest list above before committing.
How much does MaintenEase cost?
MaintenEase costs about $49/month (Starter, checked 2026-07-30), which is $588 per year.
What do I lose by replacing MaintenEase?
Honestly: managed backups and hosted reliability; polished mobile field workflows; advanced analytics and predictive features; integrations, API, and SSO; onboarding and data-import support. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to MaintenEase?
Yes: Atlas CMMS (Jira for technicians: work orders, assets, PM schedules, and a server you now own) The prompt is for when you want it exactly your way.