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:
jtricerolph 2026-08-26 10:14:03 +00:00
parent aaf31f26b7
commit a661a06a8f
7 changed files with 110 additions and 27 deletions

View file

@ -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()
}

View 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)
}

View file

@ -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) {

View file

@ -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]
})

View file

@ -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,
]
)

View file

@ -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() {
<td>{t.location_name}</td>
<td>{t.asset_name || '—'}</td>
<td><PriorityBadge priority={t.priority} /></td>
<td>{t.interval_value} {t.interval_unit}</td>
<td className={new Date(t.next_due) <= new Date() ? 'overdue' : ''}>{formatDate(t.next_due)}</td>
<td>{t.interval_value} {t.interval_unit}{t.schedule_mode === 'floating' && <span className="field-hint" style={{ display: 'block' }}>after completion</span>}</td>
<td className={!t.pending_task_id && new Date(t.next_due) <= new Date() ? 'overdue' : ''}>
{t.schedule_mode === 'floating' && t.pending_task_id
? <span className="badge badge-outline">awaiting completion</span>
: formatDate(t.next_due)}
</td>
<td>{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}</td>
<td>{!t.active && <span className="badge badge-outline">paused</span>}</td>
</tr>
@ -187,6 +195,22 @@ export default function Recurring() {
))}
</div>
</div>
<div className="field">
<label>Schedule</label>
<div className="chip-bar" style={{ marginBottom: 6 }}>
<button type="button" className={`chip ${form.schedule_mode === 'fixed' ? 'active' : ''}`} onClick={() => setForm({ ...form, schedule_mode: 'fixed' })}>
Fixed
</button>
<button type="button" className={`chip ${form.schedule_mode === 'floating' ? 'active' : ''}`} onClick={() => setForm({ ...form, schedule_mode: 'floating' })}>
Floating
</button>
</div>
<div className="field-hint">
{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.'}
</div>
</div>
<div className="field-row">
<div className="field"><label>Repeat every</label>
<input type="number" min={1} value={form.interval_value} onChange={e => setForm({ ...form, interval_value: parseInt(e.target.value) || 1 })} />

View file

@ -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<Pick<Task, 'id' | 'title' | 'status' | 'priority' | 'created_at' | 'completed_at' | 'location_name'>>
}
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 {