diff --git a/backend/src/db.js b/backend/src/db.js index 40f4f76..1c3725e 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -87,6 +87,7 @@ export async function initDb() { next_due DATE NOT NULL, 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) created_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -167,6 +168,16 @@ export async function initDb() { ALTER TABLE task_events ADD COLUMN IF NOT EXISTS photo_id INT REFERENCES task_photos(id) ON DELETE SET NULL `) + // Floating recurrence: the task currently spawned from a template, awaiting + // resolution. Only meaningful for schedule_mode = 'floating' (see scheduler.js + // and the /api/tasks/:id/resolve handler); NULL means no occurrence is open. + await pool.query(` + ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS schedule_mode TEXT NOT NULL DEFAULT 'fixed' + `) + await pool.query(` + ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS pending_task_id INT REFERENCES tasks(id) ON DELETE SET NULL + `) + await seedDefaults() } diff --git a/backend/src/lib/recurrence.js b/backend/src/lib/recurrence.js new file mode 100644 index 0000000..75fea3c --- /dev/null +++ b/backend/src/lib/recurrence.js @@ -0,0 +1,15 @@ +// Shared date-interval helpers for recurring task templates. +// Used by both the scheduler (fixed-schedule rollover) and the resolve +// route (floating-schedule rollover on completion). + +export function addInterval(date, value, unit) { + const d = new Date(date) + if (unit === 'days') d.setDate(d.getDate() + value) + else if (unit === 'weeks') d.setDate(d.getDate() + value * 7) + else d.setMonth(d.getMonth() + value) + return d +} + +export function isoDate(d) { + return d.toISOString().slice(0, 10) +} diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js index 40a382a..45174af 100644 --- a/backend/src/lib/scheduler.js +++ b/backend/src/lib/scheduler.js @@ -1,26 +1,22 @@ import { pool } from '../db.js' import { createTask, logEvent } from './task-core.js' +import { addInterval, isoDate } from './recurrence.js' const CHECK_INTERVAL_MS = 60 * 60 * 1000 // hourly -function addInterval(date, value, unit) { - const d = new Date(date) - if (unit === 'days') d.setDate(d.getDate() + value) - else if (unit === 'weeks') d.setDate(d.getDate() + value * 7) - else d.setMonth(d.getMonth() + value) - return d -} - -function isoDate(d) { - return d.toISOString().slice(0, 10) -} - -// Spawn one task per due template, then advance next_due past today so a -// backlog after downtime produces a single task, not one per missed occurrence. +// Spawn one task per due template. +// - 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 +// "pending" instead — the next date is only computed once this task is +// actually resolved (see /api/tasks/:id/resolve), and pending_task_id +// stops this same occurrence being re-spawned on every hourly sweep. 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 ORDER BY id`, + `SELECT * FROM task_templates + WHERE active = TRUE AND next_due <= $1 AND (schedule_mode != 'floating' OR pending_task_id IS NULL) + ORDER BY id`, [today] ) @@ -49,9 +45,13 @@ export async function spawnDueTemplates(log = console) { }) } - let next = addInterval(tpl.next_due, tpl.interval_value, tpl.interval_unit) - while (isoDate(next) <= today) next = addInterval(next, tpl.interval_value, tpl.interval_unit) - await pool.query('UPDATE task_templates SET next_due = $1 WHERE id = $2', [isoDate(next), tpl.id]) + if (tpl.schedule_mode === 'floating') { + await pool.query('UPDATE task_templates SET pending_task_id = $1 WHERE id = $2', [task.id, tpl.id]) + } else { + let next = addInterval(tpl.next_due, tpl.interval_value, tpl.interval_unit) + while (isoDate(next) <= today) next = addInterval(next, tpl.interval_value, tpl.interval_unit) + await pool.query('UPDATE task_templates SET next_due = $1 WHERE id = $2', [isoDate(next), tpl.id]) + } log.info?.(`Scheduler spawned task ${task.id} from template ${tpl.id} (${tpl.title})`) } catch (err) { diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index 0763282..76795a7 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -1,6 +1,7 @@ import { requireAuth, requireCap, hasCap } from '../auth.js' import { pool, getConfig } from '../db.js' import { createTask, logEvent, TRANSITIONS, PRIORITIES, STATUSES } from '../lib/task-core.js' +import { addInterval, isoDate } from '../lib/recurrence.js' import { fetchBookings } from '../lib/newbook.js' import { notifyAssignment } from '../lib/mailer.js' import { notifyAssignmentPush } from '../lib/push.js' @@ -121,7 +122,7 @@ export async function taskRoutes(app) { let template = null if (task.template_id) { const { rows: tpl } = await pool.query( - 'SELECT id, title, interval_value, interval_unit, next_due, active FROM task_templates WHERE id = $1', + 'SELECT id, title, interval_value, interval_unit, next_due, active, schedule_mode, pending_task_id FROM task_templates WHERE id = $1', [task.template_id] ) template = tpl[0] || null @@ -272,6 +273,26 @@ export async function taskRoutes(app) { await logEvent(task.id, 'cost', { note: `Cost recorded: £${cost}${b.cost_notes ? ` — ${b.cost_notes}` : ''}`, userName: req.user.name }) } + // Floating recurrence: only a full 'fixed' resolution starts the clock on the + // next occurrence (a temporary_fix means the job isn't really done yet). + // Re-fetch the template fresh so a mid-cycle interval edit takes effect from + // here, not retroactively. pending_task_id is always cleared once this task + // resolves, even if the template's mode changed while the task was open — + // otherwise a stale pending flag could block the template forever. + if (task.template_id) { + const { rows: tplRows } = await pool.query('SELECT * FROM task_templates WHERE id = $1', [task.template_id]) + const tpl = tplRows[0] + if (tpl && tpl.pending_task_id === task.id) { + if (tpl.schedule_mode === 'floating' && status === 'fixed') { + const next = addInterval(rows[0].completed_at, tpl.interval_value, tpl.interval_unit) + await pool.query('UPDATE task_templates SET next_due = $1, pending_task_id = NULL WHERE id = $2', + [isoDate(next), tpl.id]) + } else if (status === 'fixed') { + await pool.query('UPDATE task_templates SET pending_task_id = NULL WHERE id = $1', [tpl.id]) + } + } + } + return rows[0] }) diff --git a/backend/src/routes/templates.js b/backend/src/routes/templates.js index fe232cf..dc233be 100644 --- a/backend/src/routes/templates.js +++ b/backend/src/routes/templates.js @@ -4,6 +4,7 @@ import { PRIORITIES } from '../lib/task-core.js' import { spawnDueTemplates } from '../lib/scheduler.js' const INTERVAL_UNITS = ['days', 'weeks', 'months'] +const SCHEDULE_MODES = ['fixed', 'floating'] export async function templateRoutes(app) { app.addHook('preHandler', requireAuth) @@ -28,14 +29,16 @@ 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' }) const interval = parseInt(b.interval_value) || 1 if (interval < 1) return reply.status(400).send({ error: 'interval_value must be at least 1' }) 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, created_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, + assigned_to_name, contractor_id, interval_value, interval_unit, next_due, template_notes, + schedule_mode, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING *`, [ b.title, b.description || null, b.location_id, b.asset_id || null, b.priority || 'medium', b.unusable === true, @@ -44,7 +47,7 @@ export async function templateRoutes(app) { 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, - b.template_notes || '', req.user.email, + b.template_notes || '', b.schedule_mode || 'fixed', req.user.email, ] ) return rows[0] @@ -57,14 +60,16 @@ export async function templateRoutes(app) { const b = req.body || {} 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' }) const newType = b.assigned_type || t.assigned_type const { rows } = await pool.query( `UPDATE task_templates SET 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 - WHERE id = $16 RETURNING *`, + interval_value = $11, interval_unit = $12, next_due = $13, template_notes = $14, active = $15, + schedule_mode = $16 + WHERE id = $17 RETURNING *`, [ b.title ?? t.title, b.description !== undefined ? b.description : t.description, @@ -81,6 +86,7 @@ export async function templateRoutes(app) { b.next_due ?? t.next_due, b.template_notes !== undefined ? b.template_notes : t.template_notes, b.active ?? t.active, + b.schedule_mode ?? t.schedule_mode, req.params.id, ] ) diff --git a/frontend/src/pages/Recurring.tsx b/frontend/src/pages/Recurring.tsx index 8b8271a..d0f0aa7 100644 --- a/frontend/src/pages/Recurring.tsx +++ b/frontend/src/pages/Recurring.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Plus, X, Play } from 'lucide-react' -import type { Template, Category, Location, Asset, Priority } from '../types' +import type { Template, Category, Location, Asset, Priority, ScheduleMode } from '../types' import { PRIORITIES, PRIORITY_LABELS, can } from '../types' import { fetchTemplates, createTemplate, updateTemplate, runDueTemplates, fetchLocations, fetchAssets } from '../api' import { useAuth } from '../components/AuthGate' @@ -20,12 +20,14 @@ interface TemplateForm { next_due: string template_notes: string active: boolean + schedule_mode: ScheduleMode assignment: Assignment } 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, + schedule_mode: 'fixed', assignment: { assigned_type: 'staff', assigned_to: null, assigned_to_name: null, contractor_id: null }, } @@ -78,6 +80,7 @@ export default function Recurring() { next_due: form.next_due, template_notes: form.template_notes, active: form.active, + schedule_mode: form.schedule_mode, assigned_type: form.assignment.assigned_type, assigned_to: form.assignment.assigned_to, assigned_to_name: form.assignment.assigned_to_name, @@ -124,6 +127,7 @@ export default function Recurring() { 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, + schedule_mode: t.schedule_mode, assignment: { assigned_type: t.assigned_type, assigned_to: t.assigned_to, assigned_to_name: t.assigned_to_name, contractor_id: t.contractor_id, @@ -133,8 +137,12 @@ export default function Recurring() { {t.location_name} {t.asset_name || '—'} - {t.interval_value} {t.interval_unit} - {formatDate(t.next_due)} + {t.interval_value} {t.interval_unit}{t.schedule_mode === 'floating' && after completion} + + {t.schedule_mode === 'floating' && t.pending_task_id + ? awaiting completion + : formatDate(t.next_due)} + {t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')} {!t.active && paused} @@ -187,6 +195,22 @@ export default function Recurring() { ))} +
+ +
+ + +
+
+ {form.schedule_mode === 'fixed' + ? 'Always due on the same recurring date, regardless of when each occurrence is actually completed.' + : 'The next occurrence is due one interval after each task is marked fixed — late or early completion shifts the whole schedule.'} +
+
setForm({ ...form, interval_value: parseInt(e.target.value) || 1 })} /> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cf7ca40..5a5eae4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -130,6 +130,8 @@ export interface TaskDetail extends Task { interval_unit: string next_due: string active: boolean + schedule_mode: ScheduleMode + pending_task_id: number | null } | null } @@ -183,6 +185,8 @@ export interface ContractorDetail extends Contractor { tasks: Array> } +export type ScheduleMode = 'fixed' | 'floating' + export interface Template { id: number title: string @@ -203,6 +207,8 @@ export interface Template { next_due: string template_notes: string active: boolean + schedule_mode: ScheduleMode + pending_task_id: number | null } export interface AppConfig {