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

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