From 57866f4cadcc010366021986f5e4b2fcd52bf9c9 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Wed, 27 May 2026 01:11:07 +0300 Subject: [PATCH] ML-198: document timezone plan --- ...ore-user-timezone-implementation-routes.md | 115 +++++++++ backlog/tasks/ml-198 - store-user-timezone.md | 234 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 backlog/docs/doc-28 - ML-198-Research-Store-user-timezone-implementation-routes.md create mode 100644 backlog/tasks/ml-198 - store-user-timezone.md diff --git a/backlog/docs/doc-28 - ML-198-Research-Store-user-timezone-implementation-routes.md b/backlog/docs/doc-28 - ML-198-Research-Store-user-timezone-implementation-routes.md new file mode 100644 index 00000000..28fbc41c --- /dev/null +++ b/backlog/docs/doc-28 - ML-198-Research-Store-user-timezone-implementation-routes.md @@ -0,0 +1,115 @@ +--- +id: doc-28 +title: "ML-198 Research: Store user timezone implementation routes" +type: specification +created_date: "2026-05-26 21:55" +updated_date: "2026-05-26 22:02" +tags: + - research + - timezone + - liveview +--- + +# ML-198 Research: Store user timezone implementation routes + +## Research determination + +This task requires research before planning because there is more than one viable persistence route and the chosen route affects multiple architectural layers: LiveView `on_mount`, application configuration, background workers, API/controller behaviour, database schema design, and tests. + +## Current state + +- The browser sends `Intl.DateTimeFormat().resolvedOptions().timeZone` in LiveSocket connect params from `assets/js/app.js`. +- `MusicLibraryWeb.Hooks.GetTimezone` reads the connect param on each LiveView connection and assigns `:timezone`, falling back to `MusicLibrary.default_timezone/0`. +- The fallback default timezone is configured as `config :music_library, default_timezone: "Europe/London"` and can be overridden by the `DEFAULT_TIMEZONE` runtime environment variable. +- There is no persisted user/account/preferences schema. The app is effectively a single-user authenticated app using a login password, not per-user accounts. +- Existing non-LiveView timezone-sensitive code still uses the default timezone or UTC: + - `MusicLibrary.Worker.SendRecordsOnThisDayEmail` computes today via `MusicLibrary.default_timezone/0`. + - `MusicLibraryWeb.MaintenanceLive.Index` manually sends the records-on-this-day email using `MusicLibrary.default_timezone/0`. + - `MusicLibraryWeb.CollectionController.on_this_day/2` uses `Date.utc_today/0` when the API caller does not pass an explicit date. + - `MusicLibrary.ListeningStats.cutoff_timestamp/2` has a fallback path intended to use the default timezone when no `:timezone` option is supplied. +- `MusicLibrary.Secrets` already provides encrypted key-value storage for credentials such as the Last.fm session key, but timezone is not secret data. +- Oban cron schedule timezone is currently hard-coded in config (`Europe/London`). Storing a timezone can update the date calculation at worker execution time, but it does not automatically make Oban's cron schedule run at 7 AM in the stored timezone. +- Oban OSS `Oban.Plugins.Cron` loads its crontab statically at boot. Runtime, globally coordinated cron updates are an Oban Pro `DynamicCron` feature, so this project should not rely on mutating `Oban.Plugins.Cron` dynamically. + +## Route 1 — Dedicated non-secret settings/preferences context (selected) + +Create a small first-class persistence layer for non-secret application/user preferences. Since the app is single-user, this can be either: + +- a `settings`/`app_settings` key-value table keyed by setting name, or +- a `user_preferences` singleton table with a `timezone` column. + +The context would expose timezone-specific functions such as: + +- `current_timezone/0` — returns stored timezone first, then `MusicLibrary.default_timezone/0`. +- `store_timezone/1` or `update_timezone_if_changed/1` — validates and persists browser-provided timezones. +- Optional helper returning whether the value changed so the LiveView hook can decide whether to show the toast. + +`GetTimezone` would only write when the browser provided a timezone connect param. First browser-provided value is stored silently; later differences update the store and notify with a translated info toast. LiveViews continue assigning `:timezone`, but from the resolved stored value after reconciliation. + +Non-LiveView paths would call the shared resolver instead of reading the environment default directly. + +### Pros + +- Cleanly models timezone as non-secret application/user state rather than credential material. +- Gives all code paths a single resolver that implements "stored first, environment fallback". +- Easy to validate timezone values before writing or using them. +- Keeps future non-secret preferences out of `Secrets`. +- Works for LiveViews, controllers, workers, and tests. + +### Cons + +- Requires a migration, schema/context module, and tests. +- Adds one small persisted table for a single setting unless future preferences are added. +- Needs careful handling of disconnected LiveView mount so the default fallback is not written before the browser timezone arrives. + +## Route 2 — Reuse `MusicLibrary.Secrets` as a persisted key-value store + +Store the timezone under a key such as `user_timezone` or `current_timezone` using the existing encrypted `secrets` table. + +### Pros + +- Minimal implementation: no migration and no new schema/table. +- Existing context already supports `store/2`, `get/1`, and update-on-conflict behaviour. +- Meets the durability requirement for workers and controllers. + +### Cons + +- Semantically wrong: timezone is preference/configuration, not secret credential material. +- Adds unnecessary encryption/decryption overhead to a frequently read value. +- Makes `Secrets` responsible for both credentials and non-sensitive preferences, weakening architectural boundaries documented in `docs/architecture.md`. +- Harder to document and reason about future settings. + +## Route 3 — Store timezone only in the browser session/cookie/localStorage + +Persist the timezone client-side or in the Phoenix session and read it during web requests. + +### Pros + +- No database migration. +- Keeps browser-derived state near the browser. + +### Cons + +- Does not satisfy the requirement for background operations such as the records-on-this-day email worker, because Oban jobs do not have a browser session. +- API callers and other server-side operations may not have access to the browser session. +- Server restarts or cookie/session issues can reintroduce fallback-only behaviour. + +## Route 4 — Runtime dynamic Oban Cron timezone + +Attempt to make the Oban cron plugin's timezone follow the stored timezone so the daily records-on-this-day email runs at the stored local 7 AM rather than at the configured default timezone. + +### Pros + +- Aligns both the email date calculation and the send time with the stored timezone. + +### Cons + +- Oban OSS cron configuration is static at boot; dynamic runtime cron updates are an Oban Pro feature. +- Reconfiguring/restarting cron in the application would be more complex and riskier than necessary for a single daily email. +- Operational behaviour would be harder to verify and roll back. + +## Final selected route + +Use **Route 1** for persistence, plus a **self-scheduled Oban workflow** for the daily digest rather than attempting to mutate `Oban.Plugins.Cron` at runtime. + +The schedule design should remove the static daily `SendRecordsOnThisDayEmail` cron entry and replace it with a scheduler that inserts an Oban job at the next local 7 AM according to the stored timezone. The schedule must be bootstrapped on application startup, rescheduled whenever the stored timezone changes, and rescheduled after each scheduler run. The email-sending job should keep retrying independently from the scheduling job so future daily scheduling is not lost when a Mailgun delivery attempt fails. diff --git a/backlog/tasks/ml-198 - store-user-timezone.md b/backlog/tasks/ml-198 - store-user-timezone.md new file mode 100644 index 00000000..3e6fdba7 --- /dev/null +++ b/backlog/tasks/ml-198 - store-user-timezone.md @@ -0,0 +1,234 @@ +--- +id: ML-198 +title: store user timezone +status: To Do +assignee: [] +created_date: "2026-05-26 21:52" +updated_date: "2026-05-26 22:10" +labels: [] +dependencies: [] +documentation: + - doc-28 - ML-198-Research-Store-user-timezone-implementation-routes.md +modified_files: + - priv/repo/migrations/*_create_settings.exs + - lib/music_library/settings.ex + - lib/music_library/settings/setting.ex + - lib/music_library.ex + - lib/music_library_web/hooks/get_timezone.ex + - lib/music_library/records_on_this_day_schedule.ex + - lib/music_library/worker/schedule_records_on_this_day_email.ex + - lib/music_library/worker/send_records_on_this_day_email.ex + - lib/music_library/application.ex + - lib/music_library/collection.ex + - lib/music_library_web/live/stats_live/index.ex + - lib/music_library_web/live/wishlist_live/index.ex + - lib/music_library_web/live/wishlist_live/show.ex + - lib/music_library_web/live/maintenance_live/index.ex + - lib/music_library_web/controllers/collection_controller.ex + - lib/music_library/listening_stats.ex + - config/config.exs + - config/prod.exs + - test/music_library/settings_test.exs + - test/music_library/records_on_this_day_schedule_test.exs + - test/music_library_web/hooks/get_timezone_test.exs + - test/music_library/worker/schedule_records_on_this_day_email_test.exs + - test/music_library/worker/send_records_on_this_day_email_test.exs + - test/music_library/collection_test.exs + - test/music_library_web/live/stats_live/index_test.exs + - test/music_library_web/live/wishlist_live/index_test.exs + - test/music_library_web/live/wishlist_live/show_test.exs + - test/music_library_web/live/maintenance_live/index_test.exs + - test/music_library_web/controllers/collection_controller_test.exs + - test/music_library/listening_stats_test.exs + - docs/architecture.md + - docs/production-infrastructure.md +ordinal: 31000 +--- + +## Description + + + +Currently the user timezone is resolved via a JS hook with every socket connection, and when needed every LiveView can use it. For other operations (e.g. the records on this day email) the system relies on a default timezone setup via an environment variable. + +This creates issues where changing the timezone after a flight requires updating the environment variable despite the fact that the browser already provided the updated value. + +We should: + +1. Maintain a default timezone as an environment variable as a fallback +2. Store the current user timezone the first time the user visits. +3. On every connection, compare the user timezone with the stored one. If they differ, notify the user with a toast that the timezone has been updated, and store the updated timezone. +4. All code paths that need a timezone can resolve from the stored value first, falling back to the default timezone. + + +## Acceptance Criteria + + + +- [ ] #1 A non-secret persisted settings/preferences store exists for the current timezone; `DEFAULT_TIMEZONE` remains the fallback when no valid stored timezone exists. +- [ ] #2 First connected LiveView visit with a valid browser timezone stores it silently and assigns it to `@timezone`. +- [ ] #3 Subsequent connected LiveView visits compare browser timezone with stored timezone; changes update storage, assign the new value, and show a translated info toast. +- [ ] #4 Missing or invalid browser timezone values do not overwrite a valid stored timezone and do not crash LiveView mounting. +- [ ] #5 Server-side timezone consumers resolve stored timezone first, then fallback default, including records-on-this-day worker/manual send/API default date and ListeningStats fallback. +- [ ] #6 The daily records-on-this-day Oban schedule runs at local 7 AM in the stored timezone, is bootstrapped on app start, and is rescheduled when the stored timezone changes. +- [ ] #7 The email sender job uses an explicit local date and retries independently from the scheduler so future scheduling is not lost on mail delivery failure. +- [ ] #8 Tests cover settings persistence, LiveView hook behavior, scheduler calculations/rescheduling, affected code paths, and invalid timezone handling. +- [ ] #9 Architecture/production docs are updated to reflect the new settings context and dynamic stored-timezone digest scheduling. + + +## Implementation Plan + + + +## Chosen direction + +Use Route 1: add a dedicated non-secret settings/preferences persistence layer for the stored user timezone. Because OSS `Oban.Plugins.Cron` loads cron entries statically at boot and runtime cron timezone updates are an Oban Pro `DynamicCron` feature, make the records-on-this-day digest follow the stored timezone via a self-scheduled Oban workflow instead of trying to mutate the Cron plugin at runtime. + +## Review amendments incorporated + +- Audit every current-date/timezone-sensitive call site, not only the originally listed worker/controller paths. +- Keep `SendRecordsOnThisDayEmail` backward-compatible with already-queued no-arg jobs during rollout. +- Make scheduler uniqueness, replacement, and race/idempotency behaviour explicit and tested. +- Verify scheduler date math across timezones and at least one DST boundary. +- Include full lint validation (`mise run dev:lint`) in addition to targeted tests and `mise run test`. + +## Objective alignment + +- **Default fallback**: keep `MusicLibrary.default_timezone/0` backed by `DEFAULT_TIMEZONE` and add a stored-first resolver, e.g. `MusicLibrary.current_timezone/0`. +- **First browser visit**: when LiveSocket connect params include a valid browser timezone and no timezone is stored, persist it silently and assign it to the socket. +- **Timezone changes**: on each connected LiveView mount, compare the browser timezone with the stored timezone. If it differs, persist the new value, reschedule the daily digest, and show an info toast. +- **All server/user-local date code paths**: replace default-only or UTC-only timezone lookups where the behaviour is intended to be user-local. +- **Oban schedule follows timezone**: schedule the daily digest as an Oban job at the next local 7 AM for the stored timezone, rescheduling on startup, after each scheduler run, and whenever the stored timezone changes. + +## Simplicity and alternatives considered + +The simplest viable approach is a small `settings`/`preferences` table plus a self-scheduled Oban scheduler job. This is simpler and safer than dynamic Cron reconfiguration because it uses existing Oban scheduled jobs and avoids Oban Pro-only capabilities. + +Rejected/deferred alternatives: + +1. **Reuse `MusicLibrary.Secrets`**: faster to implement but semantically wrong because timezone is not secret data and should not live in the encrypted credentials context. +2. **Browser/session-only storage**: does not work for Oban workers or API/controller paths that run without a browser session. +3. **Runtime mutation of `Oban.Plugins.Cron`**: not supported by OSS Oban Cron; would add operational risk. Self-scheduled jobs satisfy the same local-time requirement with less complexity. + +## Implementation steps and verification + +1. **Add non-secret settings persistence** + - Add a migration for a small settings/preferences table. + - Prefer project consistency: use a normal `binary_id` primary key, a non-null string `key`, a non-null string `value`, timestamps, and a unique index on `key`. If the implementation chooses `key` as the primary key instead, document this as an intentional exception to the current binary-id schema convention in `docs/architecture.md`. + - Add `MusicLibrary.Settings.Setting` schema and `MusicLibrary.Settings` context. + - Add timezone-specific functions such as `current_timezone/0`, `stored_timezone/0`, and `update_timezone_if_changed/1`. + - Make `update_timezone_if_changed/1` return explicit outcomes, e.g. `{:ok, :stored}`, `{:ok, :unchanged}`, `{:ok, :changed}`, or `{:error, :invalid_timezone}`, so callers only reschedule/show toasts for real changes. + - Validate browser-provided timezones against the configured timezone database before persisting. + - Ensure invalid/corrupt stored values are ignored by resolver functions and never crash callers. + - Verification before continuing: run the migration up/down locally, add context tests proving fallback, first-store, unchanged, changed, corrupt stored value, and invalid-timezone behaviour. + +2. **Add a stored-first timezone resolver** + - Keep `MusicLibrary.default_timezone/0` as the environment fallback only. + - Add `MusicLibrary.current_timezone/0` that delegates at runtime to `MusicLibrary.Settings.current_timezone/0`, returning the stored timezone first and falling back to `default_timezone/0`. + - Avoid compile-time cyclic dependencies: `MusicLibrary.current_timezone/0` should be a runtime delegation/helper only. + - Ensure invalid/corrupt stored values cannot crash callers; fall back to the default and log a warning if useful. + - Verification before continuing: unit tests assert resolver behaviour with missing, valid, and invalid stored values. + +3. **Update `MusicLibraryWeb.Hooks.GetTimezone`** + - On disconnected mounts or missing connect params, assign `MusicLibrary.current_timezone/0` and do not write anything. + - On connected mounts with a valid browser timezone: + - store silently if this is the first persisted timezone; + - do nothing if unchanged; + - update, reschedule the digest, and show a translated info toast if changed. + - On invalid browser timezone, keep the existing stored/default timezone and do not overwrite it. + - Confirm that the toast path works with the existing `on_mount` order (`GetTimezone` runs before `ShowToast`). If messages sent during `GetTimezone` are not picked up reliably, adjust hook ordering or use a direct socket-based toast path. + - Verification before continuing: update `test/music_library_web/hooks/get_timezone_test.exs` to cover no param, first store, unchanged value, changed value with toast, invalid value, and duplicate/concurrent mount safety where practical. + +4. **Introduce self-scheduled records-on-this-day scheduling** + - Add a scheduler/context module, e.g. `MusicLibrary.RecordsOnThisDaySchedule`, that computes the next local run at configured send time (`07:00`) in `MusicLibrary.current_timezone/0`. + - Move the daily send time into config rather than hard-coding another magic number. + - Make schedule calculation injectable/testable with an explicit `now` option or helper so tests do not depend on wall-clock time. + - Add `MusicLibrary.Worker.ScheduleRecordsOnThisDayEmail` as a thin Oban worker that delegates to the scheduler/context: enqueue the email-sending job for the intended local date, then ensure the next scheduler job exists. + - Update `MusicLibrary.Worker.SendRecordsOnThisDayEmail` to accept an explicit ISO date in args and send for that deterministic local date; keep retries independent from the scheduler. + - Keep a backward-compatible `perform/1` clause for empty/no-date args during rollout. It should compute the date using `MusicLibrary.current_timezone/0`, send once, and allow existing queued/retryable jobs from the previous implementation to complete safely. + - Ensure scheduler jobs are unique/rescheduled so only one future scheduler job exists. Cancel/replace only future scheduled scheduler jobs, not executing/retryable scheduler jobs and not email-send jobs. + - Ensure repeated `ensure_scheduled/0` calls are idempotent and leave exactly one future scheduler job. + - Verification before continuing: worker/scheduler tests cover next-run calculation before/after 7 AM in multiple timezones, at least one DST boundary, repeated `ensure_scheduled/0`, scheduler `perform/1` enqueuing the send worker and scheduling the next run, no-arg send-worker compatibility, and send worker retries independently on mailer failure. + +5. **Bootstrap and reschedule Oban scheduling** + - Remove the static daily `SendRecordsOnThisDayEmail` entry from `Oban.Plugins.Cron`; keep other Cron jobs unchanged. + - Add a small supervised bootstrap process after the repos, Oban, and migrations start. It should call the scheduler’s `ensure_scheduled/0`, log failures, and avoid crashing the app if scheduling temporarily fails. + - Call the same reschedule function after a stored timezone change in `GetTimezone`. + - Handle multi-tab/multi-LiveView races by relying on the explicit `Settings.update_timezone_if_changed/1` outcome and scheduler idempotency. + - Verification before continuing: tests or targeted assertions prove startup bootstrap calls scheduling logic; Oban test assertions prove changing timezone replaces the future scheduler job with one scheduled for the new local timezone and does not create duplicates after repeated changes/ensures. + +6. **Audit and replace default-only or UTC-only timezone call sites** + - Audit all current call sites with `rg "default_timezone|Date\\.utc_today|DateTime\\.utc_now|DateTime\\.now!" lib test config docs` before implementation and again before final validation. + - Update the records-on-this-day worker/scheduler, `MaintenanceLive` manual send path, and `CollectionController.on_this_day/2` default date path to use `MusicLibrary.current_timezone/0`. + - Fix `ListeningStats` fallback to use a lazy stored-first resolver when no explicit `:timezone` option is supplied. + - Review `Collection.get_records_on_this_day/1` and its default argument. Prefer explicit dates at call sites; if the default remains, make sure it uses the stored/current timezone rather than UTC when the semantics are user-local. + - Review LiveViews that derive a current date for user-facing record status or “today” behaviour, including `StatsLive.Index`, `WishlistLive.Index`, and `WishlistLive.Show`; use `socket.assigns.timezone` or `MusicLibrary.current_timezone/0` as appropriate. + - Keep LiveViews/components that already use `socket.assigns.timezone` as-is after the hook assigns the stored/current value. + - Verification before continuing: add/update tests proving manual send and API default date use the stored timezone; add `ListeningStats` coverage for the no-option fallback path; update affected Stats/Wishlist tests for local-date semantics or explicitly document any UTC semantics that remain intentional. + +7. **Update user-visible text and UI checks** + - Add gettext-wrapped toast text such as “Timezone updated to %{timezone}.” + - If the Maintenance page displays timezone information, make sure it distinguishes current stored timezone from the default fallback accurately. + - Verification before continuing: run gettext extraction/checks as needed and assert the toast/maintenance text in tests. + +8. **Documentation and final validation** + - Update architecture and production docs listed below. + - Run targeted tests first, then project validation: + - `mix test test/music_library/settings_test.exs` + - `mix test test/music_library_web/hooks/get_timezone_test.exs` + - `mix test test/music_library/records_on_this_day_schedule_test.exs` + - `mix test test/music_library/worker/schedule_records_on_this_day_email_test.exs` + - `mix test test/music_library/worker/send_records_on_this_day_email_test.exs` + - impacted controller/live/context tests + - `mix format --check-formatted` + - `mix gettext.extract --check-up-to-date` + - `mise run dev:lint` + - `mise run test` + - Verification before finishing: all targeted tests, lint, and full project test task pass. + +## Architecture impact analysis + +- **Schemas/tables**: add a non-secret settings/preferences schema and table in `MusicLibrary.Repo`, with a unique/indexed setting key. Prefer preserving the project-wide `binary_id` primary-key convention; document any deliberate exception. +- **Contexts**: add `MusicLibrary.Settings`; add or update a records-on-this-day scheduler/context module for date/schedule calculations. +- **Web hooks/UI**: update `MusicLibraryWeb.Hooks.GetTimezone` to persist/compare browser timezones and emit a toast on changes. +- **Workers/Oban**: add scheduler worker; update email sender worker to accept explicit dates while preserving no-arg compatibility during rollout; remove the static daily Cron entry; keep other Cron jobs unchanged. +- **Supervision tree**: add a lightweight scheduling bootstrap child after repos/Oban/migrations. +- **Controllers/routes**: no route changes; `CollectionController.on_this_day/2` default-date behaviour changes to use stored timezone. +- **LiveViews**: update user-local current-date derivation where currently UTC-only (`StatsLive`, `WishlistLive`) or document intentional UTC semantics. +- **PubSub topics**: no new topics expected. +- **External APIs/services**: no new external API integrations; Mailgun usage remains the existing one-email-per-day digest path. +- **Migrations/deprecation**: migration is additive. Existing `DEFAULT_TIMEZONE` remains supported as fallback; no user-facing deprecation needed. + +## Performance profile + +- Settings reads/writes are O(1)-style indexed lookups against a single-row/small table. +- Each connected LiveView mount adds at most one settings read and only writes when the browser timezone is first seen or changes. +- Disconnected mounts may also read the stored/current timezone to assign a fallback; this is still a single indexed lookup and acceptable for this single-user app. +- Scheduler operations are constant-size: compute next run, cancel/replace at most one future scheduler job, insert one scheduled job. +- Scheduler uniqueness checks query Oban’s job table by worker/state/args and should remain bounded by the one-scheduler-job invariant; tests should assert duplicate jobs are not produced. +- No N+1 query risk is introduced; no collection/list queries are changed except current-date inputs. +- Memory footprint is negligible; no long-lived large state is required. +- Latency impact is expected to be minimal. Timezone writes happen only on first visit or timezone change. + +## Benchmarking requirements + +No ongoing benchmark is required because all new work is constant-time/single-row settings access plus one scheduled Oban job per day. If implementation reveals unexpected mount latency or Oban query cost, perform a one-off QueryReporter/SQL check and verify settings lookup/reschedule operations remain below 10 ms locally and do not scan large application tables. + +## Cost profile + +No new paid resources are introduced. Mailgun cost remains unchanged because the digest is still one scheduled email per day. Storage cost is negligible: one settings row plus one scheduled Oban job. Compute impact is negligible. + +## Production Changes + +- **Rollout**: standard deployment only. Coolify’s existing post-deploy migration step creates the settings table. No new environment variables are required. `DEFAULT_TIMEZONE` remains the fallback. +- **Initial state**: before the first browser visit stores a timezone, the scheduler uses `DEFAULT_TIMEZONE`. After the browser provides a timezone, the app stores it and reschedules the next digest for that timezone’s local 7 AM. +- **Queued job compatibility**: `SendRecordsOnThisDayEmail` must keep a no-arg compatibility clause for at least this deployment so pre-existing queued/retryable jobs from the old static Cron implementation do not crash. After a later release confirms no old jobs remain, this compatibility path may be removed in a separate cleanup task if desired. +- **Verification after deploy**: confirm a settings row appears after login, confirm the Maintenance page/current timezone is correct, and verify one future `ScheduleRecordsOnThisDayEmail` job exists in Oban for the expected local run time. +- **Rollback**: redeploy the previous version. The additive settings table can remain. If rolling back after a scheduler job has been inserted, cancel future `ScheduleRecordsOnThisDayEmail` jobs through Oban Web or an approved console action so old code does not execute a removed worker; the old static Cron schedule will resume from the rolled-back config. + +## Documentation updates + +- `docs/architecture.md`: add the Settings schema/context, scheduler worker, bootstrap supervision child, and update the Cron Workers section to describe dynamic stored-timezone scheduling for records-on-this-day. Also document any intentional settings primary-key convention exception if one is introduced. +- `docs/production-infrastructure.md`: update the Oban/daily digest description to explain that `DEFAULT_TIMEZONE` is fallback only and the persisted timezone controls the digest schedule after first browser visit, including rollout compatibility for old no-arg jobs. +- `docs/project-conventions.md`: update only if the implementation establishes a reusable convention for non-secret settings/preferences; otherwise no change needed. +