From f24af3c5a2b83e805f7624db10568c2c642155f3 Mon Sep 17 00:00:00 2001 From: Dustin Nguyen Date: Wed, 7 Oct 2026 14:29:34 -0400 Subject: [PATCH] Fix recompute_metrics crash on outcome-window reminders recompute_monitoring_metrics failed with "TypeError: '<=' not supported between instances of 'int' and 'NoneType'" in dashboard/data/daily.py _ema_metrics, so no MetricsDaily rows were written for any participant-day that contained an outcome reminder. What went wrong: - Migration 0050 (2026-09-29) made CheckinReminder.daily_count_at_send nullable and added jitai_log. The +60 min outcome check-in reminder (tasks.py) is saved with a jitai_log and no slot, so the column is NULL. - _ema_metrics treated every CheckinReminder as a slot reminder and ran 0 <= index < SCHEDULED_CHECK_IN_DAILY_CAP on the value. The first outcome reminder of a day raised the TypeError and aborted the recompute. - The schema docs still described the column as not null, and the metrics reader was not updated when the writer changed. Fix: _ema_metrics reads only reminders with a slot (daily_count_at_send__isnull=False). Outcome reminders no longer count toward slots_reminded_uncovered, slots_silent or reminders_sent, which describe the slot schedule. The timeline view is unaffected: a NULL slot matches nothing there. Adds a regression test and corrects production_schema.md and data-dictionary.md. After deploy, run `manage.py recompute_metrics --all` to backfill days missed since 2026-09-29; the periodic task only covers the trailing 3 days. --- analytics/analysis-resources/data-dictionary.md | 2 +- analytics/analysis-resources/production_schema.md | 3 ++- dashboard/data/daily.py | 3 ++- dashboard/tests.py | 8 ++++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/analytics/analysis-resources/data-dictionary.md b/analytics/analysis-resources/data-dictionary.md index f84c4d6..0164810 100644 --- a/analytics/analysis-resources/data-dictionary.md +++ b/analytics/analysis-resources/data-dictionary.md @@ -385,7 +385,7 @@ participant has an active JITAI outcome window open. Confirmed by Dr. Chang, 202 | `id` | INT (PK) | Backend / scheduler | Primary key. | | `user_id` | INT (FK → `user.user_id`) | Backend / scheduler | Participant who received the reminder. | | `sent_at` | DATETIME | Backend / scheduler | When the reminder push was sent (`auto_now_add`). | -| `daily_count_at_send` | SMALLINT | Backend / scheduler | **The 0-indexed check-in slot this reminder was for** (0–5). Despite the column name it is not a count of anything. | +| `daily_count_at_send` | SMALLINT, nullable | Backend / scheduler | **The 0-indexed check-in slot this reminder was for** (0–5). Despite the column name it is not a count of anything. **NULL for an outcome-window reminder** (migration `0050`), which is tied to a decision point through `jitai_log_id` instead of a slot; filter on `IS NOT NULL` before treating the value as a slot index. | > **Corrected 2026-09-10.** Earlier revisions described `daily_count_at_send` as the number of > check-ins already submitted that day, and the task as enforcing a 120-minute cooldown. Both diff --git a/analytics/analysis-resources/production_schema.md b/analytics/analysis-resources/production_schema.md index 061ad9a..bbd9128 100644 --- a/analytics/analysis-resources/production_schema.md +++ b/analytics/analysis-resources/production_schema.md @@ -294,7 +294,8 @@ Note: unique/index names on this table are still prefixed `app_wearabledevice_fi |---|---|---|---| | id | bigint | not null | identity | | sent_at | timestamptz | not null | | -| daily_count_at_send | smallint | not null | | +| daily_count_at_send | smallint | null | | +| jitai_log_id | bigint | null | | | user_id | integer | not null | | **PK:** id  ·  **Index:** user_id  ·  **Checks:** daily_count_at_send >= 0 diff --git a/dashboard/data/daily.py b/dashboard/data/daily.py index ebd6ecf..4dc8632 100644 --- a/dashboard/data/daily.py +++ b/dashboard/data/daily.py @@ -148,7 +148,8 @@ def _ema_metrics(user, local_date, day_start, day_end, slots): reminders = list( CheckinReminder.objects - .filter(user=user, sent_at__gte=day_start, sent_at__lt=day_end) + .filter(user=user, sent_at__gte=day_start, sent_at__lt=day_end, + daily_count_at_send__isnull=False) .values_list('daily_count_at_send', 'sent_at') ) reminded = {} diff --git a/dashboard/tests.py b/dashboard/tests.py index c10d753..479d76c 100644 --- a/dashboard/tests.py +++ b/dashboard/tests.py @@ -213,6 +213,14 @@ def test_payload_covers_every_model_column(self): keys = {'study_day', 'local_date', 'is_run_in', 'is_active_day', 'item_bank_version'} self.assertEqual(model_fields, set(METRIC_FIELDS) | keys) + def test_outcome_reminder_without_slot_is_excluded_from_slot_metrics(self): + make_reminder(self.user, 2, self._at(13, 0)) + make_reminder(self.user, None, self._at(15, 0)) + + metrics = self._compute() + self.assertEqual(metrics['slots_reminded_uncovered'], 1) + self.assertEqual(metrics['reminders_sent'], 1) + def test_slot_coverage_and_reminders(self): make_ema(self.user, self._at(10, 15), answers={'B1_valence': 5}) make_ema(self.user, self._at(13, 30), answers={'B1_valence': 3})