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
|
|
@ -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 })} />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue