Skip to content

Chores: due dates, calendar repeats and a per-chore "show from" - #159

Merged
mapgie merged 7 commits into
mainfrom
claude/chore-due-dates-repeat-s07lxu
Sep 24, 2026
Merged

mapgie merged 7 commits into
mainfrom
claude/chore-due-dates-repeat-s07lxu

Conversation

@mapgie

@mapgie mapgie commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Repeating chores can now have a due date. The repeat can be counted in days, weeks, months or years, e.g. "House insurance, due 1 Oct 2026, every year". Each chore also gets its own "Show from" setting, kept on this phone only. There are no one-off chores: a due date only applies when the chore also has a repeat.

What changes for the user

  • Edit chore sheet, three new rows:
    • Counted in: Days, Weeks, Months or Years. Shown once a repeat is set; the stepper then reads 1 y, 2 w.
    • Due date: None, or a date from the picker. Also only shown once a repeat is set. Clearing the repeat drops the date on save.
    • Show from: Auto, or a number of days before due. 0 means "on the day".
  • Card and overview: the caption reads e.g. admin · yearly · due 1 Oct. The badge counts whole days: 9d left, due today, 4d over.
  • Status: a dated chore is red from its date onwards, and amber within min(7, repeat/2) days before it. A dated chore that hasn't been logged yet is not shown as "never done".
  • Hiding: when Show from is set, the chore stays in the hidden section until that many days before it is due. This overrides both the smart-visibility lead time and the 60-day "distant" rule. On Auto, a dated chore uses its cadence bucket's lead time. A pinned chore always shows.
  • Add to calendar: a dated chore creates an all-day event on its date.

Next due date

The stored due_date is the date the user entered, and it is never rewritten. nextChoreDueDate(anchor, repeat, lastLog) works out the next due date from it and the latest log:

  • A log counts for the due date nearest to it, whether early or late. Logging on 24 Sep or on 5 Oct both move it to 1 Oct 2027.
  • Due dates before the entered date never come due.
  • Each due date is counted from the entered date, not chained from the previous one. So a monthly chore due 31 Jan goes to 28 Feb and then 31 Mar.

Logging never writes to the chore, so every undo path works with no extra code (LESSONS #64). A row with a date but no repeat, e.g. one set from the web app, is treated as undated and timed from its last log.

Where things are stored

  • Supabase:
    • tags.due_date date and tags.repeat_unit text CHECK (repeat_unit IN ('week','month','year')). A null unit means days.
    • Both are added with ADD COLUMN IF NOT EXISTS, and the CHECK is re-asserted in the constraint sync.
    • interval_days still holds the approximate length (a year is stored as 365), so the web app and the cadence buckets keep working.
    • chorePatch only names the new columns when they hold a value or held one before. That way plain edits still save before the schema is applied. The extra read this needs is commented as removable later.
  • This phone only: Show from lives in ChoreLeadStore (DataStore, keyed by tag id) and reaches the list through ChoreUiState.leadOverrides.

Tests

  • ChoreScheduleTest:
    • the next-due rule on fixed dates
    • reading the repeat back
    • status and badge
    • a date without a repeat is ignored
  • ChoreUiStateTest:
    • dated lead-time hiding, the Overdue chip and the due sort
    • Show from precedence, 0 meaning the due day, pinned chores
  • ChorePayloadTest: which columns are sent, explicit nulls on clear, and lead_days never sent.
  • SchemaSyncTest: RepeatUnit values are allowed by the CHECK, and the ALTERs are present.
  • SheetDraftTest:
    • the date, unit and Show from carried in the draft
    • the date only saved alongside a repeat

Changelog fragment: minor.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H5p1rGPAJ2yaLENgnsDJUo

A chore can now carry a due date and repeat in days, weeks, months or
years ("house insurance, due 1 Oct 2026, every year"). The stored date is
the anchor; the next due date is derived from it and the latest log, so a
log a little early or late moves the chore to the next occurrence and
undoing a log needs no extra code.

Schema: tags gains nullable due_date and repeat_unit (CHECK week/month/
year; null means days). interval_days keeps the approximate length so the
web app and cadence buckets keep working. The edit PATCH names the new
columns only when they hold or held a value, so plain edits still save on
a database that has not had schema.sql applied yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5p1rGPAJ2yaLENgnsDJUo
A chore can now carry lead_days: keep it in the hidden section until
that many days before it is due. It wins over both the smart-visibility
cadence lead and the legacy 60-day distant rule; null leaves those in
charge. A pinned chore still always shows.

The sheet gains a "Show from" row (Auto / Days before due…). Repeat, due
date and lead days now travel together as ChoreSchedule from the sheet
through the view model to the repository. Schema adds tags.lead_days
(CHECK >= 0) with the same only-send-when-used PATCH rule as due_date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5p1rGPAJ2yaLENgnsDJUo
@mapgie mapgie changed the title Add due dates and calendar repeats to chores Chores: due dates, calendar repeats and a per-chore "show from" Sep 22, 2026
The per-chore lead time moves out of Supabase into ChoreLeadStore
(DataStore, tag id to days), alongside swipe-to-snooze and the bucket
lead times: when a chore shows up is a per-phone display choice, so one
person's setting no longer hides it for the rest of the household.

tags.lead_days, its CHECK and the PATCH handling are removed again;
ChoreSchedule is back to repeat and due date. The list state reads the
overrides from leadOverrides, the sheet opens with this phone's value and
saves it through the view model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5p1rGPAJ2yaLENgnsDJUo

@mapgie mapgie left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff (all 18 files) and traced the core scheduling logic against its tests. a11y_check.py and check_changelog_fragment.py both pass locally. No correctness bugs found; a few minor observations below.

What's well done

  • The "never rewrite due_date" design (LESSONS #64). nextChoreDueDate derives the next occurrence from the anchor plus the latest log, so all four undo paths and web-app logs work with no extra code. Occurrences are counted from the anchor (anchor.plusMonths(k)), never chained, which correctly avoids month-end drift (31 Jan then 28 Feb then 31 Mar). The bracket-and-nearest loop is monotonic and terminates, and the "before the anchor never comes due" clamp (maxOf(ticked + 1, 0)) is right.
  • Backward-compatible schema. ADD COLUMN IF NOT EXISTS plus the re-asserted CHECKs in "Constraint sync" keeps schema.sql idempotent, and SchemaSyncTest now guards repeat_unit and the ALTERs. Keeping interval_days as the approximate length preserves the web app and cadence buckets.
  • PATCH gating. chorePatch(..., includeSchedule) names the new columns only when there is a value or the row already had one, so plain edits still save against a project that has not applied the migration. ChorePayloadTest covers the explicit-null-clear and lead-days-alone cases.
  • Tests read as behaviours, not methods, with dates sat mid-window so a run cannot straddle a boundary. dueSoonDays, the Overdue chip, the DUE sort, and all three "show from" precedence rules are covered, and the new ValueChip / dropdown rows carry a11y roles.

Minor observations (non-blocking)

  1. Extra Supabase read per shared edit. updateTag's STAY_SHARED path now calls findShared(tagId) before every patchShared, purely to compute hadSchedule. That is a new round-trip on every plain chore edit, and it is only actually needed during the pre-migration window; once the columns exist you would always send them. Reasonable tradeoff, but worth a comment noting it can be dropped when the schema is known-applied.
  2. ChoreRepeat.from display mismatch on externally-edited rows. If interval_days is edited elsewhere to a non-multiple of the unit (e.g. 45 on a repeat_unit='month' row), the app falls back to every 45d in the caption while the stored repeat_unit still says month. Harmless and documented in the KDoc, just flagging that stored unit and shown unit can disagree in that case.
  3. A done one-off dated chore stays in the main list reading "done" (status FRESH, not hidden) until archived. This is stated as intended in the PR body, so not a defect; it is just the one place a dated chore does not self-tidy the way a repeating one does.

Nothing here needs a change before merge.


Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5p1rGPAJ2yaLENgnsDJUo

mapgie commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

Following up on the review:

  1. Extra read per edit. Added a comment in 48e8d5c at the findShared call. It says the read only matters until every project has the new columns, and can be dropped after that.
  2. Stored unit and shown unit disagreeing. No code change. The next save from the app writes repeat_unit back to null (days), so the stored unit and the shown unit match again.
  3. A completed one-off chore stays in the list showing "done". Left as it is for now. Whether it should move into the hidden section instead is a product decision, and I've asked the author.

Generated by Claude Code

Every chore repeats, so a due date now only counts alongside a repeat.
The sheet shows the Due date row only once a repeat is set and drops a
leftover date when the repeat is cleared. A row that still has a date
but no repeat (from the web app, say) is treated as undated and timed
from its last log. The one-off rules go: ONE_OFF_EARLY_DAYS, the "done"
badge and the nullable next due date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5p1rGPAJ2yaLENgnsDJUo
README: chores bullet, project map (ChoreSchedule.kt, ChoreLeadStore),
where "Show from" lives, a "When a chore is due" section, and a note to
re-run schema.sql for the new tags columns. In-app help mentions due
dates and the per-chore Show from row. CLAUDE.md gains a map row for the
due-date seam and the facts that every chore repeats and Show from is
on-device.

ChoreLeadStore's string codec moves into a pure ChoreLeadCodec with its
own JVM test (round trip, '|' in a tag id, bad entries, back to Auto).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5p1rGPAJ2yaLENgnsDJUo

mapgie commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the on-device "Show from" store: ChoreLeadStore should be changed to prune entries it no longer owns.

The KDoc says it follows the same scheme as ChoreSnoozeStore, but there's a meaningful difference: ChoreSnoozeStore self-prunes on every read via stillActive(), so stale snoozes drop out on their own. ChoreLeadStore never prunes, so a <tagId>|<days> entry lives forever once written. Today chores are archived rather than hard-deleted and the private/shared moves keep the tag id, so nothing orphans an entry in normal use, but:

  • deleteShared does hard-delete a tags row, and any chore-delete path added later would leak a lead entry with no owner.
  • Tag ids double as NFC ids and are reused across the shared id space, so a stale entry can silently reattach its "Show from" to a different future chore that happens to land on the same id.

Two low-cost options, either is fine:

  1. Reconcile on read. leadDays already flows into ChoreUiState; drop keys that don't match a current chore's tag id when building the map (the way ChoreSnoozeStore drops expired ones). Self-healing, no new call sites.
  2. Prune on removal. Call choreLeadStore.set(tagId, null) wherever a chore is permanently removed or its sticker erased (TagStickerStore already does the erase-time drop for its own store, so there's a pattern to mirror).

Option 1 is the smaller change and keeps the store honest even if a future delete path forgets to clean up. ChoreLeadCodec is already pure and tested, so this is a small addition with a matching test.


Generated by Claude Code

ChoreLeadStore kept a <tagId>|<days> entry forever once written, unlike
ChoreSnoozeStore, which self-prunes stale entries on read. A hard-deleted
chore (or a future delete path) would leave its "show from" behind, and
because a tag id is an NFC id reused across the id space, that setting
could later reattach to a different chore landing on the same id.

ChoreLeadStore.retainOnly drops entries whose tag id is not among the
chores that still exist, reconciled after every successful load against
active + archived (the complete shared and private set). It writes only
when there is something to prune. The pure ChoreLeadCodec.retainOnly is
tested on the JVM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dZB2iMJJxM2AjbJTMEpbu
@mapgie
mapgie marked this pull request as ready for review September 24, 2026 01:13
@mapgie
mapgie merged commit ff14e00 into main Sep 24, 2026
7 checks passed
@mapgie
mapgie deleted the claude/chore-due-dates-repeat-s07lxu branch September 24, 2026 01:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants