Skip to content

Commit dfe0d72

Browse files
committed
House keeping and chore refactoring to merge chore due date and chore schedule into a single table. This will allow for more flexibility in scheduling chores and tracking their completion. The following changes were made:
1 parent b9eb82e commit dfe0d72

13 files changed

Lines changed: 454 additions & 80 deletions

File tree

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ coverage/
1616
# sounds live in server/assets/ and ARE tracked; seeded copies land here.
1717
server/uploads/
1818

19+
# Local SQLite database (runtime data, initialized by migrations)
20+
server/data/*.db
21+
server/data/*.db-journal
22+
server/data/*.db-wal
23+
server/data/*.db-shm
24+
25+
# Generated encryption key (secret, do not commit)
26+
server/data/.encryption-key
27+
1928
package-lock.json
2029

2130
.vscode/

client/src/components/ChoreSchedulesTab.jsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ const defaultScheduleForm = {
126126
sleepCount: '',
127127
sleepUnit: 'd',
128128
visible: true,
129+
due_date: '',
129130
due_time: '',
130131
sound_enabled: false,
131132
sound: '',
@@ -241,6 +242,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) {
241242
sleepCount: schedule.interval ? (schedule.interval.match(/^(\d+)/)?.[1] || '') : '',
242243
sleepUnit: schedule.interval ? (schedule.interval.match(/[dwmy]$/i)?.[0].toLowerCase() || 'd') : 'd',
243244
visible: !!schedule.visible,
245+
due_date: schedule.due_date || '',
244246
due_time: schedule.due_time || '',
245247
sound_enabled: !!schedule.sound_enabled,
246248
sound: schedule.sound || '',
@@ -273,6 +275,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) {
273275
duration: !scheduleForm.isOneTime ? scheduleForm.duration : 'day-of',
274276
interval: normalizedInterval,
275277
visible: scheduleForm.visible ? 1 : 0,
278+
due_date: scheduleForm.due_date || null,
276279
due_time: scheduleForm.due_time || null,
277280
sound_enabled: scheduleForm.sound_enabled ? 1 : 0,
278281
sound: scheduleForm.sound_enabled ? (scheduleForm.sound || null) : null,
@@ -928,6 +931,17 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) {
928931

929932
<Divider />
930933

934+
<TextField
935+
label="Due date (optional)"
936+
type="date"
937+
size="small"
938+
value={scheduleForm.due_date}
939+
onChange={(e) => updateScheduleForm({ due_date: e.target.value })}
940+
InputLabelProps={{ shrink: true }}
941+
helperText="Calendar deadline (best for one-time tasks). The chore turns yellow when due, red when overdue."
942+
sx={{ maxWidth: 260 }}
943+
/>
944+
931945
<TextField
932946
label="Due time (optional)"
933947
type="time"

client/src/components/ChoreWidget.jsx

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ import {
2020
DialogContent,
2121
DialogActions,
2222
Backdrop,
23-
CircularProgress
23+
CircularProgress,
24+
Menu,
25+
ListItemText
2426
} from '@mui/material';
25-
import { Edit, Save, Cancel, Add, Delete, Check, Undo } from '@mui/icons-material';
27+
import { Edit, Save, Cancel, Add, Delete, Check, Undo, SwapHoriz } from '@mui/icons-material';
2628
import axios from 'axios';
2729
import { API_BASE_URL } from '../utils/apiConfig.js';
2830
import { getDeviceApiBase } from '../utils/deviceName.js';
29-
import { shouldShowChoreToday, getTodayDateString, convertDaysToCrontab } from '../utils/choreHelpers.js';
31+
import { shouldShowChoreToday, getTodayDateString, convertDaysToCrontab, getDueDateStatus, formatDueDate } from '../utils/choreHelpers.js';
3032

3133
const USERS_UPDATED_EVENT = 'homeglow:users-updated';
3234

@@ -65,6 +67,8 @@ const ChoreWidget = ({ transparentBackground, refreshInterval = 0 }) => {
6567
const [deviceSettingsLoaded, setDeviceSettingsLoaded] = useState(false);
6668
const [isLoading, setIsLoading] = useState(false);
6769
const [dailyClamReward, setDailyClamReward] = useState(2);
70+
const [reassignAnchor, setReassignAnchor] = useState(null);
71+
const [reassignSchedule, setReassignSchedule] = useState(null);
6872

6973
const daysOfWeek = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
7074

@@ -282,6 +286,46 @@ const ChoreWidget = ({ transparentBackground, refreshInterval = 0 }) => {
282286
}
283287
};
284288

289+
const reassignChore = async (scheduleId, newUserId) => {
290+
const schedule = schedules.find(s => s.id === scheduleId);
291+
if (!schedule || schedule.user_id === newUserId) {
292+
return;
293+
}
294+
295+
try {
296+
setIsLoading(true);
297+
await axios.patch(`${API_BASE_URL}/api/chore-schedules/${scheduleId}`, {
298+
user_id: newUserId,
299+
visible: 1
300+
});
301+
await fetchData();
302+
} catch (error) {
303+
console.error('Error reassigning chore:', error);
304+
alert(error.response?.data?.error || 'Failed to reassign chore');
305+
} finally {
306+
setIsLoading(false);
307+
}
308+
};
309+
310+
const openReassignMenu = (event, schedule) => {
311+
event.stopPropagation();
312+
setReassignAnchor(event.currentTarget);
313+
setReassignSchedule(schedule);
314+
};
315+
316+
const closeReassignMenu = () => {
317+
setReassignAnchor(null);
318+
setReassignSchedule(null);
319+
};
320+
321+
const handleReassignSelect = (newUserId) => {
322+
const scheduleId = reassignSchedule?.id;
323+
closeReassignMenu();
324+
if (scheduleId) {
325+
reassignChore(scheduleId, newUserId);
326+
}
327+
};
328+
285329
const saveChore = async () => {
286330
try {
287331
setIsLoading(true);
@@ -433,6 +477,15 @@ const ChoreWidget = ({ transparentBackground, refreshInterval = 0 }) => {
433477
};
434478

435479
const renderChoreItem = (schedule) => {
480+
const dueStatus = getDueDateStatus(schedule.due_date, getTodayDateString(), schedule.completed);
481+
const rowBgColor = schedule.completed
482+
? 'rgba(0, 255, 0, 0.1)'
483+
: dueStatus === 'overdue'
484+
? 'rgba(244, 67, 54, 0.16)'
485+
: dueStatus === 'due'
486+
? 'rgba(255, 193, 7, 0.20)'
487+
: 'transparent';
488+
436489
return (
437490
<Box
438491
key={schedule.id}
@@ -441,7 +494,7 @@ const ChoreWidget = ({ transparentBackground, refreshInterval = 0 }) => {
441494
border: '1px solid var(--card-border)',
442495
borderRadius: 2,
443496
mb: 1,
444-
bgcolor: schedule.completed ? 'rgba(0, 255, 0, 0.1)' : 'transparent',
497+
bgcolor: rowBgColor,
445498
display: 'flex',
446499
justifyContent: 'space-between',
447500
alignItems: 'center'
@@ -465,14 +518,23 @@ const ChoreWidget = ({ transparentBackground, refreshInterval = 0 }) => {
465518
sx={{ ml: 1, fontSize: '0.7rem' }}
466519
/>
467520
)}
521+
{schedule.due_date && (
522+
<Chip
523+
label={`${dueStatus === 'overdue' ? '⚠️ Overdue' : `Due ${formatDueDate(schedule.due_date)}`}`}
524+
size="small"
525+
color={dueStatus === 'overdue' ? 'error' : dueStatus === 'due' ? 'warning' : 'default'}
526+
variant={dueStatus === 'upcoming' || dueStatus === 'none' ? 'outlined' : 'filled'}
527+
sx={{ ml: 1, fontSize: '0.7rem' }}
528+
/>
529+
)}
468530
</Typography>
469531
{schedule.description && (
470532
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
471533
{schedule.description}
472534
</Typography>
473535
)}
474536
</Box>
475-
<Box sx={{ display: 'flex', gap: 1 }}>
537+
<Box sx={{ display: 'flex', gap: 0.5 }}>
476538
<IconButton
477539
color={schedule.completed ? "secondary" : "primary"}
478540
onClick={() => toggleChoreCompletion(schedule, schedule.completed)}
@@ -491,6 +553,25 @@ const ChoreWidget = ({ transparentBackground, refreshInterval = 0 }) => {
491553
>
492554
{schedule.completed ? <Undo fontSize="small" /> : <Check fontSize="small" />}
493555
</IconButton>
556+
{users.length > 1 && (
557+
<IconButton
558+
onClick={(e) => openReassignMenu(e, schedule)}
559+
size="small"
560+
title="Reassign to another person"
561+
sx={{
562+
minWidth: 'auto',
563+
width: 32,
564+
height: 32,
565+
color: 'var(--accent)',
566+
border: '1px solid var(--card-border)',
567+
'&:hover': {
568+
bgcolor: 'rgba(var(--accent-rgb), 0.1)'
569+
}
570+
}}
571+
>
572+
<SwapHoriz fontSize="small" />
573+
</IconButton>
574+
)}
494575
</Box>
495576
</Box>
496577
);
@@ -861,6 +942,25 @@ const ChoreWidget = ({ transparentBackground, refreshInterval = 0 }) => {
861942
</Button>
862943
</DialogActions>
863944
</Dialog>
945+
946+
<Menu
947+
anchorEl={reassignAnchor}
948+
open={Boolean(reassignAnchor)}
949+
onClose={closeReassignMenu}
950+
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
951+
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
952+
>
953+
<MenuItem disabled sx={{ opacity: 0.7, fontSize: '0.8rem' }}>
954+
Reassign to…
955+
</MenuItem>
956+
{users
957+
.filter(user => user.id !== reassignSchedule?.user_id)
958+
.map(user => (
959+
<MenuItem key={user.id} onClick={() => handleReassignSelect(user.id)}>
960+
<ListItemText primary={user.username} />
961+
</MenuItem>
962+
))}
963+
</Menu>
864964
</Box>
865965

866966
<Backdrop

client/src/utils/choreHelpers.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,28 @@ export function convertDaysToCrontab(daysArray) {
6464
const dayNumbers = daysArray.map(day => dayMap[day.toLowerCase()]).sort();
6565
return `0 0 * * ${dayNumbers.join(',')}`;
6666
}
67+
68+
// Urgency status for a chore's calendar due date (issue #97).
69+
// Compares 'YYYY-MM-DD' strings lexicographically (valid for ISO dates).
70+
// Returns: 'none' (no date / already completed), 'upcoming' (before due day),
71+
// 'due' (due today), or 'overdue' (past due).
72+
export function getDueDateStatus(dueDate, todayStr = getTodayDateString(), completed = false) {
73+
if (!dueDate || completed) {
74+
return 'none';
75+
}
76+
if (dueDate === todayStr) {
77+
return 'due';
78+
}
79+
return dueDate < todayStr ? 'overdue' : 'upcoming';
80+
}
81+
82+
// Formats a 'YYYY-MM-DD' string as a short, locale-friendly label (e.g. 'Jul 3').
83+
export function formatDueDate(dueDate) {
84+
if (typeof dueDate !== 'string') return '';
85+
const parts = dueDate.split('-').map(Number);
86+
if (parts.length !== 3 || parts.some(Number.isNaN)) return dueDate;
87+
const [year, month, day] = parts;
88+
const date = new Date(year, month - 1, day);
89+
if (Number.isNaN(date.getTime())) return dueDate;
90+
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
91+
}

client/src/utils/choreHelpers.test.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ vi.mock('./timezone.js', () => ({
44
getServerTimezoneSync: () => 'UTC',
55
}));
66

7-
import { shouldShowChoreToday, convertDaysToCrontab } from './choreHelpers.js';
7+
import { shouldShowChoreToday, convertDaysToCrontab, getDueDateStatus, formatDueDate } from './choreHelpers.js';
88

99
describe('choreHelpers utilities', () => {
1010
let consoleErrorSpy;
@@ -55,4 +55,40 @@ describe('choreHelpers utilities', () => {
5555
it('convertDaysToCrontab maps and sorts day values', () => {
5656
expect(convertDaysToCrontab(['friday', 'monday', 'sunday'])).toBe('0 0 * * 0,1,5');
5757
});
58+
59+
describe('getDueDateStatus', () => {
60+
const today = '2026-05-01';
61+
62+
it('returns none when there is no due date', () => {
63+
expect(getDueDateStatus(null, today, false)).toBe('none');
64+
expect(getDueDateStatus('', today, false)).toBe('none');
65+
});
66+
67+
it('returns none when the chore is already completed', () => {
68+
expect(getDueDateStatus('2026-05-01', today, true)).toBe('none');
69+
});
70+
71+
it('returns due when the due date is today', () => {
72+
expect(getDueDateStatus('2026-05-01', today, false)).toBe('due');
73+
});
74+
75+
it('returns overdue when the due date is in the past', () => {
76+
expect(getDueDateStatus('2026-04-30', today, false)).toBe('overdue');
77+
});
78+
79+
it('returns upcoming when the due date is in the future', () => {
80+
expect(getDueDateStatus('2026-05-09', today, false)).toBe('upcoming');
81+
});
82+
});
83+
84+
describe('formatDueDate', () => {
85+
it('formats a valid date as a short label', () => {
86+
expect(formatDueDate('2026-07-03')).toBe('Jul 3');
87+
});
88+
89+
it('passes through malformed input', () => {
90+
expect(formatDueDate('not-a-date')).toBe('not-a-date');
91+
expect(formatDueDate(null)).toBe('');
92+
});
93+
});
5894
});

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ stays relevant.
2323
- [Database & Migrations](architecture/database.md) — SQLite schema, tables, and the migration system.
2424
- [Backend Reference](reference/backend-api.md) — server structure, services, and the full REST API surface.
2525
- [Frontend Reference](reference/frontend.md) — React component map, state, and data flow.
26+
- [Chores Refactor History](architecture/chores-refactor-history.md) — the (implemented) original design spec for the three-table chore system.
2627

2728
### Feature deep-dives
2829
- [Features & Domains](reference/features.md) — chores/clams, calendar sync, photos, screensaver, theming, tabs & layout.

docs/architecture/chores-refactor-history.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
# Chores Refactor — Historical Design Spec
2+
3+
> **Status: implemented.** This is the original design spec for the migration from the
4+
> old single-table chores model to the current three-table system
5+
> (`chores` / `chore_schedules` / `chore_history`). It is kept for history and to explain
6+
> *why* the schema looks the way it does. For the current schema and endpoints see
7+
> [database.md](database.md) and [../reference/backend-api.md](../reference/backend-api.md).
8+
> Later additions on top of this design: `duration`/`interval`/`parent_schedule_id`
9+
> (sticky/recurring scheduling), `due_time`/`sound*` (due-time notification sounds, #108),
10+
> and `due_date` (calendar deadlines with urgency coloring, #97).
11+
12+
---
13+
114
Current State Analysis
215
The existing system stores all chore information in a single chores table with fields: id, user_id, title, description, time_period, assigned_day_of_week, repeat_type, completed, clam_value, expiration_date. Clam totals are maintained in users.clam_total, and there's a pruneAndResetChores() function that runs on server startup to manage chore lifecycle.
316

docs/architecture/database.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ run in ascending order. The registry lives in `schemaMigrations` in
4545
| 13 | `schema13-tabsByDefaultBackfill.js` | Backfills default tabs. |
4646
| 14 | `schema14-deviceAndTabJsonStorage.js` | Moves widget layout into `tabs.config_json` and device settings into `devices.device_settings_json`; **drops** `widget_tab_assignments`. |
4747
| 15 | `schema15-choreDueTimeSound.js` | Adds `due_time`, `sound`, `sound_enabled`, `reminder_interval_minutes` to `chore_schedules` (chore due-time notification sounds). |
48+
| 16 | `schema16-choreDueDate.js` | Adds `due_date` to `chore_schedules` (calendar deadline with urgency coloring, issue #97). |
4849

4950
Each versioned migration runs inside a transaction, reads its context from
5051
`globalThis.__HOMEGLOW_SCHEMA_MIGRATION_CONTEXT`, and writes the new
@@ -78,6 +79,7 @@ due_time, -- 'HH:MM' 24h local time the chore is due (nullab
7879
sound_enabled, -- 0/1: play a notification sound at due_time
7980
sound, -- chosen sound filename; null = use global default
8081
reminder_interval_minutes, -- null/0 = ring once; N = repeat every N min until completed
82+
due_date, -- 'YYYY-MM-DD' calendar deadline (nullable; drives yellow/red urgency, #97)
8183
visible, created_at
8284
```
8385

docs/reference/backend-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ static route.
125125
| --- | --- | --- |
126126
| GET/POST | `/api/chores` | List / create chore definitions. |
127127
| PATCH/DELETE | `/api/chores/:id` | Update / delete a chore. |
128-
| GET/POST | `/api/chore-schedules` | List (filter by `user_id`, `visible`, `usage`, `chore_id`) / create. Accepts `due_time` (`HH:MM`), `sound`, `sound_enabled`, `reminder_interval_minutes` for due-time sounds. |
128+
| GET/POST | `/api/chore-schedules` | List (filter by `user_id`, `visible`, `usage`, `chore_id`) / create. Accepts `due_time` (`HH:MM`), `sound`, `sound_enabled`, `reminder_interval_minutes` for due-time sounds, and `due_date` (`YYYY-MM-DD`) for calendar deadlines. PATCH `user_id` reassigns a chore and re-checks the daily bonus for both owners. |
129129
| GET/PATCH/DELETE | `/api/chore-schedules/:id` | Single schedule CRUD. |
130130
| POST | `/api/chore-schedules/bulk` | Bulk create schedules. |
131131
| GET/POST | `/api/chore-history` | Query / add history entries. |

docs/reference/features.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,29 @@ optional follow-up **reminder interval** that repeats until the chore is complet
7878
`components/SoundPicker.jsx`; the sound fields on `chore_schedules`; `/api/sounds*` +
7979
seeding in `server/index.js`; defaults generated by `server/scripts/generateDefaultSounds.js`.
8080

81+
### Chore due-dates (issue #97)
82+
83+
A schedule can carry a **calendar due date** (`due_date`, `YYYY-MM-DD`) — a deadline,
84+
distinct from the due-*time* chime above. It's aimed at **one-off chores** (which already
85+
persist on the list until completed), e.g. "prep the guest sheets by Friday." The chore row
86+
colors by urgency: **yellow** when due today, **red** (with an "⚠️ Overdue" chip) once past
87+
due, and a plain "Due &lt;date&gt;" chip while upcoming. Completing the chore clears the
88+
coloring. Purely visual — `due_date` does not change which chores appear.
89+
90+
**Code:** `getDueDateStatus`/`formatDueDate` in `utils/choreHelpers.js`; row coloring + chip
91+
in `ChoreWidget.jsx`; the `due_date` field in `ChoreSchedulesTab.jsx`; `due_date` column and
92+
validation in `server/index.js`.
93+
94+
### Reassigning a chore (from the dashboard)
95+
96+
Each chore row has a **swap-arrow** button (when more than one user exists) that opens a
97+
dropdown to move the chore to another person without opening settings. The backend
98+
reassignment (a `PATCH` of the schedule's `user_id`) re-checks the daily "all regular chores
99+
done" bonus for **both** the previous and new owner and never removes points.
100+
101+
**Code:** reassign UI in `ChoreWidget.jsx`; `PATCH /api/chore-schedules/:id` +
102+
`awardDailyRegularBonusIfDue` in `server/index.js`.
103+
81104
## Calendar
82105

83106
- Supports multiple sources simultaneously: **public ICS** links, **CalDAV**

0 commit comments

Comments
 (0)