Skip to content

feat: expand habit schedule model (ORB-221, step 1 of 3) - #792

Merged
thomasluizon merged 5 commits into
mainfrom
thomasluizon/ticket-227-habit-schedule
Oct 19, 2026
Merged

thomasluizon merged 5 commits into
mainfrom
thomasluizon/ticket-227-habit-schedule

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Logging advances DueDate, so it cannot retain the initial date of a habit. This adds a persisted, immutable StartDate and the four-value HabitSchedule enum alongside the legacy fields.

This is step 1 of 3 for thomasluizon/orbit-tickets#227; keep the ticket open after this pull request.

Approach

  • src/Orbit.Domain/Entities/Habit.cs and Enums/HabitSchedule.cs derive Schedule on creation and every successful update. Existing guards, flags, scheduling behavior, and DueDate remain in use. StartDate captures the creation input and survives logging, unlogging, catch-up, postponement, skipping, and updates.
  • HabitScheduleSnapshot, its projection, and candidate hydration carry both persisted values through the existing read paths. ScheduledStartDate keeps its separate recurrence-anchor role.
  • OrbitDbContext and ExpandHabitScheduleAndStartDate add the columns with defaults suitable for older API processes. The migration locks habit writes, counts all four invalid legacy combinations, and raises an exception with the count before changing rows. It derives General, Flexible, OneTime, then Recurring in that order. Existing start dates are approximations: earliest non-deleted log date, otherwise the UTC creation date, capped at the current due date. The backfill also sets UpdatedAtUtc, so delta sync resends schedule and startDate for every existing habit once. A compatibility trigger keeps old API writers consistent, initializes their start date, rejects invalid combinations, and preserves the stored start on updates. Down removes the trigger and added columns; the legacy flags already remain intact.
  • The habit detail, full detail, schedule, calendar, nested child, and sync response mappings append optional schedule and startDate. Existing isGeneral, isFlexible, and dueDate stay unchanged. Create/update requests stay unchanged. Response annotations expose the additions in src/Orbit.Api/openapi.json and openapi_v2.json, with the new schedule documented as the string enum the API sends (JsonStringEnumConverter). FrequencyUnit keeps main's document: requests still accept integers, so a string-only enum would narrow the request contract (the OpenAPI Breaking-Change Gate rejects it); its exact document is step 2; generated migration files stay with the model change.
  • StartDate is the creation anchor only: the date the habit was created to start. It stays fixed when DueDate moves, including to an earlier date; the DueDate clamp applies to the backfill only. A user-edited start date is step 2. The schedule read projection no longer assigns StartDate or Schedule on tracked entities.

Steps remaining on this ticket:

  1. Migrate readers to the enum and separate the mutable NextDueDate property/column while retaining JSON dueDate.
  2. Remove the independent domain flags once readers have moved; continue deriving the legacy DTO fields for shipped clients. Contract contraction remains a later fleet-gated change.

The UI/shared-schema follow-up belongs to its separate ticket after this merges. This PR does not change MinSupportedVersion.

Test evidence

Characterizations were committed first in 49cbc0e6, before production changes, and remain unchanged in the implementation commit. They cover all four types in the schedule service, metrics, query, and user streak service, including a Sao Paulo timezone boundary, plus log/unlog, skip, catch-up, and postponement outcomes.

Observed regression sequence:

  1. dotnet test tests/Orbit.Domain.Tests --no-restore --filter FullyQualifiedName~Log_Recurring_AdvancesDueDate: the existing unchanged test passed with no StartDate field.
  2. dotnet test tests/Orbit.Domain.Tests --no-restore --filter FullyQualifiedName~LogTenTimes_PreservesOriginalStartDate: the new test logged ten times, checked the advanced due date, and failed because the original-start property was absent. Reflection let this assertion run against the original model; the final test uses the typed property.
  3. After implementation, that test passes in the focused and full domain suites.

The first full infrastructure run caught a stale schema-preservation inventory (10 differences versus the new 12). FeatureFlagAccountsMigrationTests now explicitly verifies both added columns and the appended migration; its focused rerun passes.

Focused checks cover all type transitions, retained flags when frequency changes, rejected updates, the unchanged Days restriction, existing validator/command inputs, snapshots, actual response producers and serialized legacy fields, optional v1/v2 schemas, and migration backfill/guard/rollback operations.

The migration backfill and invalid-row count query execute against SQLite with only PostgreSQL function syntax adapted. The PostgreSQL exception block and compatibility trigger ran against a real PostgreSQL 17 database in the review round below. No database harness was added to the repository.

Tool evidence: dotnet ef migrations add ExpandHabitScheduleAndStartDate --project ../Orbit.Infrastructure --startup-project . --verbose reported Using DbContext factory 'OrbitDbContextFactory'. dotnet ef migrations script 20261019091925_AddOwedSubscriptionCancellations 20261019165331_ExpandHabitScheduleAndStartDate --project ../Orbit.Infrastructure --startup-project . --output <temporary SQL file> exited 0 and emitted the transaction, preflight guard, date default, backfill, and trigger. These commands ran from src/Orbit.Api without connecting to a live database.

Final validation (all exit 0):

  • dotnet build Orbit.slnx: 0 errors, 19 warnings.
  • Focused schedule/expansion suite: 50 passed.
  • dotnet test tests/Orbit.Domain.Tests --no-build: 749 passed.
  • dotnet test tests/Orbit.Application.Tests --no-build: 5,199 passed.
  • dotnet test tests/Orbit.Infrastructure.Tests --no-build: 3,741 passed after updating the schema inventory.
  • dotnet test tests/Orbit.Analyzers.Tests --no-build: 32 passed.
  • 9,721 tests passed across the four full suites. Git hooks, timeless text, dash checks, and whitespace checks pass.

Review round 1

Commit ce5eefd0.

All commands below exited 0:

Command Result
dotnet build Orbit.slnx --nologo 0 errors
dotnet test tests/Orbit.Domain.Tests --nologo 767 passed
dotnet test tests/Orbit.Application.Tests --nologo 5,203 passed
dotnet test tests/Orbit.Infrastructure.Tests --nologo 3,746 passed

Before implementation changes, the unchanged tests passed with the defects present: 19 passed, exit 0.

dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~HabitScheduleContractTests|FullyQualifiedName~HabitScheduleReadShapeTests|FullyQualifiedName~ExpandHabitScheduleAndStartDateMigrationTests' --nologo

The strengthened tests then produced 6 intended failures, exit 1: four integer-schema mismatches, overwritten tracked StartDate, and unchanged backfill timestamp.

dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~OpenApi_ScheduleEnumsMatchSerializedNames|FullyQualifiedName~ScheduleQuery_DivergentProjectionDoesNotOverwriteTrackedStartDateOrSchedule|FullyQualifiedName~Backfill_UsesEarliestActiveLogElseUtcCreationAndCapsAtDueDate' --nologo

All six pass after the fixes, including in the full infrastructure run above.

The identical expanded characterisation files also passed in a scratch checkout of origin/main (7c793090), both exit 0:

dotnet test tests/Orbit.Domain.Tests --filter 'FullyQualifiedName~HabitScheduleCharacterizationTests' --nologo
dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~HabitScheduleCharacterizationTests' --nologo

Results: 21 domain cases, 8 application cases. Commit hooks passed.

CI fix round 1

Commit f7c503fe. The string enum is limited to HabitSchedule; the FrequencyUnit schema diff against origin/main is empty in both OpenAPI files.

  • Existing test, defect present: dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~OpenApi_ScheduleEnumsMatchSerializedNames' --no-restore --verbosity quiet exited 0, four passed.
  • Strengthened test, before fix: dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~OpenApi_HabitScheduleMatchesSerializedNamesAndFrequencyUnitPreservesMainSchema' --no-restore --verbosity quiet exited 1, both cases failed on the FrequencyUnit baseline assertion.
  • After fix: dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~HabitScheduleContractTests' --no-build --verbosity normal exited 0, eight passed.
  • dotnet build Orbit.slnx --verbosity quiet: 0, zero errors.
  • dotnet test tests/Orbit.Domain.Tests --no-restore --verbosity quiet: 0, 767 passed.
  • dotnet test tests/Orbit.Application.Tests --no-restore --verbosity quiet: 0, 5,203 passed.
  • dotnet test tests/Orbit.Infrastructure.Tests --no-restore --verbosity quiet: 0, 3,744 passed.
  • All pre-commit hooks passed.

PostgreSQL 17 proof

Run against a throwaway local postgres:17 container, removed afterwards. The database was migrated to 20261019091925_AddOwedSubscriptionCancellations with dotnet ef database update, seeded with one habit of each type (and, for the recurring habit, one active log on 2026-09-05 and one deleted log on 2026-09-02), then migrated Up to 20261019165331_ExpandHabitScheduleAndStartDate (exit 0).

Check Observed
Backfill, one-time (created 2026-10-01, due 2026-10-25, no log) StartDate 2026-10-01, Schedule 0
Backfill, recurring (earliest active log 2026-09-05, deleted log ignored) StartDate 2026-09-05, Schedule 1
Backfill, flexible (created 2026-10-19 02:30 UTC, due 2026-10-19) StartDate 2026-10-19, Schedule 2
Backfill, general (created 2026-10-15, due 2026-10-10) StartDate 2026-10-10 (capped at DueDate), Schedule 3
UpdatedAtUtc after the backfill bumped on all four rows
INSERT from a legacy writer that omits the new columns StartDate = DueDate (2026-11-01), Schedule 1
INSERT with an explicit StartDate 2026-10-05 and a wrong Schedule 3 StartDate 2026-10-05 kept, Schedule derived as 0
UPDATE StartDate to 2020-01-01 with DueDate 2026-10-21 StartDate stays 2026-09-05, DueDate 2026-10-21
UPDATE Schedule to 3 on the recurring habit Schedule stays 1
UPDATE IsGeneral to true on the one-time habit Schedule becomes 3
UPDATE a general habit with FrequencyUnit 0; INSERT a flexible habit with no FrequencyUnit both rejected by the trigger guard (RAISE in sync_habit_schedule_expand()); row count unchanged
Down to the previous migration exit 0; Schedule and StartDate columns, the trigger and the function removed; all rows kept
Up on a second database holding one general habit with a FrequencyUnit exit 1, Habit schedule expansion aborted: 1 invalid legacy rows; no column added, migration not recorded

Assumptions

  • StartDate captures the validated date passed to Habit.Create, retaining existing creation-date normalization rather than changing request semantics in the expand step.
  • Creation timestamps are converted explicitly to UTC before taking their date for backfill, rather than relying on the database session timezone.
  • Response annotations are included so both generated API contracts describe the optional additions, rather than leaving the affected responses without schemas.
  • Reused the existing schema-transformer callback instead of adding another transformer.
  • Exercised CannotSkipOneTimeTask through a legacy snapshot instead of weakening factory validation.

Manual steps

  • A merge to main deploys nothing. Before a production release, obtain the owner's explicit yes and take a backup of the production Render Postgres database. Record its reference before running the migration.
  • With a read-only query on the production Render Postgres database, record total/active habit counts and the invalid-row count before release. The invalid count must be zero; investigate any nonzero result instead of repairing or bucketing rows implicitly:
SELECT COUNT(*) AS total_habits,
       COUNT(*) FILTER (WHERE NOT "IsDeleted") AS active_habits
FROM "Habits";

SELECT COUNT(*) AS invalid_habits
FROM "Habits"
WHERE ("IsGeneral" AND ("FrequencyUnit" IS NOT NULL OR "FrequencyQuantity" IS NOT NULL))
   OR ("IsGeneral" AND "IsBadHabit")
   OR ("IsFlexible" AND "FrequencyUnit" IS NULL)
   OR ("IsFlexible" AND cardinality("Days") > 0);
  • After approval and snapshot, release through GitHub Actions, Release API (.github/workflows/release.yml), with environment=production and branch=main. Retain the migration's backfilled-row count and verify the released commit through that workflow's health check.
  • Immediately after migration, run this read-only check on the same database and record a zero result (the StartDate > DueDate term holds for the backfill only; DueDate may later move before the creation anchor):
SELECT COUNT(*) AS invalid_expanded_habits
FROM "Habits"
WHERE NOT "IsDeleted"
  AND ("StartDate" IS NULL OR "StartDate" > "DueDate"
       OR "Schedule" IS NULL
       OR "Schedule" <> CASE WHEN "IsGeneral" THEN 3
                            WHEN "IsFlexible" THEN 2
                            WHEN "FrequencyUnit" IS NULL THEN 0 ELSE 1 END);
  • Before using Down, drain API processes that depend on the new columns. Rollback preserves legacy flags and due dates but drops StartDate; a later reapply recomputes its approximation.

pullfrog[bot]
pullfrog Bot previously approved these changes Oct 19, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes Reviewed the schedule-model expansion, persistence rollout, response mappings, and compatibility with the current orbit-ui-mobile consumer; no Critical or High findings were identified.

  • Domain model: Adds derived HabitSchedule and immutable original StartDate, preserving legacy scheduling behavior and the separate recurrence anchor.
  • Persistence: Adds the columns, guarded historical backfill, legacy-writer compatibility trigger, rollback operations, and updated EF model snapshots.
  • API contracts: Appends optional fields to detail, schedule, calendar, nested-child, and sync responses, with response annotations and generated contracts for both API versions; existing requests and legacy fields remain intact.
  • Verification: Reviewed the characterization and regression coverage and ran 204 relevant unit tests, all passing. PostgreSQL trigger execution remains unverified against PostgreSQL; the migration tests exercise adapted backfill queries and inspect generated trigger SQL.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

pullfrog[bot]
pullfrog Bot previously approved these changes Oct 19, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new Critical or High issues found.

Reviewed changes Reviewed ce5eefd0 since the prior Pullfrog review at 22b704ab, with the full PR diff as context.

  • Corrected enum schemas: Documented HabitSchedule and FrequencyUnit as named strings in both OpenAPI versions, matching existing serialization and the current consumer.
  • Preserved tracked values: Removed StartDate and Schedule assignments from read-field restoration and added a divergent-projection regression test.
  • Enabled backfill synchronization: Updated existing habits' UpdatedAtUtc during backfill so delta sync resends the expanded fields.
  • Expanded behavioral coverage: Added four-type mutation and metrics characterizations, earlier-date anchor tests, and schema/backfill assertions. All 73 focused tests passed; the working tree is clean.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new Critical or High issues found.

Reviewed changes Reviewed f7c503fe since the prior Pullfrog review at ce5eefd0, with the full PR diff and current consumer schemas as context.

  • Restored schema compatibility: Restricted the string-enum transformer to HabitSchedule and restored FrequencyUnit to its exact base schema in both OpenAPI versions, without changing runtime serialization.
  • Strengthened contract coverage: Retained assertions for the new schedule enum and added complete FrequencyUnit baseline assertions; all eight focused contract tests passed, and the working tree remains clean.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit 7a708b5 into main Oct 19, 2026
25 checks passed
@thomasluizon
thomasluizon deleted the thomasluizon/ticket-227-habit-schedule branch October 19, 2026 19:03
thomasluizon added a commit that referenced this pull request Oct 19, 2026
…uppression cleanup (#746) (#793)

* feat: expand habit schedule model (ORB-221, step 1 of 3) (#792)

* test: characterize habit schedules before expand step (ORB-221)

* feat: expand habit schedule and immutable start date (ORB-221)

* test: include habit expansion in schema preservation guard

* fix: address habit schedule review (thomasluizon/orbit-tickets#227)

* fix: preserve FrequencyUnit OpenAPI contract (ORB-221)

(cherry picked from commit 7a708b5)

* Clear handwritten suppressions and audit retained exemptions (ORB-224) (#791)

* fix: recognize UTC timestamp sinks without date exemptions

* fix: guard generated logging arguments and remove blanket disables

* refactor: remove catalog suppression scaffolding without changing metadata

* fix: forward cancellation and remove obsolete library suppressions

* chore: record every suppression verdict and reject duplicate reasons

* fix: address suppression audit review for ticket 230

* fix: resolve suppression cleanup CI findings

Refs thomasluizon/orbit-tickets#230

* test: prove OAuth authorize query validation parity

(cherry picked from commit 05ab55f)
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.

1 participant