Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions docs/planning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 10 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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' },
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
<a href="#/dashboard" data-tab="dashboard">Dashboard</a>
<a href="#/devices" data-tab="devices">Devices</a>
<a href="#/schedules" data-tab="schedules">Schedules</a>
<a href="#/chores" data-tab="chores">Chores</a>
</nav>
<nav id="saved-views-nav" class="secondary-nav saved-nav" aria-label="Saved views"></nav>

Expand Down
121 changes: 118 additions & 3 deletions public/planning.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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())
Expand All @@ -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'],
Expand Down
Loading
Loading