Add fixed/floating schedule mode to recurring task templates
Fixed mode keeps the existing behavior: next_due always advances from
the previous scheduled date, regardless of when a task is actually
completed. Floating mode instead computes the next occurrence from
the completion date of the current task, so early/late completion
shifts the whole schedule.
- task_templates gains schedule_mode ('fixed'|'floating', default
'fixed') and pending_task_id, tracking the open task spawned from a
floating template so the hourly sweep doesn't respawn duplicates
before it's resolved.
- scheduler.js: floating templates set pending_task_id instead of
advancing next_due at spawn time.
- tasks.js /resolve: floating templates recompute next_due from
completed_at once the task is marked fixed (not temporary_fix,
since that isn't really done yet); pending_task_id is always
cleared on fixed resolution so a stale flag can't wedge the
template if the mode changed mid-cycle.
- addInterval/isoDate pulled out of scheduler.js into a shared
lib/recurrence.js used by both the scheduler and the resolve route.
- Recurring.tsx: schedule mode toggle in the template form, and the
list shows "awaiting completion" instead of a stale next-due date
for floating templates with an open occurrence.
This commit is contained in:
parent
aaf31f26b7
commit
a661a06a8f
7 changed files with 110 additions and 27 deletions
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
15
backend/src/lib/recurrence.js
Normal file
15
backend/src/lib/recurrence.js
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue