diff --git a/backend/src/db.js b/backend/src/db.js index e005457..567c4fc 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -94,6 +94,7 @@ export async function initDb() { interval_value INT NOT NULL DEFAULT 1, interval_unit TEXT NOT NULL DEFAULT 'months', -- days | weeks | months next_due DATE NOT NULL, + lead_days INT NOT NULL DEFAULT 0, -- generate the task this many days before next_due (due_date stays the real date) template_notes TEXT NOT NULL DEFAULT '', -- carried onto every future occurrence active BOOLEAN NOT NULL DEFAULT TRUE, schedule_mode TEXT NOT NULL DEFAULT 'fixed', -- fixed (anchored to next_due) | floating (anchored to completion) @@ -190,6 +191,9 @@ export async function initDb() { await pool.query(` ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS group_id INT REFERENCES template_groups(id) ON DELETE SET NULL `) + await pool.query(` + ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS lead_days INT NOT NULL DEFAULT 0 + `) await seedDefaults() } diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js index 45174af..744567b 100644 --- a/backend/src/lib/scheduler.js +++ b/backend/src/lib/scheduler.js @@ -5,6 +5,11 @@ import { addInterval, isoDate } from './recurrence.js' const CHECK_INTERVAL_MS = 60 * 60 * 1000 // hourly // Spawn one task per due template. +// - lead_days brings generation forward of next_due (e.g. a septic tank +// empty-out wants 30 days' warning to book a contractor; an AC filter +// clean might only need 2-3) — the spawned task's due_date is still the +// real next_due, so overdue/priority handling is unaffected, it just +// appears in the task list earlier. // - Fixed templates: advance next_due past today immediately, so a backlog // after downtime produces a single task, not one per missed occurrence. // - Floating templates: leave next_due untouched and mark the template @@ -15,7 +20,7 @@ export async function spawnDueTemplates(log = console) { const today = isoDate(new Date()) const { rows: due } = await pool.query( `SELECT * FROM task_templates - WHERE active = TRUE AND next_due <= $1 AND (schedule_mode != 'floating' OR pending_task_id IS NULL) + WHERE active = TRUE AND (next_due - lead_days) <= $1 AND (schedule_mode != 'floating' OR pending_task_id IS NULL) ORDER BY id`, [today] ) diff --git a/backend/src/routes/templates.js b/backend/src/routes/templates.js index d790c95..40b1448 100644 --- a/backend/src/routes/templates.js +++ b/backend/src/routes/templates.js @@ -52,6 +52,8 @@ export async function templateRoutes(app) { if (b.schedule_mode && !SCHEDULE_MODES.includes(b.schedule_mode)) return reply.status(400).send({ error: 'Invalid schedule_mode' }) const interval = parseInt(b.interval_value) || 1 if (interval < 1) return reply.status(400).send({ error: 'interval_value must be at least 1' }) + const leadDays = b.lead_days !== undefined ? parseInt(b.lead_days) || 0 : 0 + if (leadDays < 0) return reply.status(400).send({ error: 'lead_days cannot be negative' }) if (b.group_id) { const { rows: g } = await pool.query('SELECT id FROM template_groups WHERE id = $1', [b.group_id]) if (!g.length) return reply.status(400).send({ error: 'Unknown group_id' }) @@ -60,9 +62,9 @@ export async function templateRoutes(app) { const { rows } = await pool.query( `INSERT INTO task_templates (title, description, location_id, asset_id, priority, unusable, assigned_type, assigned_to, - assigned_to_name, contractor_id, interval_value, interval_unit, next_due, template_notes, + assigned_to_name, contractor_id, interval_value, interval_unit, next_due, lead_days, template_notes, schedule_mode, group_id, created_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING *`, + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) RETURNING *`, [ b.title, b.description || null, b.location_id, b.asset_id || null, b.priority || 'medium', b.unusable === true, @@ -70,7 +72,7 @@ export async function templateRoutes(app) { b.assigned_type === 'contractor' ? null : (b.assigned_to || null), b.assigned_type === 'contractor' ? null : (b.assigned_to_name || null), b.assigned_type === 'contractor' ? (b.contractor_id || null) : null, - interval, b.interval_unit || 'months', b.next_due, + interval, b.interval_unit || 'months', b.next_due, leadDays, b.template_notes || '', b.schedule_mode || 'fixed', b.group_id || null, req.user.email, ] ) @@ -85,6 +87,7 @@ export async function templateRoutes(app) { if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'Invalid priority' }) if (b.interval_unit && !INTERVAL_UNITS.includes(b.interval_unit)) return reply.status(400).send({ error: 'Invalid interval_unit' }) if (b.schedule_mode && !SCHEDULE_MODES.includes(b.schedule_mode)) return reply.status(400).send({ error: 'Invalid schedule_mode' }) + if (b.lead_days !== undefined && parseInt(b.lead_days) < 0) return reply.status(400).send({ error: 'lead_days cannot be negative' }) if (b.group_id) { const { rows: g } = await pool.query('SELECT id FROM template_groups WHERE id = $1', [b.group_id]) if (!g.length) return reply.status(400).send({ error: 'Unknown group_id' }) @@ -96,8 +99,8 @@ export async function templateRoutes(app) { title = $1, description = $2, location_id = $3, asset_id = $4, priority = $5, unusable = $6, assigned_type = $7, assigned_to = $8, assigned_to_name = $9, contractor_id = $10, interval_value = $11, interval_unit = $12, next_due = $13, template_notes = $14, active = $15, - schedule_mode = $16, group_id = $17 - WHERE id = $18 RETURNING *`, + schedule_mode = $16, group_id = $17, lead_days = $18 + WHERE id = $19 RETURNING *`, [ b.title ?? t.title, b.description !== undefined ? b.description : t.description, @@ -116,6 +119,7 @@ export async function templateRoutes(app) { b.active ?? t.active, b.schedule_mode ?? t.schedule_mode, b.group_id !== undefined ? (b.group_id || null) : t.group_id, + b.lead_days !== undefined ? parseInt(b.lead_days) || 0 : t.lead_days, req.params.id, ] ) diff --git a/frontend/src/pages/Recurring.tsx b/frontend/src/pages/Recurring.tsx index 8be3cca..f4f98d1 100644 --- a/frontend/src/pages/Recurring.tsx +++ b/frontend/src/pages/Recurring.tsx @@ -21,6 +21,7 @@ interface TemplateForm { interval_value: number interval_unit: 'days' | 'weeks' | 'months' next_due: string + lead_days: number template_notes: string active: boolean schedule_mode: ScheduleMode @@ -33,7 +34,7 @@ interface TemplateForm { const EMPTY: TemplateForm = { title: '', description: '', location_id: '', asset_id: '', priority: 'medium', unusable: false, - interval_value: 1, interval_unit: 'months', next_due: '', template_notes: '', active: true, + interval_value: 1, interval_unit: 'months', next_due: '', lead_days: 0, template_notes: '', active: true, schedule_mode: 'fixed', group_name: '', assignment: { assigned_type: 'staff', assigned_to: null, assigned_to_name: null, contractor_id: null }, multiLocation: false, location_ids: [], @@ -105,7 +106,7 @@ export default function Recurring() { id: t.id, title: t.title, description: t.description || '', location_id: t.location_id, asset_id: t.asset_id || '', priority: t.priority, unusable: t.unusable, interval_value: t.interval_value, interval_unit: t.interval_unit, - next_due: t.next_due.slice(0, 10), template_notes: t.template_notes, active: t.active, + next_due: t.next_due.slice(0, 10), lead_days: t.lead_days, template_notes: t.template_notes, active: t.active, schedule_mode: t.schedule_mode, group_name: t.group_name || '', assignment: { assigned_type: t.assigned_type, assigned_to: t.assigned_to, @@ -124,7 +125,7 @@ export default function Recurring() { title: t.title, description: t.description || '', location_id: '', asset_id: '', priority: t.priority, unusable: t.unusable, interval_value: t.interval_value, interval_unit: t.interval_unit, - next_due: t.next_due.slice(0, 10), template_notes: t.template_notes, active: true, + next_due: t.next_due.slice(0, 10), lead_days: t.lead_days, template_notes: t.template_notes, active: true, schedule_mode: t.schedule_mode, group_name: t.group_name || '', assignment: { assigned_type: t.assigned_type, assigned_to: t.assigned_to, @@ -166,6 +167,7 @@ export default function Recurring() { interval_value: form.interval_value, interval_unit: form.interval_unit, next_due: form.next_due, + lead_days: form.lead_days, template_notes: form.template_notes, active: form.active, schedule_mode: form.schedule_mode, @@ -218,6 +220,7 @@ export default function Recurring() { {t.schedule_mode === 'floating' && t.pending_task_id ? awaiting completion : formatDate(t.next_due)} + {t.lead_days > 0 && generates {t.lead_days}d early}