From fcecf314a921fe4b213ad1e711f28d99761a439a Mon Sep 17 00:00:00 2001 From: joewolly Date: Fri, 25 Sep 2026 15:45:53 -0600 Subject: [PATCH] Add rotating household chores roster --- README.md | 6 + docs/planning.md | 31 +++ public/app.js | 10 + public/index.html | 1 + public/planning.js | 121 +++++++++++- public/styles.css | 84 ++++++++ public/sw.js | 2 +- src/api/schedules.js | 356 +++++++++++++++++++++++++++++++-- src/api/tickets.js | 62 +++++- src/db.js | 26 +++ src/recurrence.js | 12 +- src/server.js | 21 ++ test/chores.test.js | 391 +++++++++++++++++++++++++++++++++++++ test/planning-http.test.js | 222 +++++++++++++++++++++ test/planning.test.js | 45 ++++- test/queues.test.js | 39 +++- test/schedules.test.js | 269 ++++++++++++++++++++++++- 17 files changed, 1671 insertions(+), 27 deletions(-) create mode 100644 test/chores.test.js diff --git a/README.md b/README.md index 7de5d5d..4297061 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,12 @@ machine you no longer own is usually the part worth keeping. swaps, pool scrubs. A schedule is a ticket template plus a cadence, and it opens a real ticket when the work comes due. See [Recurring maintenance](#recurring-maintenance). +**Shared chores** are a separate, opt-in workflow for two people using the same +login. Chores appear on a Sunday–Saturday roster, can use the supported routine +cadences, and keep their assignment and completion history. Existing routines +are not converted into chores automatically, and chore assignments do not create +person-specific alerts. See [Shared chores](docs/planning.md#shared-household-chores). + **The dashboard** surfaces open counts by priority, which devices have the most unresolved work, anything overdue, and anything open that hasn't been touched in two weeks in Next — the tasks you forgot rather than finished. diff --git a/docs/planning.md b/docs/planning.md index 8844743..2c16564 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -98,6 +98,37 @@ Old schedules retain their existing `interval_days` and lead-time behavior. The legacy editor remains available; a 30-day interval is labeled **Every 30 days**, not monthly. Pausing or deleting a schedule preserves its task history. +## Shared household chores + +Open **Chores** to set the two household display names and add chore templates. +The app still has one shared sign-in: either person can see and edit every task. +Chores are opt-in, use the recurrence rules above, and create new ordinary tasks +with an assignee. Existing routines are not enrolled or converted into chores. +Chore tasks also appear in Today, search, task lists, and the calendar, with +their usual checklist and notification behavior. There are no person-specific +notifications. + +The roster groups assignments by person for a Sunday–Saturday week in the +configured application timezone. Each task keeps its own due date, not the end +of the week. Known dates in the current week are assigned when the week starts; +a new chore due later in the current week joins when created. Only one task per +chore may remain unfinished. Later dates that depend on finishing it appear as +conditional previews, not assignments. Missed dates during downtime are +skipped rather than backfilled; an unfinished task remains overdue with its +original date and owner. + +Weekly load includes completed assignments and open overdue carryovers. When +loads tie, rotation considers the previous actual assignee and the preceding +four weeks. Assignments stay put once made. If the other person did a chore, +reassign that task before marking it done so the right person receives credit; +the change appears in its history and affects the next rotation. Browse earlier +weeks to see final assignments and reassignment history. + +Editing a chore template changes only future tasks. Edit an existing task +separately. Archive a chore to stop future assignments without deleting its +history or closing any unfinished task. Completed chore tasks cannot be +reopened while a newer occurrence remains unfinished. + ## Install on iPhone Use the stable private **HTTPS** address described in [Deployment](deployment.md). diff --git a/public/app.js b/public/app.js index f33df1d..0c7c601 100644 --- a/public/app.js +++ b/public/app.js @@ -123,6 +123,14 @@ const statusBadge = (status) => label(status), ); +const choreAssigneeBadge = (ticket) => + ticket.is_chore && + el( + 'span', + { class: 'badge chore-assignee-badge' }, + `Chore · ${ticket.assignee_name ? `Assigned to ${ticket.assignee_name}` : 'Unassigned'}`, + ); + const priorityBadge = (priority) => el( 'span', @@ -1055,6 +1063,7 @@ function ticketRow(ticket, onToggle) { ), statusBadge(ticket.status), priorityBadge(ticket.priority), + choreAssigneeBadge(ticket), ticket.device_name && el('span', {}, `· ${ticket.device_name}`), ticket.due_date && el( @@ -1174,6 +1183,7 @@ async function renderTicketDetail(view, id) { el('span', { class: 'id mono muted' }, `#${ticket.id}`), statusBadge(ticket.status), priorityBadge(ticket.priority), + choreAssigneeBadge(ticket), el( 'span', { class: 'badge queue-badge' }, diff --git a/public/index.html b/public/index.html index 1308ecd..2ae85c0 100644 --- a/public/index.html +++ b/public/index.html @@ -44,6 +44,7 @@ Dashboard Devices Schedules + Chores diff --git a/public/planning.js b/public/planning.js index 0f537e2..b70d3f5 100644 --- a/public/planning.js +++ b/public/planning.js @@ -749,7 +749,7 @@ export function recurrenceLabel(schedule) { : `Every ${r.days} days`; } -export async function recurrenceModal(ticket = null, schedule = null) { +export async function recurrenceModal(ticket = null, schedule = null, isChore = false) { const { el, api, field, select, openModal } = ui; const projects = await api('/projects'), devices = await api('/devices'), @@ -849,7 +849,7 @@ export async function recurrenceModal(ticket = null, schedule = null) { kind.addEventListener('change', draw); draw(); openModal( - schedule ? 'Edit routine' : 'New routine', + schedule ? (isChore || schedule.is_chore ? 'Edit chore' : 'Edit routine') : (isChore ? 'New chore' : 'New routine'), () => el( 'div', @@ -954,6 +954,7 @@ export async function recurrenceModal(ticket = null, schedule = null) { project_id: data.project_id || null, device_id: data.device_id || null, recurrence, + ...(isChore || schedule?.is_chore ? { is_chore: true } : {}), checklist: data.checklist .split('\n') .map((s) => s.trim()) @@ -964,13 +965,127 @@ export async function recurrenceModal(ticket = null, schedule = null) { `/schedules${schedule ? '/' + schedule.id : ''}`, { method: schedule ? 'PATCH' : 'POST', body }, ); - location.hash = `#/schedules/${result.id}`; + location.hash = isChore || schedule?.is_chore ? '#/chores' : `#/schedules/${result.id}`; await ui.render(); }, ); } +const choreDate = (date) => new Date(`${date}T12:00:00Z`); +const isoDate = (date) => date.toISOString().slice(0, 10); +const shiftDate = (date, days) => { const d = choreDate(date); d.setUTCDate(d.getUTCDate() + days); return isoDate(d); }; +const weekStart = (date) => shiftDate(date, -choreDate(date).getUTCDay()); +const prettyDate = (date, options = { weekday: 'short', month: 'short', day: 'numeric' }) => choreDate(date).toLocaleDateString(undefined, options); + +function memberSetup(members) { + const { el, field, openModal } = ui; + openModal('People on this roster', () => el('div', {}, + el('p', { class: 'muted' }, 'Use the names you want to see beside each week’s chores.'), + ...members.map((member, i) => field(`Person ${i + 1}`, el('input', { name: `member_${member.id}`, value: member.name || '', required: true, maxlength: 80 }))), + ), async (data) => { + for (const member of members) await ui.api(`/chores/members/${member.id}`, { method: 'PATCH', body: { name: data[`member_${member.id}`] } }); + await ui.render(); + }); +} + +function choreCard(item, members, today) { + const { el, api, field, select } = ui; + const overdue = item.status !== 'resolved' && item.status !== 'closed' && item.due_date < today; + const completed = item.status === 'resolved' || item.status === 'closed'; + const memberOptions = members.map((m) => [m.id, m.name]); + const assignmentEvents = (item.events || []).filter((event) => event.kind === 'assignee'); + const eventLine = (event) => { + const from = event.from_value || 'Unassigned'; + const to = event.to_value || 'Unassigned'; + const change = from === 'Unassigned' ? `Assigned to ${to}` : to === 'Unassigned' ? `Unassigned from ${from}` : `Reassigned from ${from} to ${to}`; + const timestamp = event.created_at + ? new Date(`${String(event.created_at).replace(' ', 'T')}Z`).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) + : ''; + return timestamp ? `${change} · ${timestamp}` : change; + }; + return el('article', { class: `chore-item${completed ? ' is-complete' : ''}${overdue ? ' is-overdue' : ''}` }, + el('div', { class: 'chore-item-main' }, + el('span', { class: 'chore-date' }, prettyDate(item.due_date, { weekday: 'short', month: 'short', day: 'numeric' })), + el('div', { class: 'chore-copy' }, + el('a', { class: 'chore-title', href: `#/tickets/${item.id}` }, item.title), + el('span', { class: `chore-status${overdue ? ' overdue' : ''}` }, completed ? 'Done' : overdue ? 'Overdue' : (item.status === 'in_progress' ? 'In progress' : item.status === 'blocked' ? 'Blocked' : 'To do')), + ), + field('Assigned to', select(`assignee_${item.id}`, memberOptions, item.assignee_id, ui.guard(async (e) => { + await api(`/tickets/${item.id}`, { method: 'PATCH', body: { assignee_id: Number(e.target.value) } }); + await ui.render(); + }))), + ), + !completed && el('div', { class: 'chore-card-actions' }, + el('button', { type: 'button', class: 'btn btn-sm chore-done', onclick: ui.guard(async () => { + await api(`/tickets/${item.id}`, { method: 'PATCH', body: { status: 'resolved' } }); + await ui.render(); + }) }, 'Mark done'), + item.original_due_date && item.original_due_date !== item.due_date && el('span', { class: 'muted' }, `Originally due ${prettyDate(item.original_due_date)}`), + ), + assignmentEvents.length > 0 && el('details', { class: 'chore-history' }, el('summary', {}, 'Assignment history'), + ...assignmentEvents.map((event) => el('p', {}, eventLine(event)))), + ); +} + +async function renderChores(view, query = {}) { + const { el, api } = ui; + const payload = await api(`/chores${query.week ? `?week=${encodeURIComponent(query.week)}` : ''}`); + const start = payload.week_start || weekStart(query.week || payload.today); + const end = payload.week_end || shiftDate(start, 6); + const currentWeek = start === weekStart(payload.today); + const activeTemplates = payload.chores.filter((c) => !c.archived); + const archivedTemplates = payload.chores.filter((c) => c.archived); + const templateCards = activeTemplates.map((schedule) => el('article', { class: 'template-row' }, + el('div', {}, el('strong', {}, schedule.title), el('span', {}, recurrenceLabel(schedule))), + el('div', { class: 'planning-actions' }, + button('Edit chore', () => recurrenceModal(null, schedule, true)), + button('Archive', async () => { + await api(`/schedules/${schedule.id}`, { method: 'PATCH', body: { archived: true } }); + await ui.render(); + }), + ), + )); + const dayCount = payload.assignments.filter((a) => a.status !== 'resolved' && a.status !== 'closed').length; + const dayLabel = currentWeek ? 'This week' : (start > weekStart(payload.today) ? 'Coming up' : 'Past week'); + view.append(el('section', { class: 'chores-hero' }, + el('div', { class: 'chores-kicker' }, 'THE HOME ROUTINE'), + el('div', { class: 'chores-hero-line' }, + el('div', {}, el('h1', {}, 'Chores'), el('p', {}, 'A shared plan for the everyday things.'), + el('div', { class: 'week-switch' }, + el('a', { class: 'btn btn-sm', href: `#/chores?week=${shiftDate(start, -7)}`, 'aria-label': 'Previous week' }, '←'), + el('strong', {}, `${prettyDate(start, { month: 'long', day: 'numeric' })} – ${prettyDate(end, { month: 'long', day: 'numeric', year: 'numeric' })}`), + el('a', { class: 'btn btn-sm', href: `#/chores?week=${shiftDate(start, 7)}`, 'aria-label': 'Next week' }, '→'), + !currentWeek && el('a', { class: 'week-current', href: '#/chores' }, 'Today'), + ), + ), + el('div', { class: 'chores-count' }, el('span', {}, dayLabel), el('strong', {}, String(dayCount)), el('span', {}, 'still to do')), + ), + el('div', { class: 'chores-actions' }, + button('People', () => memberSetup(payload.members)), + button('Add a chore', () => recurrenceModal(null, null, true), true), + ), + )); + view.append(el('div', { class: 'roster-grid' }, ...payload.members.map((member, index) => { + const assigned = payload.assignments.filter((item) => Number(item.assignee_id) === Number(member.id)).sort((a, b) => a.due_date.localeCompare(b.due_date)); + const complete = assigned.filter((a) => ['resolved', 'closed'].includes(a.status)).length; + return el('section', { class: `roster-column person-${index % 2}` }, + el('header', { class: 'roster-heading' }, el('span', { class: 'person-dot' }), el('h2', {}, member.name), el('span', { class: 'roster-progress' }, `${complete}/${assigned.length}`)), + assigned.length ? el('div', { class: 'chore-stack' }, ...assigned.map((a) => choreCard(a, payload.members, payload.today))) : el('div', { class: 'roster-empty' }, 'No chores assigned this week.'), + ); + }))); + if (payload.previews?.length) view.append(el('section', { class: 'preview-section' }, + el('div', { class: 'section-intro' }, el('div', {}, el('span', { class: 'chores-kicker' }, 'NOT COMMITTED'), el('h2', {}, 'Coming up')), el('p', {}, 'These dates depend on finishing the current occurrence. The next chores are not assigned yet.')), + el('div', { class: 'preview-list' }, ...payload.previews.map((p) => el('div', { class: 'preview-row' }, el('span', {}, prettyDate(p.due_date)), el('strong', {}, p.title), el('span', { class: 'preview-tag' }, 'Preview')))), + )); + view.append(el('section', { class: 'templates-section' }, + el('div', { class: 'section-intro' }, el('div', {}, el('span', { class: 'chores-kicker' }, 'THE REPEATERS'), el('h2', {}, 'Chore templates')), el('p', {}, 'Edit a chore or archive it when it’s no longer part of the rotation. Past assignments stay in the roster.')), + activeTemplates.length ? el('div', { class: 'template-list' }, ...templateCards) : el('div', { class: 'roster-empty' }, 'No chore templates yet. Add a chore to start the rotation.'), + archivedTemplates.length ? el('details', { class: 'archived-chores' }, el('summary', {}, `Archived templates · ${archivedTemplates.length}`), ...archivedTemplates.map((c) => el('p', {}, `${c.title} · ${recurrenceLabel(c)}`))) : null, + )); +} + export const planningRoutes = [ + [/^\/chores$/, renderChores, 'chores'], [/^\/today$/, renderToday, 'today'], [/^\/waiting$/, (view) => renderDeferred(view, 'waiting'), 'waiting'], [/^\/snoozed$/, (view) => renderDeferred(view, 'snoozed'), 'snoozed'], diff --git a/public/styles.css b/public/styles.css index 6011905..567289f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -293,6 +293,7 @@ label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mut .ticket-row { flex-wrap: wrap; } .ticket-row > .task-actions { width: 100%; padding-left: 30px; } .queue-badge { background: var(--accent-soft); color: var(--accent); } +.chore-assignee-badge { background: color-mix(in srgb, #c99a62 15%, var(--bg-inset)); color: color-mix(in srgb, #d5a66d 85%, var(--text)); border: 1px solid color-mix(in srgb, #c99a62 28%, transparent); } .secondary-nav { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 20px; padding: 12px 20px 0; color: var(--text-muted); font-size: 13px; } .secondary-nav a:hover, .secondary-nav a.active { color: var(--accent); } .capture-details summary { cursor: pointer; padding: 12px 0; color: var(--text-muted); } @@ -764,6 +765,89 @@ progress { width: 100%; height: 8px; accent-color: var(--accent); } .draft-meta { font-size: 12px; color: var(--text-muted); } .planning-actions input { flex: 1; min-width: 150px; } .ticket-row .sub { overflow-wrap: anywhere; } + +/* ---- Chores -------------------------------------------------------------- */ +.chores-hero { + position: relative; + overflow: hidden; + margin: -4px 0 24px; + padding: clamp(22px, 4vw, 40px); + border: 1px solid color-mix(in srgb, #d2a46b 25%, var(--border)); + border-radius: 18px; + background: radial-gradient(ellipse at 94% 4%, rgba(202, 148, 87, .17), transparent 38%), linear-gradient(135deg, color-mix(in srgb, #31271f 35%, var(--bg-raised)), var(--bg-raised) 62%); +} +.chores-kicker { color: #c99a62; font-size: 10px; font-weight: 750; letter-spacing: .16em; } +.chores-hero-line { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; } +.chores-hero h1 { margin: 8px 0 2px; font-family: Georgia, "Times New Roman", serif; font-weight: 500; letter-spacing: -.045em; font-size: clamp(34px, 5vw, 48px); line-height: 1.1; } +.chores-hero p { margin: 0; color: var(--text-muted); } +.chores-count { display: grid; text-align: right; color: var(--text-muted); font-size: 12px; } +.chores-count strong { font-family: Georgia, serif; font-size: 34px; font-weight: 500; line-height: 1; color: var(--text); } +.week-switch { display: flex; align-items: center; gap: 12px; margin-top: 22px; flex-wrap: wrap; } +.week-switch strong { font-size: 13px; font-variant-numeric: tabular-nums; } +.week-current { color: #c99a62; font-size: 12px; } +.chores-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; } +.chores-hero .btn-primary { background: #b67b41; border-color: #b67b41; } +.roster-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; } +.roster-column { min-width: 0; } +.roster-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; padding: 0 4px; } +.roster-heading h2 { margin: 0; font-family: Georgia, serif; font-size: 21px; font-weight: 500; letter-spacing: -.02em; } +.person-dot { width: 9px; height: 9px; border-radius: 50%; background: #cf9560; box-shadow: 0 0 0 4px rgba(207,149,96,.12); } +.person-1 .person-dot { background: #7fa5aa; box-shadow: 0 0 0 4px rgba(127,165,170,.12); } +.roster-progress { margin-left: auto; color: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; } +.chore-stack { display: grid; gap: 8px; } +.chore-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 11px; background: var(--bg-raised); transition: border-color .16s, transform .16s; } +.chore-item:hover { border-color: color-mix(in srgb, #c99a62 48%, var(--border)); transform: translateY(-1px); } +.chore-item-main { display: grid; grid-template-columns: 62px minmax(0, 1fr) minmax(110px, 150px); align-items: center; gap: 10px; } +.chore-date { color: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; } +.chore-copy { min-width: 0; display: grid; gap: 2px; } +.chore-title { font-weight: 620; overflow-wrap: anywhere; } +.chore-title:hover { color: #c99a62; } +.chore-status { color: var(--text-muted); font-size: 11px; } +.chore-status.overdue { color: var(--critical); } +.chore-item.is-complete .chore-title { color: var(--text-muted); } +.chore-item.is-overdue { border-left: 2px solid var(--critical); } +.chore-item .field { margin: 0; } +.chore-item .field label { position: static; display: block; width: auto; height: auto; overflow: visible; clip: auto; margin: 0 0 4px; font-size: 10px; line-height: 1.2; } +.chore-item select { width: 100%; min-height: 36px; padding: 6px 7px; font-size: 12px; cursor: pointer; } +.chore-card-actions { display: flex; align-items: center; gap: 9px; margin: 8px 0 0 72px; } +.chore-done { color: #c99a62; } +.chore-history { margin: 10px 0 0 72px; border-top: 1px solid var(--border); padding-top: 7px; color: var(--text-muted); font-size: 11px; } +.chore-history summary, .archived-chores summary { cursor: pointer; } +.chore-history p { margin: 5px 0 0; } +.roster-empty { padding: 20px 14px; border: 1px dashed var(--border-strong); border-radius: 11px; color: var(--text-muted); font-size: 13px; } +.preview-section, .templates-section { margin-top: 42px; } +.section-intro { display: flex; justify-content: space-between; align-items: end; gap: 18px; margin-bottom: 12px; } +.section-intro h2 { margin: 3px 0 0; font-family: Georgia, serif; font-size: 25px; font-weight: 500; letter-spacing: -.025em; } +.section-intro p { max-width: 390px; margin: 0; color: var(--text-muted); font-size: 12px; } +.preview-list, .template-list { border-top: 1px solid var(--border); } +.preview-row, .template-row { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 12px 4px; border-bottom: 1px solid var(--border); } +.preview-row > span:first-child { min-width: 95px; color: var(--text-muted); font-size: 12px; } +.preview-row strong { flex: 1; font-weight: 550; } +.preview-tag { border: 1px dashed var(--border-strong); border-radius: 20px; padding: 2px 8px; color: var(--text-muted); font-size: 10px; } +.template-row > div:first-child { display: grid; gap: 2px; } +.template-row > div:first-child span { color: var(--text-muted); font-size: 12px; } +.archived-chores { margin-top: 14px; color: var(--text-muted); font-size: 12px; } +.archived-chores p { margin: 8px 0; } +@media (max-width: 760px) { + .chores-hero-line { align-items: flex-start; } + .chores-count { display: none; } + .roster-grid { grid-template-columns: 1fr; gap: 24px; } + .section-intro { display: block; } + .section-intro p { margin-top: 6px; } +} +@media (max-width: 440px) { + .chores-hero { padding: 20px 16px; } + .chores-actions { justify-content: stretch; } + .chores-actions .btn { flex: 1; justify-content: center; } + .chore-item-main { grid-template-columns: 1fr auto; gap: 5px 10px; } + .chore-date { grid-column: 1; } + .chore-copy { grid-column: 1; } + .chore-item .field { grid-column: 2; grid-row: 1 / span 2; width: 136px; } + .chore-history { margin-left: 0; } + .chore-card-actions { margin-left: 0; } + .template-row { align-items: flex-start; } + .template-row .planning-actions { flex-wrap: wrap; justify-content: flex-end; } +} @media (max-width: 760px) { .topbar { height: auto; min-height: 64px; flex-wrap: wrap; gap: 10px; padding: 10px 12px; } .topbar .brand { flex: 1; } diff --git a/public/sw.js b/public/sw.js index ec5a225..d0c3d5a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'taskhub-capture-v1'; +const CACHE = 'taskhub-capture-v2'; const ASSETS = [ '/capture.html', '/app.js', diff --git a/src/api/schedules.js b/src/api/schedules.js index 6d9c420..b04f90c 100644 --- a/src/api/schedules.js +++ b/src/api/schedules.js @@ -1,6 +1,7 @@ import { transaction } from '../db.js'; import { createTicket, getTicket } from './tickets.js'; import { civilDate, addDays, timeZoneFor } from '../dates.js'; +import { listEvents } from './events.js'; import { parseRecurrence, nextOccurrence, @@ -44,6 +45,14 @@ export function listSchedules(db, query = {}) { where.push('s.paused = :paused'); params.paused = boolean(query.paused, 'paused') ? 1 : 0; } + if (query.is_chore !== undefined && query.is_chore !== '') { + where.push('s.is_chore = :is_chore'); + params.is_chore = boolean(query.is_chore, 'is_chore') ? 1 : 0; + } + if (query.archived !== undefined && query.archived !== '') { + where.push('s.archived = :archived'); + params.archived = boolean(query.archived, 'archived') ? 1 : 0; + } const sql = `${SELECT_SCHEDULE} ${where.length ? `WHERE ${where.join(' AND ')}` : ''} @@ -52,6 +61,126 @@ export function listSchedules(db, query = {}) { return db.prepare(sql).all(params).map(shapeSchedule); } +/** + * Returns the durable two-person chore roster and assignments for one + * Sunday–Saturday week. Reading a past week is deliberately read-only. + */ +export function getChoreRoster(db, { week, today: suppliedToday } = {}) { + const today = rosterToday(db, suppliedToday); + const weekStart = week === undefined || week === null + ? sundayFor(today) + : optionalDate(week, 'week'); + if ( + !weekStart || + new Date(`${weekStart}T12:00:00Z`).getUTCDay() !== 0 + ) + throw new ValidationError('week must be a Sunday in YYYY-MM-DD format'); + const weekEnd = addDays(weekStart, 6); + + const members = db + .prepare('SELECT id, name FROM household_members ORDER BY id') + .all() + .map(({ id, name }) => ({ id, name })); + const chores = db + .prepare(`${SELECT_SCHEDULE} WHERE s.is_chore = 1 ORDER BY s.id`) + .all() + .map(shapeSchedule); + const assignments = db + .prepare( + `SELECT t.id, t.schedule_id, t.assignee_id, + m.name AS assignee_name, t.due_date, t.original_due_date, + t.status, t.title, + COALESCE(t.original_due_date, t.due_date) AS assignment_date + FROM tickets t + JOIN schedules s ON s.id = t.schedule_id AND s.is_chore = 1 + LEFT JOIN household_members m ON m.id = t.assignee_id + WHERE ( + COALESCE(t.original_due_date, t.due_date) BETWEEN ? AND ? + OR (COALESCE(t.original_due_date, t.due_date) < ? + AND t.status NOT IN ('resolved', 'closed')) + ) + ORDER BY assignment_date, t.id`, + ) + .all(weekStart, weekEnd, weekStart) + .map(({ assignment_date: _assignmentDate, ...row }) => ({ + ...row, + events: listEvents(db, row.id), + })); + + const previews = []; + const minPreviewDate = weekStart === sundayFor(today) ? today : weekStart; + for (const chore of chores) { + if (chore.archived || chore.paused || !chore.recurrence) continue; + const open = db + .prepare( + `SELECT id, due_date, original_due_date + FROM tickets + WHERE schedule_id = ? AND status NOT IN ('resolved', 'closed') + ORDER BY id DESC LIMIT 1`, + ) + .get(chore.id); + let dueDate; + if (open) { + if (chore.recurrence.kind === 'after_completion') continue; + const anchor = + open.original_due_date ?? open.due_date ?? chore.last_due ?? chore.next_due; + dueDate = nextOccurrence(chore.recurrence, anchor, anchor); + } else { + dueDate = chore.next_due; + } + if ( + dueDate >= minPreviewDate && + dueDate >= weekStart && + dueDate <= weekEnd && + !db + .prepare( + 'SELECT 1 FROM tickets WHERE schedule_id = ? AND original_due_date = ? LIMIT 1', + ) + .get(chore.id, dueDate) + ) { + previews.push({ + schedule_id: chore.id, + title: chore.title, + due_date: dueDate, + conditional: true, + }); + } + } + + return { + members, + chores, + week_start: weekStart, + week_end: weekEnd, + assignments, + previews, + today, + }; +} + +/** Updates the display name for one of the two stable household identities. */ +export function updateHouseholdMember(db, id, { name } = {}) { + const memberId = optionalId(id, 'id'); + if (![1, 2].includes(memberId)) + throw new NotFoundError(`No household member with id ${memberId}`); + const memberName = requiredText(name, 'name', 100); + if (!db.prepare('SELECT id FROM household_members WHERE id = ?').get(memberId)) + throw new NotFoundError(`No household member with id ${memberId}`); + if ( + db + .prepare('SELECT id FROM household_members WHERE name = ? AND id <> ?') + .get(memberName, memberId) + ) + throw new ValidationError('Household member names must be unique'); + db.prepare( + "UPDATE household_members SET name = ?, updated_at = datetime('now') WHERE id = ?", + ).run(memberName, memberId); + const member = db + .prepare('SELECT id, name FROM household_members WHERE id = ?') + .get(memberId); + return { id: member.id, name: member.name }; +} + /** The schedule plus the tickets it has generated, most recent first. */ export function getSchedule(db, id) { const row = db.prepare(`${SELECT_SCHEDULE} WHERE s.id = ?`).get(id); @@ -59,7 +188,8 @@ export function getSchedule(db, id) { const tickets = db .prepare( - `SELECT id, title, status, priority, due_date, resolved_at, created_at + `SELECT id, title, status, priority, due_date, resolved_at, created_at, + assignee_id, original_due_date FROM tickets WHERE schedule_id = ? ORDER BY created_at DESC LIMIT 20`, ) .all(id); @@ -69,6 +199,8 @@ export function getSchedule(db, id) { export function createSchedule(db, input = {}) { const fields = parseSchedule(db, input, { partial: false }); + if (fields.is_chore && input.source_ticket_id) + throw new ValidationError('Chores cannot be created from an existing ticket'); return transaction(db, () => { const { lastInsertRowid } = db .prepare( @@ -102,6 +234,18 @@ export function createSchedule(db, input = {}) { export function updateSchedule(db, id, input = {}) { const existing = getSchedule(db, id); + if ( + Object.hasOwn(input, 'is_chore') && + boolean(input.is_chore, 'is_chore') !== existing.is_chore + ) + throw new ValidationError('is_chore cannot be changed after schedule creation'); + if ( + existing.is_chore && + existing.archived && + Object.hasOwn(input, 'archived') && + !boolean(input.archived, 'archived') + ) + throw new ValidationError('Archived chore schedules cannot be unarchived'); const fields = parseSchedule(db, input, { partial: true, existing }); const rule = Object.hasOwn(fields, 'recurrence') @@ -138,7 +282,13 @@ export function updateSchedule(db, id, input = {}) { * schema's ON DELETE SET NULL — the same reasoning as deleting a device. */ export function deleteSchedule(db, id) { - getSchedule(db, id); + const schedule = getSchedule(db, id); + if (schedule.is_chore) { + db.prepare( + "UPDATE schedules SET archived = 1, updated_at = datetime('now') WHERE id = ?", + ).run(id); + return; + } db.prepare('DELETE FROM schedules WHERE id = ?').run(id); } @@ -153,11 +303,13 @@ export function deleteSchedule(db, id) { * * Returns what it created, which is what the notifier reports on. */ -export function runSchedules(db, { today } = {}) { +export function runSchedules(db, { today, onlyChores = false } = {}) { + const injectedToday = today == null ? null : optionalDate(today, 'today'); + const choresOnly = boolean(onlyChores, 'onlyChores'); const due = db .prepare( `SELECT * FROM schedules - WHERE paused = 0 + WHERE paused = 0 AND archived = 0 ${choresOnly ? 'AND is_chore = 1' : ''} ORDER BY next_due ASC, id ASC`, ) .all(); @@ -167,10 +319,17 @@ export function runSchedules(db, { today } = {}) { for (const schedule of due) { try { const date = - today ?? - (schedule.recurrence - ? civilDate(new Date(), schedule.time_zone) - : currentDate(db)); + injectedToday ?? + (schedule.is_chore + ? civilDate(new Date(), timeZoneFor(db)) + : schedule.recurrence + ? civilDate(new Date(), schedule.time_zone) + : currentDate(db)); + if (schedule.is_chore) { + const created = runChoreSchedule(db, schedule, date); + if (created) fired.push(created); + continue; + } if (addDays(schedule.next_due, -schedule.lead_days) > date) continue; if ( schedule.recurrence && @@ -213,9 +372,9 @@ export function runSchedules(db, { today } = {}) { return fired; } -function fire(db, schedule, today) { +function fire(db, schedule, today, { assigneeId = null } = {}) { return transaction(db, () => { - const ticket = createTicket( + let ticket = createTicket( db, { title: schedule.title, @@ -230,6 +389,19 @@ function fire(db, schedule, today) { }, { scheduleId: schedule.id }, ); + if (schedule.is_chore) { + const member = db + .prepare('SELECT id, name FROM household_members WHERE id = ?') + .get(assigneeId); + if (!member) throw new Error(`No household member with id ${assigneeId}`); + db.prepare( + 'UPDATE tickets SET assignee_id = ?, original_due_date = ? WHERE id = ?', + ).run(member.id, schedule.next_due, ticket.id); + db.prepare( + `INSERT INTO ticket_events (ticket_id, kind, from_value, to_value) + VALUES (?, 'assignee', NULL, ?)`, + ).run(ticket.id, member.name); + } JSON.parse(schedule.checklist).forEach((title, position) => db .prepare( @@ -237,6 +409,7 @@ function fire(db, schedule, today) { ) .run(ticket.id, title, position), ); + if (schedule.is_chore) ticket = getTicket(db, ticket.id); db.prepare( `UPDATE schedules @@ -259,6 +432,106 @@ function fire(db, schedule, today) { }); } +/** Materializes only a current-week chore occurrence; missed dates are skipped. */ +function runChoreSchedule(db, staleSchedule, today) { + const weekStart = sundayFor(today); + const weekEnd = addDays(weekStart, 6); + return transaction(db, () => { + const schedule = db + .prepare('SELECT * FROM schedules WHERE id = ?') + .get(staleSchedule.id); + if (!schedule || schedule.archived || schedule.paused) return null; + if (!schedule.recurrence) + throw new ValidationError(`Chore schedule ${schedule.id} needs recurrence`); + if ( + db + .prepare( + "SELECT 1 FROM tickets WHERE schedule_id = ? AND status NOT IN ('resolved','closed') LIMIT 1", + ) + .get(schedule.id) + ) + return null; + + let dueDate = schedule.next_due; + if (dueDate < today) { + dueDate = nextOccurrence( + JSON.parse(schedule.recurrence), + dueDate, + addDays(today, -1), + ); + db.prepare( + "UPDATE schedules SET next_due = ?, updated_at = datetime('now') WHERE id = ?", + ).run(dueDate, schedule.id); + schedule.next_due = dueDate; + } + if (dueDate < today || dueDate < weekStart || dueDate > weekEnd) return null; + if ( + db + .prepare( + 'SELECT 1 FROM tickets WHERE schedule_id = ? AND original_due_date = ? LIMIT 1', + ) + .get(schedule.id, dueDate) + ) + return null; + + const assigneeId = chooseAssignee(db, schedule.id, weekStart, weekEnd); + return fire(db, schedule, today, { assigneeId }); + }); +} + +/** Balances weekly work first, then avoids the schedule's last actual assignee. */ +function chooseAssignee(db, scheduleId, weekStart, weekEnd) { + const lookback = addDays(weekStart, -28); + const lookbackEnd = addDays(weekStart, -1); + const weekNumber = Math.floor( + Date.parse(`${weekStart}T12:00:00Z`) / (7 * 24 * 60 * 60 * 1000), + ); + const fairTieMemberId = (((weekNumber + scheduleId) % 2 + 2) % 2) + 1; + const load = new Map( + db + .prepare( + `SELECT assignee_id, + SUM(CASE + WHEN COALESCE(original_due_date, due_date) BETWEEN ? AND ? + OR (COALESCE(original_due_date, due_date) < ? + AND status NOT IN ('resolved', 'closed')) + THEN 1 ELSE 0 END) AS week_count, + SUM(CASE + WHEN COALESCE(original_due_date, due_date) BETWEEN ? AND ? + THEN 1 ELSE 0 END) AS recent_count + FROM tickets + WHERE assignee_id IS NOT NULL + GROUP BY assignee_id`, + ) + .all(weekStart, weekEnd, weekStart, lookback, lookbackEnd) + .map((row) => [row.assignee_id, row]), + ); + const previous = db + .prepare( + `SELECT assignee_id FROM tickets + WHERE schedule_id = ? AND assignee_id IS NOT NULL + ORDER BY COALESCE(original_due_date, due_date) DESC, id DESC LIMIT 1`, + ) + .get(scheduleId)?.assignee_id; + const members = db + .prepare('SELECT id FROM household_members ORDER BY id') + .all(); + if (members.length !== 2) + throw new Error('The chore roster must contain exactly two members'); + members.sort((a, b) => { + const aLoad = load.get(a.id) ?? { week_count: 0, recent_count: 0 }; + const bLoad = load.get(b.id) ?? { week_count: 0, recent_count: 0 }; + return ( + aLoad.week_count - bLoad.week_count || + Number(a.id === previous) - Number(b.id === previous) || + aLoad.recent_count - bLoad.recent_count || + Number(b.id === fairTieMemberId) - Number(a.id === fairTieMemberId) || + a.id - b.id + ); + }); + return members[0].id; +} + /** Walks forward by whole intervals until the date is in the future. */ function advance(db, from, intervalDays, today) { // Validation keeps this above zero, so reaching here means the row was @@ -297,6 +570,22 @@ function parseSchedule(db, input, { partial, existing = null }) { fields.tags = (tagList(input.tags) ?? []).join(','); if (!partial || has('paused')) fields.paused = boolean(input.paused, 'paused') ? 1 : 0; + if (!partial || has('is_chore')) + fields.is_chore = boolean( + input.is_chore, + 'is_chore', + Boolean(existing?.is_chore), + ) + ? 1 + : 0; + if (!partial || has('archived')) + fields.archived = boolean( + input.archived, + 'archived', + Boolean(existing?.archived), + ) + ? 1 + : 0; if (!partial || has('interval_days')) { fields.interval_days = boundedInt(input.interval_days, 'interval_days', { @@ -340,9 +629,20 @@ function parseSchedule(db, input, { partial, existing = null }) { items.map((t) => requiredText(t, 'checklist item', 500)), ); } + const isChore = fields.is_chore ?? Number(existing?.is_chore ?? 0); if (!partial || has('time_zone')) { + const applicationTimeZone = timeZoneFor(db); + if ( + isChore && + input.time_zone != null && + input.time_zone !== applicationTimeZone + ) + throw new ValidationError( + `Chore time_zone must match the application time zone (${applicationTimeZone})`, + ); fields.time_zone = - input.time_zone ?? (input.recurrence ? timeZoneFor(db) : 'UTC'); + input.time_zone ?? + (isChore || input.recurrence ? applicationTimeZone : 'UTC'); try { civilDate(new Date(), fields.time_zone); } catch { @@ -354,6 +654,9 @@ function parseSchedule(db, input, { partial, existing = null }) { : existing?.recurrence ? JSON.stringify(existing.recurrence) : null; + if (isChore && !rule) + throw new ValidationError('Chore schedules require a modern recurrence rule'); + if (isChore) fields.lead_days = 0; if (rule) { fields.lead_days = 0; if (!partial || has('next_due') || has('recurrence')) { @@ -410,12 +713,41 @@ const splitTags = (csv) => (csv ? csv.split(',').filter(Boolean) : []); const currentDate = (db) => db.prepare(`SELECT date('now') AS today`).get().today; -function shapeSchedule({ tags, paused, recurrence, checklist, ...schedule }) { +function shapeSchedule({ + tags, + paused, + recurrence, + checklist, + is_chore, + archived, + ...schedule +}) { return { ...schedule, tags: splitTags(tags), paused: Boolean(paused), + is_chore: Boolean(is_chore), + archived: Boolean(archived), recurrence: recurrence ? JSON.parse(recurrence) : null, checklist: JSON.parse(checklist), }; } + +function rosterToday(db, suppliedToday) { + if (suppliedToday instanceof Date) { + if (Number.isNaN(suppliedToday.getTime())) + throw new ValidationError('today must be a valid date'); + return civilDate(suppliedToday, timeZoneFor(db)); + } + if (suppliedToday !== undefined && suppliedToday !== null) { + const value = optionalDate(suppliedToday, 'today'); + if (value === null) throw new ValidationError('today must be a valid date'); + return value; + } + return civilDate(new Date(), timeZoneFor(db)); +} + +function sundayFor(date) { + const day = new Date(`${date}T12:00:00Z`).getUTCDay(); + return addDays(date, -day); +} diff --git a/src/api/tickets.js b/src/api/tickets.js index c97c3f8..9831fcb 100644 --- a/src/api/tickets.js +++ b/src/api/tickets.js @@ -36,6 +36,11 @@ const SELECT_TICKET = ` d.name AS device_name, d.type AS device_type, (SELECT name FROM projects WHERE id = t.project_id) AS project_name, + (SELECT name FROM household_members WHERE id = t.assignee_id) + AS assignee_name, + COALESCE( + (SELECT is_chore FROM schedules WHERE id = t.schedule_id), 0 + ) AS is_chore, (SELECT count(*) FROM checklist_items WHERE ticket_id = t.id) AS checklist_total, (SELECT count(*) FROM checklist_items WHERE ticket_id = t.id AND completed = 1) AS checklist_completed, (SELECT COUNT(*) FROM comments c WHERE c.ticket_id = t.id) AS comment_count, @@ -191,6 +196,18 @@ export function updateTicket(db, id, input = {}) { const fields = parseTicket(input, { partial: true }); const tags = tagList(input.tags); + if (Object.hasOwn(input, 'assignee_id')) { + if (!existing.is_chore) + throw new ValidationError('Only chore tickets can be assigned'); + fields.assignee_id = requiredId(input.assignee_id, 'assignee_id'); + if ( + !db + .prepare('SELECT 1 FROM household_members WHERE id = ?') + .get(fields.assignee_id) + ) + throw new ValidationError(`No household member with id ${fields.assignee_id}`); + } + if (Object.keys(fields).length === 0 && tags === null) { throw new ValidationError('No updatable fields provided'); } @@ -212,6 +229,20 @@ export function updateTicket(db, id, input = {}) { if (fields.status && fields.status !== existing.status) { const wasClosed = CLOSED_STATUSES.includes(existing.status); const isClosed = CLOSED_STATUSES.includes(fields.status); + if (existing.is_chore && wasClosed && !isClosed) { + const newerUnfinished = db + .prepare( + `SELECT 1 FROM tickets + WHERE schedule_id = ? AND id > ? + AND status NOT IN (${CLOSED_LIST}) + LIMIT 1`, + ) + .get(existing.schedule_id, existing.id); + if (newerUnfinished) + throw new ValidationError( + 'Cannot reopen this chore while a newer occurrence is unfinished', + ); + } if (isClosed && !wasClosed) fields.resolved_at = isoNow(); if (!isClosed && wasClosed) fields.resolved_at = null; } @@ -221,6 +252,9 @@ export function updateTicket(db, id, input = {}) { const afterDeviceName = Object.hasOwn(fields, 'device_id') ? deviceName(db, fields.device_id) : existing.device_name; + const afterAssigneeName = Object.hasOwn(fields, 'assignee_id') + ? memberName(db, fields.assignee_id) + : existing.assignee_name; return transaction(db, () => { const keys = Object.keys(fields); @@ -234,6 +268,18 @@ export function updateTicket(db, id, input = {}) { recordChanges(db, id, existing, fields, { deviceNames: { before: existing.device_name, after: afterDeviceName }, }); + if ( + Object.hasOwn(fields, 'assignee_id') && + fields.assignee_id !== existing.assignee_id + ) { + recordEvent( + db, + id, + 'assignee', + existing.assignee_name ?? null, + afterAssigneeName, + ); + } if (tags !== null) { const before = existing.tags.join(', '); @@ -252,7 +298,9 @@ export function updateTicket(db, id, input = {}) { } export function deleteTicket(db, id) { - getTicket(db, id); + const ticket = getTicket(db, id); + if (ticket.is_chore) + throw new ValidationError('Generated chore tickets cannot be deleted'); db.prepare('DELETE FROM tickets WHERE id = ?').run(id); } @@ -433,11 +481,23 @@ function deviceName(db, deviceId) { ); } +/** The current household member name by id, used for readable event snapshots. */ +function memberName(db, memberId) { + if (!memberId) return null; + return ( + db + .prepare('SELECT name FROM household_members WHERE id = ?') + .get(memberId)?.name ?? + null + ); +} + /** Turns the group_concat CSV into a real array for the client. */ function shapeTicket({ tag_csv, ...ticket }) { return { ...ticket, tags: tag_csv ? tag_csv.split(',').sort() : [], + is_chore: Boolean(ticket.is_chore), is_open: !CLOSED_STATUSES.includes(ticket.status), }; } diff --git a/src/db.js b/src/db.js index 35d6de2..3bab526 100644 --- a/src/db.js +++ b/src/db.js @@ -285,6 +285,32 @@ const MIGRATIONS = [ ALTER TABLE schedules ADD COLUMN last_due TEXT; UPDATE schedules SET last_due = next_due WHERE recurrence IS NOT NULL AND last_ticket_id IS NOT NULL; `, + ` + -- Chores are an explicit opt-in schedule type. Existing schedules stay + -- routines; adding these flags must never enroll historical rows. + ALTER TABLE schedules ADD COLUMN is_chore INTEGER NOT NULL DEFAULT 0 + CHECK (is_chore IN (0, 1)); + ALTER TABLE schedules ADD COLUMN archived INTEGER NOT NULL DEFAULT 0 + CHECK (archived IN (0, 1)); + + -- A fixed two-person roster shared by the household account. IDs are + -- intentionally stable because tickets keep their original assignee. + CREATE TABLE household_members ( + id INTEGER PRIMARY KEY CHECK (id IN (1, 2)), + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO household_members (id, name) VALUES + (1, 'Member 1'), + (2, 'Member 2'); + + ALTER TABLE tickets ADD COLUMN assignee_id INTEGER + REFERENCES household_members(id) ON DELETE RESTRICT; + ALTER TABLE tickets ADD COLUMN original_due_date TEXT; + CREATE INDEX idx_tickets_assignee ON tickets(assignee_id); + CREATE INDEX idx_tickets_original_due_date ON tickets(original_due_date); + `, ]; /** diff --git a/src/recurrence.js b/src/recurrence.js index 0162e07..394d314 100644 --- a/src/recurrence.js +++ b/src/recurrence.js @@ -84,10 +84,20 @@ export function syncRecurrence(db, ticketId) { row.time_zone, ); const anchor = row.last_due || row.next_due; - const next = + let next = rule.kind === 'after_completion' ? addDays(completed, rule.days) : nextOccurrence(rule, anchor, completed > anchor ? completed : anchor); + if ( + row.is_chore && + rule.kind !== 'after_completion' && + next < civilDate(new Date(), row.time_zone) + ) { + // A chore completed after its next calendar date has passed should move to + // the next live occurrence, not create a historical catch-up assignment. + const today = civilDate(new Date(), row.time_zone); + next = nextOccurrence(rule, next, addDays(today, -1)); + } db.prepare('UPDATE schedules SET next_due = ? WHERE id = ?').run( next, row.id, diff --git a/src/server.js b/src/server.js index f072ef0..50b0ddd 100644 --- a/src/server.js +++ b/src/server.js @@ -39,6 +39,8 @@ import { updateSchedule, deleteSchedule, runSchedules, + getChoreRoster, + updateHouseholdMember, } from './api/schedules.js'; import { exportEntity } from './api/export.js'; import { renderMetrics } from './api/metrics.js'; @@ -186,6 +188,12 @@ const ROUTES = [ ['GET', '/api/calendar.ics', sendCalendar], ['GET', '/api/export', sendExport], ['POST', '/api/maintenance/run', runMaintenance, 200], + ['GET', '/api/chores', readChoreRoster], + [ + 'PATCH', + '/api/chores/members/:id', + ({ db, params, body }) => updateHouseholdMember(db, params.id, body), + ], ['GET', '/api/devices', ({ db, query }) => listDevices(db, query)], ['POST', '/api/devices', ({ db, body }) => createDevice(db, body)], @@ -281,6 +289,10 @@ const ROUTES = [ /* ---- Handlers that need more than the data layer ------------------------ */ function createTicketAndNotify({ db, body, notifier, req }) { + if (Object.hasOwn(body, 'assignee_id')) + throw new ValidationError( + 'Assignments can only be changed on chore tickets', + ); const { value, fresh } = submitOnce( db, 'ticket', @@ -293,6 +305,15 @@ function createTicketAndNotify({ db, body, notifier, req }) { return value; } +function readChoreRoster({ db, query, notifier }) { + const roster = getChoreRoster(db, { week: query.week }); + if (roster.week_start !== getChoreRoster(db).week_start) return roster; + const fired = runSchedules(db, { onlyChores: true }); + for (const { ticket } of fired) + notifier.sendDetached('schedule.fired', ticket); + return getChoreRoster(db, { week: query.week }); +} + function updateTicketAndNotify({ db, params, body, notifier }) { const before = getTicket(db, params.id); const ticket = updateTicket(db, params.id, body); diff --git a/test/chores.test.js b/test/chores.test.js new file mode 100644 index 0000000..70b2898 --- /dev/null +++ b/test/chores.test.js @@ -0,0 +1,391 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { openDatabase } from '../src/db.js'; +import { + createSchedule, + getChoreRoster, + getSchedule, + runSchedules, + updateSchedule, +} from '../src/api/schedules.js'; +import { createTicket, updateTicket } from '../src/api/tickets.js'; +import { configureDates } from '../src/dates.js'; +import { syncRecurrence } from '../src/recurrence.js'; + +function database(t, timeZone = 'UTC') { + const db = openDatabase(':memory:'); + configureDates(db, timeZone); + t.after(() => db.close()); + return db; +} + +function createChore(db, { title, next_due, recurrence, ...fields }) { + return createSchedule(db, { + title, + next_due, + recurrence, + is_chore: true, + time_zone: 'UTC', + ...fields, + }); +} + +function completeAt(db, ticket, date) { + db.prepare( + "UPDATE tickets SET status = 'resolved', resolved_at = ? WHERE id = ?", + ).run(`${date} 12:00:00`, ticket.id); + syncRecurrence(db, ticket.id); +} + +test('chore recurrence supports selected weekdays, monthly rules, intervals, and completion-based dates', async (t) => { + const cases = [ + { + name: 'weekly selected days', + next_due: '2030-01-01', + recurrence: { kind: 'weekly', weekdays: [2, 5] }, + due: '2030-01-01', + completed: '2030-01-01', + next: '2030-01-04', + }, + { + name: 'monthly date clamps to leap-day', + next_due: '2032-01-31', + recurrence: { kind: 'monthly_date', day: 31 }, + due: '2032-01-31', + completed: '2032-01-31', + next: '2032-02-29', + }, + { + name: 'monthly fifth weekday means the last weekday', + next_due: '2030-01-01', + recurrence: { kind: 'monthly_weekday', ordinal: 5, weekday: 0 }, + due: '2030-01-27', + completed: '2030-01-27', + next: '2030-02-24', + }, + { + name: 'interval cadence remains anchored to its due date', + next_due: '2030-03-01', + recurrence: { kind: 'interval', days: 4 }, + due: '2030-03-01', + completed: '2030-03-05', + next: '2030-03-09', + }, + { + name: 'after-completion cadence starts from completion', + next_due: '2030-04-01', + recurrence: { kind: 'after_completion', days: 7 }, + due: '2030-04-01', + completed: '2030-04-05', + next: '2030-04-12', + }, + ]; + + for (const scenario of cases) { + await t.test(scenario.name, (t) => { + const db = database(t); + const schedule = createChore(db, { + title: scenario.name, + next_due: scenario.next_due, + recurrence: scenario.recurrence, + }); + + const [first] = runSchedules(db, { today: scenario.due }); + assert.equal(first.schedule_id, schedule.id); + assert.equal(first.ticket.original_due_date, scenario.due); + + completeAt(db, first.ticket, scenario.completed); + assert.equal(getSchedule(db, schedule.id).next_due, scenario.next); + }); + } +}); + +test('multi-weekday preview advances conditionally and completion enables another same-week chore', (t) => { + const db = database(t); + const week = '2030-01-06'; // Sunday + const schedule = createChore(db, { + title: 'Monday Wednesday Friday chore', + next_due: week, + recurrence: { kind: 'weekly', weekdays: [1, 3, 5] }, + }); + + const before = getChoreRoster(db, { week, today: week }); + assert.deepEqual( + before.previews.map(({ schedule_id, due_date, conditional }) => ({ + schedule_id, + due_date, + conditional, + })), + [{ schedule_id: schedule.id, due_date: '2030-01-07', conditional: true }], + ); + + const [monday] = runSchedules(db, { today: '2030-01-07' }); + assert.equal(monday.ticket.original_due_date, '2030-01-07'); + const whileOpen = getChoreRoster(db, { week, today: '2030-01-07' }); + assert.deepEqual( + whileOpen.previews.map(({ schedule_id, due_date, conditional }) => ({ + schedule_id, + due_date, + conditional, + })), + [{ schedule_id: schedule.id, due_date: '2030-01-09', conditional: true }], + ); + + completeAt(db, monday.ticket, '2030-01-07'); + const [wednesday] = runSchedules(db, { today: '2030-01-09' }); + assert.equal(wednesday.schedule_id, schedule.id); + assert.equal(wednesday.ticket.original_due_date, '2030-01-09'); + assert.equal(wednesday.ticket.assignee_id, monday.ticket.assignee_id === 1 ? 2 : 1); +}); + +test('manual reassignment changes the next actual owner and roster week load', (t) => { + const db = database(t); + const week = '2030-01-06'; // Sunday + const recurring = createChore(db, { + title: 'Reassigned Monday Wednesday chore', + next_due: week, + recurrence: { kind: 'weekly', weekdays: [1, 3] }, + }); + createChore(db, { + title: 'Second Monday chore', + next_due: week, + recurrence: { kind: 'weekly', weekdays: [1] }, + }); + + const monday = runSchedules(db, { today: '2030-01-07' }); + assert.equal(monday.length, 2); + const first = monday.find(({ schedule_id }) => schedule_id === recurring.id).ticket; + const second = monday.find(({ schedule_id }) => schedule_id !== recurring.id).ticket; + assert.notEqual(first.assignee_id, second.assignee_id); + + const firstOwner = first.assignee_id; + const secondOwner = second.assignee_id; + updateTicket(db, first.id, { assignee_id: secondOwner }); + updateTicket(db, second.id, { assignee_id: firstOwner }); + + const reassignedRoster = getChoreRoster(db, { week, today: '2030-01-07' }); + const counts = new Map([[1, 0], [2, 0]]); + for (const assignment of reassignedRoster.assignments) + counts.set(assignment.assignee_id, counts.get(assignment.assignee_id) + 1); + assert.deepEqual([...counts.values()], [1, 1]); + assert.equal( + reassignedRoster.assignments.find(({ id }) => id === first.id).assignee_id, + secondOwner, + ); + + completeAt(db, first, '2030-01-07'); + const wednesday = runSchedules(db, { today: '2030-01-09' }).find( + ({ schedule_id }) => schedule_id === recurring.id, + ); + assert.ok(wednesday); + assert.equal( + wednesday.ticket.assignee_id, + firstOwner, + 'with equal week loads, the next occurrence avoids the manually selected last owner', + ); +}); + +test('the four prior weeks compensate an otherwise tied chore assignment', (t) => { + const db = database(t); + const week = '2030-02-03'; // Sunday + const history = createChore(db, { + title: 'Historical assignments', + next_due: week, + recurrence: { kind: 'weekly', weekdays: [0] }, + paused: true, + }); + + for (const date of [ + '2030-01-06', // exactly four weeks before; the lower edge is included + '2030-01-13', + '2030-01-20', + '2030-01-27', + ]) { + const ticket = createTicket( + db, + { title: `Recent work ${date}`, due_date: date }, + { scheduleId: history.id }, + ); + db.prepare( + "UPDATE tickets SET assignee_id = 1, original_due_date = ?, status = 'resolved', resolved_at = ? WHERE id = ?", + ).run(date, `${date} 12:00:00`, ticket.id); + } + + // This fifth-prior-week assignment must not outweigh the four in-window + // assignments above. + const old = createTicket( + db, + { title: 'Out-of-window work', due_date: '2029-12-30' }, + { scheduleId: history.id }, + ); + db.prepare( + "UPDATE tickets SET assignee_id = 2, original_due_date = '2029-12-30', status = 'resolved', resolved_at = '2029-12-30 12:00:00' WHERE id = ?", + ).run(old.id); + + const current = createChore(db, { + title: 'Current tie-break chore', + next_due: week, + recurrence: { kind: 'weekly', weekdays: [0] }, + }); + const [created] = runSchedules(db, { today: week, onlyChores: true }); + assert.equal(created.schedule_id, current.id); + assert.equal(created.ticket.assignee_id, 2); +}); + +test('archiving preserves the open assignment and prevents future occurrences', (t) => { + const db = database(t); + const week = '2030-06-02'; // Sunday + const schedule = createChore(db, { + title: 'Keep this open assignment', + next_due: week, + recurrence: { kind: 'weekly', weekdays: [0] }, + }); + const [created] = runSchedules(db, { today: week }); + + updateSchedule(db, schedule.id, { archived: true }); + const currentWeek = getChoreRoster(db, { week, today: week }); + assert.equal( + currentWeek.assignments.some(({ id }) => id === created.ticket.id), + true, + ); + assert.equal( + currentWeek.previews.some(({ schedule_id }) => schedule_id === schedule.id), + false, + ); + + const nextWeek = '2030-06-09'; + const futureRoster = getChoreRoster(db, { week: nextWeek, today: nextWeek }); + assert.deepEqual( + futureRoster.assignments.map(({ id }) => id), + [created.ticket.id], + 'the still-open ticket is a carryover, not a new occurrence', + ); + assert.equal( + futureRoster.previews.some(({ schedule_id }) => schedule_id === schedule.id), + false, + ); + assert.equal(runSchedules(db, { today: nextWeek }).length, 0); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM tickets WHERE schedule_id = ?').get(schedule.id) + .count, + 1, + ); +}); + +test('ordinary routines and their tickets are absent from the chore roster', (t) => { + const db = database(t); + const week = '2030-02-03'; // Sunday + const ordinary = createSchedule(db, { + title: 'Ordinary Monday routine', + next_due: '2030-02-04', + interval_days: 7, + }); + const chore = createChore(db, { + title: 'Chore Monday routine', + next_due: week, + recurrence: { kind: 'weekly', weekdays: [1] }, + }); + const fired = runSchedules(db, { today: '2030-02-04' }); + assert.equal(fired.length, 2); + + const roster = getChoreRoster(db, { week, today: '2030-02-04' }); + assert.deepEqual(roster.chores.map(({ id }) => id), [chore.id]); + assert.deepEqual( + roster.assignments.map(({ schedule_id }) => schedule_id), + [chore.id], + ); + assert.equal(roster.assignments.some(({ schedule_id }) => schedule_id === ordinary.id), false); +}); + +test('the roster Sunday boundary follows the configured app time zone', (t) => { + const db = database(t, 'America/Los_Angeles'); + const beforeLocalSunday = getChoreRoster(db, { + today: new Date('2026-09-20T06:59:00Z'), // Saturday 23:59 in Los Angeles + }); + assert.equal(beforeLocalSunday.today, '2026-09-19'); + assert.equal(beforeLocalSunday.week_start, '2026-09-13'); + + const afterLocalMidnight = getChoreRoster(db, { + today: new Date('2026-09-20T07:01:00Z'), // Sunday 00:01 in Los Angeles + }); + assert.equal(afterLocalMidnight.today, '2026-09-20'); + assert.equal(afterLocalMidnight.week_start, '2026-09-20'); +}); + +test('chore time zones are fixed to the app zone while ordinary routines keep custom zones', (t) => { + const db = database(t, 'America/Los_Angeles'); + const recurrence = { kind: 'weekly', weekdays: [1] }; + + assert.throws( + () => + createSchedule(db, { + title: 'Mismatched chore', + is_chore: true, + next_due: '2026-09-21', + recurrence, + time_zone: 'UTC', + }), + /Chore time_zone must match the application time zone/, + ); + + const chore = createSchedule(db, { + title: 'App-zone chore', + is_chore: true, + next_due: '2026-09-21', + recurrence, + }); + assert.equal(chore.time_zone, 'America/Los_Angeles'); + assert.throws( + () => updateSchedule(db, chore.id, { time_zone: 'UTC' }), + /Chore time_zone must match the application time zone/, + ); + assert.equal(getSchedule(db, chore.id).time_zone, 'America/Los_Angeles'); + + const routine = createSchedule(db, { + title: 'Custom-zone routine', + next_due: '2026-09-21', + recurrence, + time_zone: 'UTC', + }); + assert.equal(routine.time_zone, 'UTC'); + assert.equal( + updateSchedule(db, routine.id, { time_zone: 'America/New_York' }).time_zone, + 'America/New_York', + ); +}); + +test('chore materialization uses the app-zone date at the Sunday boundary', (t) => { + const db = database(t, 'America/Los_Angeles'); + const chore = createSchedule(db, { + title: 'Saturday chore', + is_chore: true, + next_due: '2026-09-19', + recurrence: { kind: 'weekly', weekdays: [6] }, + }); + assert.equal(chore.time_zone, 'America/Los_Angeles'); + + // Simulate a legacy row created before chore time zones were constrained. + db.prepare('UPDATE schedules SET time_zone = ? WHERE id = ?').run( + 'UTC', + chore.id, + ); + + const RealDate = globalThis.Date; + const frozenNow = RealDate.parse('2026-09-20T06:59:00Z'); // Saturday 23:59 in Los Angeles + globalThis.Date = class extends RealDate { + constructor(...args) { + super(...(args.length ? args : [frozenNow])); + } + + static now() { + return frozenNow; + } + }; + try { + const [created] = runSchedules(db); + assert.equal(created.schedule_id, chore.id); + assert.equal(created.ticket.original_due_date, '2026-09-19'); + } finally { + globalThis.Date = RealDate; + } +}); diff --git a/test/planning-http.test.js b/test/planning-http.test.js index d158a60..9e56a45 100644 --- a/test/planning-http.test.js +++ b/test/planning-http.test.js @@ -18,6 +18,7 @@ import { projects, savedViews, checklist } from '../src/api/planning.js'; import { createSchedule, getSchedule, + getChoreRoster, runSchedules, } from '../src/api/schedules.js'; import { addAttachment, getAttachment } from '../src/api/attachments.js'; @@ -97,6 +98,227 @@ test('configured civil time zone drives planning filters and new routines', asyn assert.equal(JSON.parse(legacy.text).time_zone, 'UTC'); }); +test('chore HTTP integration keeps assignment, roster, and occurrence history coherent', async (t) => { + const { db, request } = await server(t); + const members = db.prepare('SELECT id FROM household_members ORDER BY id').all(); + assert.equal(members.length, 2, 'the household has two assignable members'); + const names = ['Chore Member One', 'Chore Member Two']; + for (let i = 0; i < members.length; i++) { + const renamed = await request(`/api/chores/members/${members[i].id}`, 'PATCH', { + name: names[i], + }); + assert.equal(renamed.status, 200); + assert.equal(JSON.parse(renamed.text).name, names[i]); + } + + const today = JSON.parse((await request('/api/planning')).text).today; + const week = addDays( + today, + -new Date(`${today}T12:00:00Z`).getUTCDay(), + ); + const created = await request('/api/schedules', 'POST', { + title: 'Wash the towels', + is_chore: true, + recurrence: { kind: 'interval', days: 7 }, + next_due: today, + }); + assert.equal(created.status, 201); + const schedule = JSON.parse(created.text); + assert.equal(schedule.is_chore, true); + + const rosterResponse = await request(`/api/chores?week=${week}`); + assert.equal(rosterResponse.status, 200); + const roster = JSON.parse(rosterResponse.text); + assert.deepEqual( + roster, + JSON.parse(JSON.stringify(getChoreRoster(db, { week }))), + ); + + let occurrences = db + .prepare('SELECT id FROM tickets WHERE schedule_id = ? ORDER BY id') + .all(schedule.id); + assert.equal(occurrences.length, 1, 'a roster read materializes one occurrence'); + const firstId = occurrences[0].id; + let first = JSON.parse((await request(`/api/tickets/${firstId}`)).text); + assert.equal(first.is_chore, true); + assert.ok(members.some((member) => member.id === first.assignee_id)); + assert.equal( + first.assignee_name, + names[members.findIndex((member) => member.id === first.assignee_id)], + ); + + const assignedTo = members.find((member) => member.id !== first.assignee_id); + const changed = await request(`/api/tickets/${firstId}`, 'PATCH', { + assignee_id: assignedTo.id, + }); + assert.equal(changed.status, 200); + first = JSON.parse(changed.text); + assert.equal(first.assignee_id, assignedTo.id); + assert.equal(first.assignee_name, names[members.findIndex((m) => m.id === assignedTo.id)]); + const assigneeEvent = first.events + .filter((event) => event.kind === 'assignee') + .at(-1); + assert.deepEqual( + [assigneeEvent?.from_value, assigneeEvent?.to_value], + [names[members.findIndex((m) => m.id !== assignedTo.id)], first.assignee_name], + ); + const listed = JSON.parse((await request('/api/tickets?status=all')).text); + assert.equal(listed.find((ticket) => ticket.id === firstId).assignee_name, first.assignee_name); + const reassignedRoster = JSON.parse( + (await request(`/api/chores?week=${week}`)).text, + ); + const rosterAssignment = reassignedRoster.assignments.find( + (assignment) => assignment.id === firstId, + ); + assert.equal(rosterAssignment.assignee_id, assignedTo.id); + assert.equal(rosterAssignment.assignee_name, first.assignee_name); + + assert.equal( + (await request('/api/tickets', 'POST', { + title: 'Untrusted assignment', + assignee_id: members[0].id, + })).status, + 400, + 'public ticket creation cannot claim an assignee', + ); + const ordinary = JSON.parse( + (await request('/api/tickets', 'POST', { title: 'Ordinary task' })).text, + ); + assert.equal( + (await request(`/api/tickets/${ordinary.id}`, 'PATCH', { + assignee_id: members[0].id, + })).status, + 400, + 'ordinary tickets cannot be assigned', + ); + assert.equal( + (await request(`/api/tickets/${firstId}`, 'PATCH', { assignee_id: 999999 })).status, + 400, + 'assignments must reference a household member', + ); + + assert.equal((await request(`/api/tickets/${firstId}`, 'PATCH', { status: 'closed' })).status, 200); + // Reading again cannot rematerialize the same scheduled date from history. + await request(`/api/chores?week=${week}`); + await request(`/api/chores?week=${week}`); + occurrences = db + .prepare('SELECT id, status FROM tickets WHERE schedule_id = ? ORDER BY id') + .all(schedule.id); + assert.equal(occurrences.length, 1, 'the same occurrence is not duplicated'); + + // Model a later generated occurrence while keeping this test independent of + // which weekday the test happens to run on. + const newerTicket = createTicket( + db, + { title: 'Later towel occurrence', due_date: addDays(today, 1) }, + { scheduleId: schedule.id }, + ); + db.prepare( + 'UPDATE tickets SET assignee_id = ?, original_due_date = ? WHERE id = ?', + ).run(assignedTo.id, addDays(today, 1), newerTicket.id); + + assert.equal( + (await request(`/api/tickets/${firstId}`, 'PATCH', { status: 'open' })).status, + 400, + 'an older chore cannot reopen ahead of a newer unfinished occurrence', + ); + assert.equal( + (await request('/api/tickets/bulk', 'POST', { + ids: [firstId, newerTicket.id], + status: 'open', + })).status, + 400, + 'bulk reopening uses the same chore guard', + ); + assert.equal(JSON.parse((await request(`/api/tickets/${firstId}`)).text).status, 'closed'); + + assert.equal( + (await request(`/api/schedules/${schedule.id}`, 'PATCH', { archived: true })).status, + 200, + ); + assert.equal( + (await request(`/api/tickets/${firstId}`, 'DELETE')).status, + 400, + 'archiving the schedule does not make generated history deletable', + ); + assert.equal((await request(`/api/tickets/${firstId}`)).status, 200); + assert.equal((await request(`/api/tickets/${ordinary.id}`, 'DELETE')).status, 204); +}); + +test('chore roster reads only materialize current-week chores; maintenance fires routines', async (t) => { + const { db, request } = await server(t); + const { today } = JSON.parse((await request('/api/planning')).text); + const currentWeek = addDays( + today, + -new Date(`${today}T12:00:00Z`).getUTCDay(), + ); + const pastWeek = addDays(currentWeek, -7); + const invalidWeek = addDays(currentWeek, 1); + + const choreResponse = await request('/api/schedules', 'POST', { + title: 'Current-week chore', + is_chore: true, + recurrence: { kind: 'interval', days: 7 }, + next_due: today, + }); + assert.equal(choreResponse.status, 201); + const choreId = JSON.parse(choreResponse.text).id; + const routineResponse = await request('/api/schedules', 'POST', { + title: 'Ordinary routine', + recurrence: { kind: 'interval', days: 7 }, + next_due: today, + }); + assert.equal(routineResponse.status, 201); + const routineId = JSON.parse(routineResponse.text).id; + const occurrences = (scheduleId) => + db + .prepare('SELECT id FROM tickets WHERE schedule_id = ? ORDER BY id') + .all(scheduleId); + + const pastRosterResponse = await request(`/api/chores?week=${pastWeek}`); + assert.equal(pastRosterResponse.status, 200); + assert.equal(JSON.parse(pastRosterResponse.text).week_start, pastWeek); + assert.deepEqual(occurrences(choreId), [], 'past roster reads do not fire chores'); + assert.deepEqual( + occurrences(routineId), + [], + 'past roster reads do not fire ordinary routines', + ); + + assert.equal( + (await request(`/api/chores?week=${invalidWeek}`)).status, + 400, + 'a non-Sunday week is rejected', + ); + assert.deepEqual(occurrences(choreId), [], 'invalid weeks do not fire chores'); + assert.deepEqual( + occurrences(routineId), + [], + 'invalid weeks do not fire ordinary routines', + ); + + const currentRosterResponse = await request(`/api/chores?week=${currentWeek}`); + assert.equal(currentRosterResponse.status, 200); + const currentRoster = JSON.parse(currentRosterResponse.text); + assert.equal(currentRoster.week_start, currentWeek); + assert.equal(occurrences(choreId).length, 1, 'current roster reads fire due chores'); + assert.deepEqual( + occurrences(routineId), + [], + 'current roster reads do not fire ordinary routines', + ); + + const maintenanceResponse = await request('/api/maintenance/run', 'POST'); + assert.equal(maintenanceResponse.status, 200); + const maintenance = JSON.parse(maintenanceResponse.text); + const routineFired = maintenance.schedules_fired.find( + (entry) => entry.schedule_id === routineId, + ); + assert.ok(routineFired, 'manual maintenance still fires ordinary routines'); + assert.equal(occurrences(routineId).length, 1); + assert.equal(occurrences(routineId)[0].id, routineFired.ticket_id); +}); + test('HTTP planning CRUD, order and saved-view filters execute the public contract', async (t) => { const { request } = await server(t); let res = await request('/api/projects', 'POST', { name: 'Office' }); diff --git a/test/planning.test.js b/test/planning.test.js index e52cbdc..dc442b8 100644 --- a/test/planning.test.js +++ b/test/planning.test.js @@ -16,12 +16,13 @@ import { } from '../src/api/planning.js'; import { createSchedule, + getChoreRoster, getSchedule, updateSchedule, runSchedules, } from '../src/api/schedules.js'; -import { nextOccurrence } from '../src/recurrence.js'; -import { civilDate, addDays } from '../src/dates.js'; +import { nextOccurrence, syncRecurrence } from '../src/recurrence.js'; +import { civilDate, addDays, configureDates } from '../src/dates.js'; import { submitOnce } from '../src/submissions.js'; import { renderCalendar } from '../src/api/calendar.js'; @@ -179,6 +180,46 @@ test('reopening and recompleting a calendar routine does not skip its next occur assert.equal(getSchedule(db, schedule.id).next_due, due); }); +test('chore recurrence commits only the next known weekly occurrence after completion', (t) => { + const db = database(t); + configureDates(db, 'UTC'); + const today = new Date().toISOString().slice(0, 10); + const weekStart = addDays( + today, + -new Date(`${today}T12:00:00Z`).getUTCDay(), + ); + const schedule = createSchedule(db, { + title: 'Wipe counters', + is_chore: true, + next_due: weekStart, + time_zone: 'UTC', + recurrence: { kind: 'weekly', weekdays: [0, 1, 2, 3, 4, 5, 6] }, + }); + assert.equal(schedule.is_chore, true); + assert.equal(schedule.lead_days, 0); + + const first = runSchedules(db, { today: weekStart })[0].ticket; + assert.equal(first.original_due_date, weekStart); + assert.equal( + getChoreRoster(db, { week: weekStart, today: weekStart }).previews[0] + .conditional, + true, + ); + + // Simulate a completion on the preceding day so its successor is known for + // today. Calendar recurrences never create the intervening missed dates. + db.prepare( + "UPDATE tickets SET status = 'resolved', resolved_at = ? WHERE id = ?", + ).run(`${addDays(today, -1)} 12:00:00`, first.id); + syncRecurrence(db, first.id); + assert.equal(getSchedule(db, schedule.id).next_due, today); + + const successor = runSchedules(db, { today }); + assert.equal(successor.length, 1); + assert.equal(successor[0].ticket.original_due_date, today); + assert.equal(successor[0].ticket.schedule_id, schedule.id); +}); + test('reopening older history cannot generate a parallel unfinished recurrence', (t) => { const db = database(t), today = civilDate(); diff --git a/test/queues.test.js b/test/queues.test.js index 3045e9a..faa6251 100644 --- a/test/queues.test.js +++ b/test/queues.test.js @@ -140,6 +140,7 @@ test('migration preserves version-5 records and attachment bytes; backup restore const scratch = await mkdtemp(join(tmpdir(), 'task-hub-migration-')); let db; let restored; + let primaryError; try { const path = join(scratch, 'legacy.db'); db = openDatabase(path); @@ -149,7 +150,15 @@ test('migration preserves version-5 records and attachment bytes; backup restore const { queue: _queue, ...before } = getTicket(db, task.id); // Reconstruct the immediately preceding schema, then exercise the normal // open/migrate path against its on-disk records (not an empty database). - db.exec(`DROP TABLE submissions; DROP TABLE saved_views; DROP TABLE checklist_items; + db.exec(`DROP INDEX idx_tickets_assignee; + DROP INDEX idx_tickets_original_due_date; + ALTER TABLE tickets DROP COLUMN assignee_id; + ALTER TABLE tickets DROP COLUMN original_due_date; + DROP TABLE household_members; + ALTER TABLE schedules DROP COLUMN is_chore; + ALTER TABLE schedules DROP COLUMN archived; + ALTER TABLE schedules DROP COLUMN last_due; + DROP TABLE submissions; DROP TABLE saved_views; DROP TABLE checklist_items; DROP INDEX idx_tickets_project; ALTER TABLE tickets DROP COLUMN project_id; ALTER TABLE tickets DROP COLUMN today_rank; @@ -161,9 +170,10 @@ test('migration preserves version-5 records and attachment bytes; backup restore ALTER TABLE schedules DROP COLUMN project_id; ALTER TABLE schedules DROP COLUMN checklist; ALTER TABLE schedules DROP COLUMN time_zone; - ALTER TABLE schedules DROP COLUMN last_due; DROP TABLE projects; - DROP INDEX idx_tickets_queue_status; ALTER TABLE tickets DROP COLUMN queue; PRAGMA user_version = 5`); + DROP INDEX idx_tickets_queue_status; + ALTER TABLE tickets DROP COLUMN queue; + PRAGMA user_version = 5`); db.close(); db = null; db = openDatabase(path); @@ -178,9 +188,26 @@ test('migration preserves version-5 records and attachment bytes; backup restore db.close(); db = openDatabase(path); assert.equal(getTicket(db, task.id).queue, 'someday'); + } catch (error) { + primaryError = error; + throw error; } finally { - restored?.close(); - db?.close(); - await rm(scratch, { recursive: true, force: true }); + let cleanupError; + for (const handle of [restored, db]) { + try { + handle?.close(); + } catch (error) { + cleanupError ??= error; + } + } + try { + await rm(scratch, { recursive: true, force: true }); + } catch (error) { + cleanupError ??= error; + } + if (cleanupError) { + if (primaryError) primaryError.cleanupError = cleanupError; + else throw cleanupError; + } } }); diff --git a/test/schedules.test.js b/test/schedules.test.js index 7d2f5cd..87dca15 100644 --- a/test/schedules.test.js +++ b/test/schedules.test.js @@ -2,15 +2,19 @@ import test, { beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { openDatabase, transaction } from '../src/db.js'; import { createDevice } from '../src/api/devices.js'; -import { getTicket, listTickets } from '../src/api/tickets.js'; +import { createTicket, getTicket, listTickets } from '../src/api/tickets.js'; import { listSchedules, getSchedule, + getChoreRoster, + updateHouseholdMember, createSchedule, updateSchedule, deleteSchedule, runSchedules, } from '../src/api/schedules.js'; +import { syncRecurrence } from '../src/recurrence.js'; +import { addDays } from '../src/dates.js'; let db; @@ -26,6 +30,16 @@ const today = () => db.prepare(`SELECT date('now') AS d`).get().d; const daysFromNow = (n) => db.prepare(`SELECT date('now', ?) AS d`).get(`${n >= 0 ? '+' : ''}${n} days`).d; +const createChore = (title, next_due, weekdays = [0]) => + createSchedule(db, { + title, + next_due, + interval_days: 7, + recurrence: { kind: 'weekly', weekdays }, + is_chore: true, + }); +const createTicketForChoreTest = (title) => createTicket(db, { title }); + /* ---- Creating ----------------------------------------------------------- */ test('a new schedule defaults to being due today', () => { @@ -77,6 +91,259 @@ test('rejects a device that does not exist', () => { ); }); +test('chore opt-in is explicit and requires a modern recurrence', () => { + const routine = createSchedule(db, { title: 'Routine', interval_days: 7 }); + assert.equal(routine.is_chore, false); + assert.equal(routine.archived, false); + assert.throws( + () => createSchedule(db, { title: 'No cadence', is_chore: true }), + /Chore schedules require a modern recurrence/, + ); + assert.deepEqual(listSchedules(db, { is_chore: true }), []); + assert.equal(listSchedules(db, { is_chore: false }).length, 1); + const chore = createChore('Opted in at creation', '2026-09-20', [0]); + assert.throws( + () => updateSchedule(db, chore.id, { recurrence: null }), + /Chore schedules require a modern recurrence/, + ); + assert.ok(getSchedule(db, chore.id).recurrence); + assert.throws( + () => updateSchedule(db, chore.id, { is_chore: false }), + /is_chore cannot be changed/, + ); + assert.throws( + () => + updateSchedule(db, routine.id, { + is_chore: true, + recurrence: { kind: 'weekly', weekdays: [2] }, + }), + /is_chore cannot be changed/, + ); + assert.equal(updateSchedule(db, chore.id, { archived: true }).archived, true); + assert.throws( + () => updateSchedule(db, chore.id, { archived: false }), + /cannot be unarchived/, + ); + assert.equal(updateSchedule(db, chore.id, { title: 'Archived title' }).archived, true); + assert.equal(listSchedules(db, { is_chore: true, archived: true }).length, 1); +}); + +test('the persistent roster has two stable editable member IDs', () => { + const initial = getChoreRoster(db, { today: '2026-09-25' }); + assert.deepEqual(initial.members, [ + { id: 1, name: 'Member 1' }, + { id: 2, name: 'Member 2' }, + ]); + assert.deepEqual(updateHouseholdMember(db, 1, { name: 'Alex' }), { + id: 1, + name: 'Alex', + }); + assert.equal(getChoreRoster(db, { today: '2026-09-25' }).members[0].id, 1); + assert.throws(() => updateHouseholdMember(db, 1, { name: 'Member 2' }), /unique/); +}); + +test('chores cannot be converted from an existing ticket', () => { + const task = createTicketForChoreTest('Existing task'); + assert.throws( + () => + createSchedule(db, { + title: 'Chore', + is_chore: true, + next_due: '2026-09-20', + recurrence: { kind: 'weekly', weekdays: [0] }, + source_ticket_id: task.id, + }), + /cannot be created from an existing ticket/, + ); + assert.equal(getTicket(db, task.id).schedule_id, null); +}); + +test('runSchedules can materialize chores without firing ordinary routines', () => { + const routine = createSchedule(db, { + title: 'Ordinary routine', + next_due: '2026-09-20', + interval_days: 7, + }); + const chore = createChore('Household chore', '2026-09-20', [0]); + + const choresOnly = runSchedules(db, { + today: '2026-09-20', + onlyChores: true, + }); + assert.deepEqual( + choresOnly.map(({ schedule_id }) => schedule_id), + [chore.id], + ); + assert.equal(getSchedule(db, routine.id).last_ticket_id, null); + assert.equal(listTickets(db).length, 1); + + const normalSweep = runSchedules(db, { today: '2026-09-20' }); + assert.deepEqual( + normalSweep.map(({ schedule_id }) => schedule_id), + [routine.id], + ); +}); + +test('Sunday commits known chores, balances assignment, and exposes later work conditionally', () => { + const actualToday = today(); + const weekday = new Date(`${actualToday}T12:00:00Z`).getUTCDay(); + const weekStart = addDays(actualToday, -weekday); + const daily = createChore('Daily dishes', weekStart, [0, 1, 2, 3, 4, 5, 6]); + const weekly = createChore('Weekly floors', weekStart, [0]); + + const fired = runSchedules(db, { today: weekStart }); + assert.deepEqual( + fired.map(({ ticket }) => ticket.assignee_id), + [1, 2], + 'the initial load is split between the two members', + ); + assert.ok(fired.every(({ ticket }) => ticket.original_due_date === weekStart)); + assert.ok(fired.every(({ ticket }) => ticket.events.some((e) => e.kind === 'assignee'))); + assert.equal(runSchedules(db, { today: addDays(weekStart, 1) }).length, 0); + + const roster = getChoreRoster(db, { week: weekStart, today: weekStart }); + assert.equal(roster.week_start, weekStart); + assert.equal(roster.week_end, addDays(weekStart, 6)); + assert.deepEqual( + roster.assignments.map(({ assignee_id }) => assignee_id), + [1, 2], + ); + assert.deepEqual(roster.previews, [ + { + schedule_id: daily.id, + title: 'Daily dishes', + due_date: addDays(weekStart, 1), + conditional: true, + }, + ]); + assert.equal(roster.chores.find((chore) => chore.id === weekly.id).is_chore, true); +}); + +test('completion enables a later weekly occurrence without backfilling a missed one', () => { + const actualToday = today(); + const actualWeekday = new Date(`${actualToday}T12:00:00Z`).getUTCDay(); + const weekStart = addDays(actualToday, -actualWeekday); + const schedule = createChore( + 'Daily counter', + weekStart, + [0, 1, 2, 3, 4, 5, 6], + ); + const first = runSchedules(db, { today: weekStart })[0].ticket; + + // Complete on the day before the injected current day; the successor is + // therefore a known current-week occurrence, not historical catch-up. + db.prepare( + "UPDATE tickets SET status = 'resolved', resolved_at = ? WHERE id = ?", + ).run(`${addDays(actualToday, -1)} 12:00:00`, first.id); + syncRecurrence(db, first.id); + assert.equal(getSchedule(db, schedule.id).next_due, actualToday); + + const next = runSchedules(db, { today: actualToday }); + assert.equal(next.length, 1); + assert.equal(next[0].ticket.original_due_date, actualToday); + assert.notEqual(next[0].ticket.assignee_id, first.assignee_id); + + // A Monday missed while offline is skipped to the next occurrence in this + // week, rather than being materialized with a historical due date. + const midweek = createChore('Midweek laundry', '2026-09-21', [1, 4]); + const missed = runSchedules(db, { today: '2026-09-22' }); + const laundry = missed.find(({ schedule_id }) => schedule_id === midweek.id); + assert.equal(laundry.ticket.original_due_date, '2026-09-24'); +}); + +test('roster browsing is read-only, validates Sunday starts, and only creates within this week', () => { + const schedule = createChore('Future chore', '2026-09-27', [0]); + const before = getSchedule(db, schedule.id).next_due; + assert.throws( + () => getChoreRoster(db, { week: '2026-09-21', today: '2026-09-25' }), + /week must be a Sunday/, + ); + const past = getChoreRoster(db, { week: '2026-09-20', today: '2026-09-25' }); + assert.equal(past.assignments.length, 0); + assert.equal(getSchedule(db, schedule.id).next_due, before); + assert.equal(runSchedules(db, { today: '2026-09-25' }).length, 0); + assert.equal(listTickets(db).length, 0); +}); + +test('deleting a chore archives it and retains its open assignment history', () => { + const schedule = createChore('Keep the entry clear', '2026-09-20', [0]); + const [{ ticket }] = runSchedules(db, { today: '2026-09-20' }); + + deleteSchedule(db, schedule.id); + + assert.equal(getSchedule(db, schedule.id).archived, true); + assert.equal(getTicket(db, ticket.id).assignee_id, 1); + assert.equal(getChoreRoster(db, { week: '2026-09-20', today: '2026-09-20' }).assignments.length, 1); + assert.equal(runSchedules(db, { today: '2026-09-27' }).length, 0); + assert.equal(listSchedules(db, { archived: true }).length, 1); +}); + +test('an overdue open chore retains its original owner and date as a carryover', () => { + const prior = createChore('Old chore', '2026-09-13', [0]); + const [{ ticket: overdue }] = runSchedules(db, { today: '2026-09-13' }); + const next = createChore('New week chore', '2026-09-20', [0]); + + const created = runSchedules(db, { today: '2026-09-20' }); + const current = created.find(({ schedule_id }) => schedule_id === next.id).ticket; + db.prepare('UPDATE tickets SET due_date = ? WHERE id = ?').run( + '2026-09-28', + overdue.id, + ); + const roster = getChoreRoster(db, { + week: '2026-09-20', + today: '2026-09-20', + }); + const carried = roster.assignments.find(({ id }) => id === overdue.id); + assert.equal(carried.assignee_id, overdue.assignee_id); + assert.equal(carried.original_due_date, '2026-09-13'); + assert.equal(carried.due_date, '2026-09-28'); + assert.notEqual( + current.assignee_id, + overdue.assignee_id, + 'the carryover shifts new work to the less-loaded member', + ); + assert.ok(roster.chores.some(({ id }) => id === prior.id)); +}); + +test('historical chore starts advance to the current week without backfilling', () => { + const actualToday = today(); + const weekday = new Date(`${actualToday}T12:00:00Z`).getUTCDay(); + const oldStart = addDays(actualToday, -45); + const schedule = createChore('Historical start', oldStart, [weekday]); + + const [{ ticket }] = runSchedules(db, { today: actualToday }); + assert.equal(ticket.schedule_id, schedule.id); + assert.equal(ticket.due_date, actualToday); + assert.equal(ticket.original_due_date, actualToday); +}); + +test('fair final tie-break varies by schedule rather than favoring the lower member ID', () => { + const firstWeek = '2026-01-04'; + const firstSchedule = createChore('First fair chore', firstWeek, [0]); + const [first] = runSchedules(db, { today: firstWeek }); + db.prepare( + "UPDATE tickets SET status = 'resolved', resolved_at = '2026-01-04 12:00:00' WHERE id = ?", + ).run(first.ticket.id); + deleteSchedule(db, firstSchedule.id); + + const secondWeek = '2026-02-15'; + const secondSchedule = createChore('Second fair chore', secondWeek, [0]); + const laterFired = runSchedules(db, { today: secondWeek }); + assert.deepEqual( + laterFired.map(({ schedule_id }) => schedule_id), + [secondSchedule.id], + 'the completed archived recurrence is not generated again', + ); + const [second] = laterFired; + + assert.notEqual( + first.ticket.assignee_id, + second.ticket.assignee_id, + 'with no current or prior-four-week load and no schedule-specific prior owner, the tie shifts', + ); + assert.equal(second.schedule_id, secondSchedule.id); +}); + /* ---- Firing ------------------------------------------------------------- */ test('fires a due schedule and advances it by one interval', () => {