0% прочитано

Відкриття панелі нотифікацій ще не означає, що користувач прочитав кожну нотифікацію

Notification analytics легко зіпсувати одним неправильним припущенням: якщо користувач відкрив bell dropdown, це ще не означає, що він побачив або прочитав кожне повідомлення. Потрібно окремо визначити shown, dismissed, read, acknowledged і clicked.

4 листопада 2026 р. 9 хв читанняЛаравель

У notification center є проста, але небезпечна спокуса: користувач відкрив панель нотифікацій - значить прочитав усе, що там було.

Для analytics це неправильна модель. Bell dropdown може містити десять повідомлень, але користувач міг лише швидко відкрити його, побачити верхній рядок і закрити. Якщо в цей момент виставити read_at усім записам, read rate стане красивим, але неправдивим.

У нашому Notification Center ми розділили кілька різних моментів: notification могла бути створена, показана, закрита, явно прочитана, підтверджена або клікнута. Це різні події і вони повинні мати різну семантику.

Сам факт відкриття bell dropdown означає лише, що користувач відкрив контейнер зі списком. Він не доводить, що конкретна notification була повністю показана або прочитана.

Тому dropdown open не повинен масово оновлювати read_at і не повинен автоматично змінювати analytics для всіх елементів списку.

Dropdown open не має масово ставити read_at
public function openDropdown(): void{    // Load notifications for display.     // Do NOT:    // Notification::query()    //     ->whereNull('read_at')    //     ->update(['read_at' => now()]);}

Shown означає, що UI справді показав notification

Для modal notification ми окремо фіксуємо shown_at лише тоді, коли modal справді змонтований і показаний користувачу.

Це корисна подія для UI і badge: notification вже не нова і не "not yet viewed". Але shown_at все одно не є доказом людського прочитання.

shown_at записується один раз
if ($notification->shown_at === null) {    $notification->forceFill([        'shown_at' => now(),    ])->save();}

Dismissed теж не означає read

Користувач може закрити modal через Escape або backdrop, не прочитавши текст. Тому dismissal має власний timestamp dismissed_at.

Важливо не перетворювати закриття на автоматичний read_at. Інакше analytics знову змішає "прибрав з екрана" і "прочитав".

Dismiss без автоматичного read
$notification->forceFill([    'dismissed_at' =>        $notification->dismissed_at            ?? now(),])->save(); // read_at stays unchanged.

Read має бути прив’язаний до визначеної дії

read_at має означати конкретну, документовану дію. Найнадійніший варіант - explicit Mark as read, "Я прочитав" або інша узгоджена дія, яка справді підтверджує ознайомлення.

Головне правило: timestamp не повинен з’являтися через випадковий UI side effect, наприклад відкриття dropdown.

Idempotent explicit read
public function markAsRead(    Notification $notification,): void {    if ($notification->read_at !== null) {        return;    }     $notification->forceFill([        'read_at' => now(),    ])->save();}

Acknowledged сильніше за viewed

Для modal notification у нас є ще один окремий стан: acknowledged_at. Наприклад, користувач натискає кнопку "Зрозуміло".

У такій моделі acknowledgement може одночасно встановити read_at, тому що дія явно означає, що користувач підтвердив notification. Але навіть тут shown_at і acknowledged_at залишаються різними timestamps.

Explicit acknowledgement
$now = now(); $notification->forceFill([    'read_at' =>        $notification->read_at            ?? $now,    'acknowledged_at' =>        $notification->acknowledged_at            ?? $now,])->save();

CTA click теж має окрему семантику

Клік по CTA означає, що користувач виконав конкретну дію з notification. Це варто фіксувати через clicked_at.

У нашій моделі CTA може вважатися достатньою дією для read_at, але він не є автоматичним acknowledgement. Notification може залишатися unconfirmed до окремої кнопки підтвердження.

Клік також не означає completion. Перехід на сторінку і фактичне завершення downstream action - різні події.

Badge має рахувати new, а не unread

Якщо badge використовує лише read_at IS NULL, він змішує кілька різних станів.

У нашій UX-моделі bell badge рахує notification, які ще не були реально показані як modal - тобто немає shown_at. Після першого показу badge зменшується, навіть якщо notification ще не acknowledged.

Badge рахує not yet viewed
$unviewedCount = Notification::query()    ->whereNull('shown_at')    ->count();
Для non-modal notification потрібна окрема визначена viewed semantics. Не можна автоматично переносити modal shown_at правило на простий список у dropdown.

Після показу modal notification вже не new, але користувач може ще не підтвердити її. Тому вона залишається в active dropdown до acknowledged_at.

Це інша query boundary, ніж badge. Badge відповідає на "що ще не показано?", а dropdown - "що ще не підтверджено?".

Active dropdown = unconfirmed
$activeDropdown = Notification::query()    ->whereNull('acknowledged_at')    ->latest()    ->get();

Аналітика повинна зберігати всі стани окремо

Для нормальної notification analytics я не зводжу все до однієї колонки read/unread. Мінімально корисні окремі timestamps:

  • shown_at - UI реально показав notification;
  • dismissed_at - користувач закрив її без підтвердження;
  • read_at - відбулася визначена дія читання;
  • acknowledged_at - користувач явно підтвердив notification;
  • clicked_at - користувач натиснув CTA;

Ці timestamps дозволяють рахувати різні funnels без вигаданих висновків про поведінку користувача.

Read rate потребує точного denominator

Навіть після точного read_at потрібно пояснювати denominator. Read rate від targeted recipients, delivered database notifications чи shown notifications - це різні метрики.

Якщо UI не може надійно визначити читання для конкретного channel, краще мати unknown/read-unverifiable, а не автоматично класифікувати запис як unread або read.

Email open не доводить людське читання

Те саме правило стосується email. Provider delivery acknowledgement означає доставку до каналу, а open pixel може спрацювати автоматично або через image proxy.

Тому email delivery, email open і explicit in-app read не можна складати в одну метрику "прочитано".

Події мають бути idempotent

Realtime reconnect, повторний click або повторний API request не повинні змінювати перший момент read. Якщо read_at вже є, повторна дія не створює новий read event і не пересуває timestamp вперед.

Monotonic read_at
$notification->forceFill([    'read_at' =>        $notification->read_at            ?? now(),])->save();

Які сценарії тестувати

  • відкриття bell dropdown - не ставить read_at;
  • перший modal mount - ставить shown_at один раз;
  • Escape/backdrop - ставить dismissed_at, але не read_at;
  • Mark as read - ставить read_at один раз;
  • "Зрозуміло" - ставить acknowledged_at і, за контрактом, read_at;
  • CTA - ставить clicked_at, але не автоматичний acknowledged_at;
  • refresh/reconnect - не повертає viewed notification у badge;
  • повторний read/click event - не псує timestamps і counters;
  • legacy records не переписуються заднім числом як explicit acknowledgement.

Аналітика повинна фіксувати те, що система справді спостерігала, а не те, що ми хочемо припустити про поведінку користувача.

Підсумок

Відкриття notification panel не є моментом read. Показ modal теж не є read. Dismissal не є read, а CTA click не обов’язково означає acknowledgement або completion.

Чим точніше ми розділяємо shown_at, dismissed_at, read_at, acknowledged_at і clicked_at, тим чеснішою стає notification analytics і тим простіше будувати правильні badge, dropdown та engagement metrics.