Add the Recent updates sheet behind the Home bell (Feature 21)
GET /api/v1/updates lists the last 10 distinct notification events from
the outbox, newest first and one per event key whatever its channels.
POST /api/v1/updates/seen {"through": created_at} moves
household_settings.updates_seen_at forward, so /api/v1/home's
recentUpdates.unread is true only for an event stored since. The Home
bell opens a Sheet listing the updates with relative times; a row opens
its URL, and opening the sheet marks the updates seen, clearing the
grape dot optimistically and putting it back if the mark fails.
Maestro task 96e2dc3f-1f8a-4114-bcb5-e0f93c349165.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+24
-1
@@ -1115,7 +1115,30 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
notification was stored in `notification_outbox` after
|
notification was stored in `notification_outbox` after
|
||||||
`household_settings.updates_seen_at`, or at all while that is null
|
`household_settings.updates_seen_at`, or at all while that is null
|
||||||
(`notify.StoredSince`); an event whose channels are all switched off is
|
(`notify.StoredSince`); an event whose channels are all switched off is
|
||||||
never stored and never counts. Nothing writes `updates_seen_at` yet.
|
never stored and never counts.
|
||||||
|
- `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`)
|
||||||
|
answers the Recent updates sheet: the last 10 distinct events in
|
||||||
|
`notification_outbox`, newest first, one per event key whatever its
|
||||||
|
channels or delivery status (an event stored for push and Google Chat is
|
||||||
|
one update):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every field comes from the stored payload, which never holds a webhook
|
||||||
|
URL or secret; `url` is the same-origin path the event opens.
|
||||||
|
- `POST /api/v1/updates/seen` with `{"through": "<created_at>"}` (CSRF
|
||||||
|
header required) records that the household has seen its updates
|
||||||
|
through the newest one it was shown, and answers 204. The last-seen
|
||||||
|
instant is `household_settings.updates_seen_at` — the one place it is
|
||||||
|
stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older
|
||||||
|
than the stored one is accepted and changes nothing, and an event
|
||||||
|
stored after `through` keeps `unread` true. It is not a settings edit:
|
||||||
|
`updated_by` and `updated_at` are left as they are and settings
|
||||||
|
subscribers are not told. 400 `invalid_request` for a body that is not
|
||||||
|
exactly that object, a `through` that is not RFC 3339, or one after
|
||||||
|
now.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,53 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listRecentNotifications = `-- name: ListRecentNotifications :many
|
||||||
|
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
|
||||||
|
WHERE o.id = (
|
||||||
|
SELECT i.id FROM notification_outbox i
|
||||||
|
WHERE i.event_key = o.event_key
|
||||||
|
ORDER BY i.created_at DESC, i.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
ORDER BY o.created_at DESC, o.event_key DESC
|
||||||
|
LIMIT ?1
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListRecentNotificationsRow struct {
|
||||||
|
EventKey string
|
||||||
|
Payload string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the last lim distinct events stored, newest first: one row per
|
||||||
|
// event key, the last stored for it whatever its channel, so an event
|
||||||
|
// stored for push and Google Chat is one row. The last, because a channel
|
||||||
|
// switched on later can store the same event key again, and
|
||||||
|
// HasNotificationSince counts that row: an event is as new as its newest
|
||||||
|
// row, so marking it seen also covers that row.
|
||||||
|
func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListRecentNotificationsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListRecentNotificationsRow
|
||||||
|
if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
||||||
UPDATE notification_outbox
|
UPDATE notification_outbox
|
||||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ type Querier interface {
|
|||||||
// Every stored subscription with its keys, for the push sender, oldest
|
// Every stored subscription with its keys, for the push sender, oldest
|
||||||
// first.
|
// first.
|
||||||
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
|
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
|
||||||
|
// Returns the last lim distinct events stored, newest first: one row per
|
||||||
|
// event key, the last stored for it whatever its channel, so an event
|
||||||
|
// stored for push and Google Chat is one row. The last, because a channel
|
||||||
|
// switched on later can store the same event key again, and
|
||||||
|
// HasNotificationSince counts that row: an event is as new as its newest
|
||||||
|
// row, so marking it seen also covers that row.
|
||||||
|
ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error)
|
||||||
// Reads for the AI household context block (internal/ai/harness).
|
// Reads for the AI household context block (internal/ai/harness).
|
||||||
// The most recent names the household turned down, newest first, as
|
// The most recent names the household turned down, newest first, as
|
||||||
// ListPassedNames has them.
|
// ListPassedNames has them.
|
||||||
@@ -254,6 +261,10 @@ type Querier interface {
|
|||||||
// Records list id sent to Google Chat at sent_to_chat_at, its send
|
// Records list id sent to Google Chat at sent_to_chat_at, its send
|
||||||
// confirmed, unless it is frozen into the Supply archive.
|
// confirmed, unless it is frozen into the Supply archive.
|
||||||
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
|
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
|
||||||
|
// Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||||
|
// already at or after seen_at, so it never moves back. It is not a settings
|
||||||
|
// edit: updated_by and updated_at are left as they are.
|
||||||
|
MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error)
|
||||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||||
// changed, any other keeps its status.
|
// changed, any other keeps its status.
|
||||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||||
|
|||||||
@@ -161,6 +161,23 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows
|
||||||
|
UPDATE household_settings
|
||||||
|
SET updates_seen_at = CAST(?1 AS TEXT)
|
||||||
|
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT))
|
||||||
|
`
|
||||||
|
|
||||||
|
// Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||||
|
// already at or after seen_at, so it never moves back. It is not a settings
|
||||||
|
// edit: updated_by and updated_at are left as they are.
|
||||||
|
func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
const recordChatTest = `-- name: RecordChatTest :execrows
|
const recordChatTest = `-- name: RecordChatTest :execrows
|
||||||
UPDATE chat_settings
|
UPDATE chat_settings
|
||||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||||
|
|||||||
@@ -195,3 +195,54 @@ func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return unread, nil
|
return unread, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecentLimit is how many events the Recent updates sheet lists.
|
||||||
|
const RecentLimit = 10
|
||||||
|
|
||||||
|
// Update is one event in the Recent updates sheet.
|
||||||
|
type Update struct {
|
||||||
|
// Type is the event type, e.g. plan_ready.
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
// URL is the path-absolute, same-origin path a tap on the update opens.
|
||||||
|
URL string `json:"url"`
|
||||||
|
// CreatedAt is when the event was stored, UTC RFC 3339 with
|
||||||
|
// milliseconds; it is what POST /updates/seen takes back.
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates returns the last RecentLimit distinct notification events, newest
|
||||||
|
// first, whatever channels they went to.
|
||||||
|
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
|
||||||
|
events, err := s.notifier.Recent(ctx, RecentLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("home: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Update, len(events))
|
||||||
|
for i, ev := range events {
|
||||||
|
out[i] = Update{
|
||||||
|
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
|
||||||
|
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
|
||||||
|
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
|
||||||
|
|
||||||
|
// MarkUpdatesSeen records that the household has seen its updates through
|
||||||
|
// through, the created_at of the newest update it was shown, so
|
||||||
|
// recentUpdates.unread is false until an event is stored after it. A time
|
||||||
|
// before the one already recorded changes nothing; one after now is
|
||||||
|
// ErrSeenInFuture, since it would hide events not yet stored.
|
||||||
|
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
|
||||||
|
if through.After(s.now()) {
|
||||||
|
return ErrSeenInFuture
|
||||||
|
}
|
||||||
|
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
|
||||||
|
return fmt.Errorf("home: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -370,3 +372,183 @@ func TestNewServiceRequiresServices(t *testing.T) {
|
|||||||
t.Fatal("NewService accepted nil services")
|
t.Fatal("NewService accepted nil services")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// store stores event key of type typ in the outbox at instant at, for each
|
||||||
|
// of channels, as notify stores an event.
|
||||||
|
func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||||
|
for _, ch := range channels {
|
||||||
|
if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||||
|
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||||
|
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates are the last ten distinct events, newest first: an event stored
|
||||||
|
// for push and Google Chat is listed once, whatever became of its delivery.
|
||||||
|
func TestUpdates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
got, err := e.svc.Updates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got == nil || len(got) != 0 {
|
||||||
|
t.Errorf("updates with no notifications = %#v, want an empty list", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||||
|
for i := range 12 {
|
||||||
|
key := fmt.Sprintf("plan_ready:%02d", i)
|
||||||
|
e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||||
|
}
|
||||||
|
// A delivered and a failed row of one event are still one event.
|
||||||
|
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = e.svc.Updates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != home.RecentLimit {
|
||||||
|
t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got)
|
||||||
|
}
|
||||||
|
for i, u := range got {
|
||||||
|
n := 11 - i
|
||||||
|
key := fmt.Sprintf("plan_ready:%02d", n)
|
||||||
|
want := home.Update{
|
||||||
|
Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key,
|
||||||
|
CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(),
|
||||||
|
}
|
||||||
|
if u != want {
|
||||||
|
t.Errorf("updates[%d] = %+v, want %+v", i, u, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An event emitted to push and Google Chat is one update.
|
||||||
|
func TestUpdatesEmittedToTwoChannels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var rows int
|
||||||
|
if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rows != 2 {
|
||||||
|
t.Fatalf("outbox rows = %d, want push and chat", rows)
|
||||||
|
}
|
||||||
|
got, err := e.svc.Updates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" {
|
||||||
|
t.Errorf("updates = %+v, want the one plan_ready event", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marking updates seen through the newest clears unread until a newer event
|
||||||
|
// is stored; marking them seen through an older time does not move the
|
||||||
|
// mark back, and a time after now is refused.
|
||||||
|
func TestMarkUpdatesSeen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
unread := func() bool {
|
||||||
|
t.Helper()
|
||||||
|
return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread
|
||||||
|
}
|
||||||
|
first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)
|
||||||
|
e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush)
|
||||||
|
if !unread() {
|
||||||
|
t.Fatal("not unread with an event never seen")
|
||||||
|
}
|
||||||
|
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if unread() {
|
||||||
|
t.Error("unread after marking the newest event seen")
|
||||||
|
}
|
||||||
|
|
||||||
|
second := first.Add(time.Millisecond)
|
||||||
|
e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat)
|
||||||
|
if !unread() {
|
||||||
|
t.Error("not unread with an event stored after the mark")
|
||||||
|
}
|
||||||
|
// Seen again through the older event, as a sheet opened before the
|
||||||
|
// newer one arrived would: nothing moves, so it stays unread.
|
||||||
|
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !unread() {
|
||||||
|
t.Error("an older mark hid the newer event")
|
||||||
|
}
|
||||||
|
if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if unread() {
|
||||||
|
t.Error("unread after marking the newer event seen")
|
||||||
|
}
|
||||||
|
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err := household.NewSettingsService(e.st).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want {
|
||||||
|
t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want)
|
||||||
|
}
|
||||||
|
if settings.UpdatedBy != nil {
|
||||||
|
t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clock reads 17:00 UTC.
|
||||||
|
if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) {
|
||||||
|
t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An event stored again for a channel switched on later is as new as that
|
||||||
|
// row: the update shows its time, and marking it seen clears unread.
|
||||||
|
func TestUpdateStoredAgainForAnotherChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||||
|
later := first.Add(30 * time.Minute)
|
||||||
|
e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush)
|
||||||
|
e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat)
|
||||||
|
|
||||||
|
got, err := e.svc.Updates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want {
|
||||||
|
t.Fatalf("updates = %+v, want the one event at %s", got, want)
|
||||||
|
}
|
||||||
|
e.at(t, "2026-10-13 12:00")
|
||||||
|
if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread {
|
||||||
|
t.Error("unread after the event was marked seen through its listed time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
@@ -200,6 +201,21 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
|||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkUpdatesSeen records that the household has seen its Recent updates
|
||||||
|
// through seenAt: updates_seen_at moves forward to seenAt, and is left as it
|
||||||
|
// is when it is already at or after it. It is not a settings edit, so
|
||||||
|
// updated_by and updated_at are kept and subscribers are not told.
|
||||||
|
func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error {
|
||||||
|
if seenAt.IsZero() {
|
||||||
|
return errors.New("household: mark updates seen: zero time")
|
||||||
|
}
|
||||||
|
_, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("household: mark updates seen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// applyPatch applies a validated patch to the household settings row in q's
|
// applyPatch applies a validated patch to the household settings row in q's
|
||||||
// transaction, recording updatedBy, and returns the new row.
|
// transaction, recording updatedBy, and returns the new row.
|
||||||
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
|
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
|
||||||
|
|||||||
@@ -205,6 +205,45 @@ func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, erro
|
|||||||
return found, nil
|
return found, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stored is one event as it was stored, whatever channels it went to.
|
||||||
|
type Stored struct {
|
||||||
|
Type EventType
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
// URL is the path-absolute, same-origin path the event opens.
|
||||||
|
URL string
|
||||||
|
// CreatedAt is when the event was last stored, for any channel: the
|
||||||
|
// instant StoredSince must be given for it not to count.
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns the last n distinct events stored, newest first. An event
|
||||||
|
// stored for more than one channel is one event, as new as its newest row;
|
||||||
|
// one whose preference switches every channel off is never stored and
|
||||||
|
// never listed.
|
||||||
|
func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n)
|
||||||
|
}
|
||||||
|
rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("notify: list recent events: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Stored, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
var p Payload
|
||||||
|
if err := json.Unmarshal([]byte(r.Payload), &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err)
|
||||||
|
}
|
||||||
|
created, err := parseTimestamp(r.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err)
|
||||||
|
}
|
||||||
|
out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// emitTx stores ev in tx for each channel that channels chooses from the
|
// emitTx stores ev in tx for each channel that channels chooses from the
|
||||||
// preference of ev's type.
|
// preference of ev's type.
|
||||||
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
|
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||||
)
|
)
|
||||||
@@ -20,3 +22,64 @@ func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
|||||||
writeJSON(w, r, logger, http.StatusOK, h)
|
writeJSON(w, r, logger, http.StatusOK, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updatesBody is the GET /updates answer.
|
||||||
|
type updatesBody struct {
|
||||||
|
Updates []home.Update `json:"updates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUpdates answers GET /updates with the Recent updates: the last
|
||||||
|
// home.RecentLimit distinct notification events, newest first.
|
||||||
|
func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updates, err := svc.Updates(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(r.Context(), "read recent updates", "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxUpdatesSeenBody bounds a POST /updates/seen request body.
|
||||||
|
const maxUpdatesSeenBody = 1 << 10
|
||||||
|
|
||||||
|
// updatesSeenRequest is the POST /updates/seen body: through is the
|
||||||
|
// created_at of the newest update the household was shown.
|
||||||
|
type updatesSeenRequest struct {
|
||||||
|
Through *string `json:"through"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// postUpdatesSeen answers POST /updates/seen by recording that the
|
||||||
|
// household has seen its updates through the body's through, and answers
|
||||||
|
// 204. A through before the one already recorded changes nothing.
|
||||||
|
func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Through == nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
through, err := time.Parse(time.RFC3339Nano, *req.Through)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = svc.MarkUpdatesSeen(r.Context(), through)
|
||||||
|
if errors.Is(err, home.ErrSeenInFuture) {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GET /home answers the Home screen in its JSON shape; newTestHome's
|
// GET /home answers the Home screen in its JSON shape; newTestHome's
|
||||||
@@ -67,3 +72,173 @@ func TestGetHomeNeedsSession(t *testing.T) {
|
|||||||
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
|
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// homeRouter returns a router whose Home reads st, and st.
|
||||||
|
func homeRouter(t *testing.T) (http.Handler, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
svc := newTestHomeOn(t, st)
|
||||||
|
return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeEvent stores event key in st's outbox at instant at for each of
|
||||||
|
// channels, as notify stores an event, and returns at as stored.
|
||||||
|
func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||||
|
for _, ch := range channels {
|
||||||
|
if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||||
|
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||||
|
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
// unread reads recentUpdates.unread from GET /home.
|
||||||
|
func unread(t *testing.T, h http.Handler) bool {
|
||||||
|
t.Helper()
|
||||||
|
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
updates, ok := body["recentUpdates"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body)
|
||||||
|
}
|
||||||
|
return updates["unread"] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
// markSeen posts body to /updates/seen and returns the answer.
|
||||||
|
func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)))
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /updates lists the last distinct events newest first, one per event
|
||||||
|
// whatever its channels, in their JSON shape.
|
||||||
|
func TestGetUpdates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h, st := homeRouter(t)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` {
|
||||||
|
t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush)
|
||||||
|
newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||||
|
want := `{"updates":[` +
|
||||||
|
`{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` +
|
||||||
|
`{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}`
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||||
|
t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread is true while an event newer than the last seen one exists,
|
||||||
|
// clears once POST /updates/seen marks the newest seen, and returns when a
|
||||||
|
// newer event is stored; marking an older one seen again moves nothing.
|
||||||
|
func TestUpdatesSeen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h, st := homeRouter(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if unread(t, h) {
|
||||||
|
t.Error("unread with no events")
|
||||||
|
}
|
||||||
|
first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||||
|
if !unread(t, h) {
|
||||||
|
t.Error("not unread with an event never seen")
|
||||||
|
}
|
||||||
|
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||||
|
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if unread(t, h) {
|
||||||
|
t.Error("unread after the newest event was marked seen")
|
||||||
|
}
|
||||||
|
|
||||||
|
second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush)
|
||||||
|
if !unread(t, h) {
|
||||||
|
t.Error("not unread with an event newer than the last seen")
|
||||||
|
}
|
||||||
|
// Already seen: marking the older event seen again is accepted and
|
||||||
|
// changes nothing.
|
||||||
|
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if !unread(t, h) {
|
||||||
|
t.Error("marking an older event seen hid a newer one")
|
||||||
|
}
|
||||||
|
if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if unread(t, h) {
|
||||||
|
t.Error("unread after the newer event was marked seen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed POST /updates/seen is 400 and records nothing.
|
||||||
|
func TestUpdatesSeenRejectsMalformed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h, st := homeRouter(t)
|
||||||
|
storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush)
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
``,
|
||||||
|
`[]`,
|
||||||
|
`{}`,
|
||||||
|
`{"through":null}`,
|
||||||
|
`{"through":42}`,
|
||||||
|
`{"through":"yesterday"}`,
|
||||||
|
`{"through":"2026-10-13 12:00:00"}`,
|
||||||
|
`{"through":"2026-10-13T12:00:00.000Z","extra":true}`,
|
||||||
|
`{"through":"2026-10-13T12:00:00.000Z"} {}`,
|
||||||
|
`{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`,
|
||||||
|
} {
|
||||||
|
rec := markSeen(t, h, body)
|
||||||
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) {
|
||||||
|
t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !unread(t, h) {
|
||||||
|
t.Error("a malformed POST /updates/seen marked updates seen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both endpoints need a session, and POST /updates/seen the CSRF token.
|
||||||
|
func TestUpdatesNeedSessionAndCSRF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h, _ := homeRouter(t)
|
||||||
|
body := `{"through":"2026-10-01T12:00:00.000Z"}`
|
||||||
|
for _, req := range []*http.Request{
|
||||||
|
httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil),
|
||||||
|
httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)),
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))
|
||||||
|
req.AddCookie(validSessionCookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ type RouteMounter interface {
|
|||||||
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
|
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
|
||||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
||||||
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||||
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home; runner serves
|
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET
|
||||||
|
// /api/v1/updates and POST /api/v1/updates/seen; runner serves
|
||||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||||
// served for every other GET. /health, /auth and the web build are public.
|
// served for every other GET. /health, /auth and the web build are public.
|
||||||
@@ -142,6 +143,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
}
|
}
|
||||||
apiV1.Get("/me", me(logger))
|
apiV1.Get("/me", me(logger))
|
||||||
apiV1.Get("/home", getHome(logger, homeSvc))
|
apiV1.Get("/home", getHome(logger, homeSvc))
|
||||||
|
apiV1.Get("/updates", getUpdates(logger, homeSvc))
|
||||||
|
apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc))
|
||||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||||
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
|
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
|
||||||
|
|||||||
@@ -52,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
return newTestRouterWithHome(t, version, settings, runner, newTestHome(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc.
|
||||||
|
func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
assets := fstest.MapFS{
|
assets := fstest.MapFS{
|
||||||
"index.html": {Data: []byte(fixtureIndex)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), assets)
|
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -265,7 +271,13 @@ func newTestShopping(t *testing.T) *shopping.Service {
|
|||||||
// connection. One database keeps the routers that never ask for Home cheap.
|
// connection. One database keeps the routers that never ask for Home cheap.
|
||||||
func newTestHome(t *testing.T) *home.Service {
|
func newTestHome(t *testing.T) *home.Service {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st := newTestStore(t)
|
return newTestHomeOn(t, newTestStore(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestHomeOn returns a home service over st, a migrated database, which
|
||||||
|
// it seeds with zone America/New_York and the seed notification preferences.
|
||||||
|
func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||||
|
t.Helper()
|
||||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,3 +74,20 @@ SELECT EXISTS (
|
|||||||
SELECT 1 FROM notification_outbox
|
SELECT 1 FROM notification_outbox
|
||||||
WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT)
|
WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT)
|
||||||
) AS found;
|
) AS found;
|
||||||
|
|
||||||
|
-- name: ListRecentNotifications :many
|
||||||
|
-- Returns the last lim distinct events stored, newest first: one row per
|
||||||
|
-- event key, the last stored for it whatever its channel, so an event
|
||||||
|
-- stored for push and Google Chat is one row. The last, because a channel
|
||||||
|
-- switched on later can store the same event key again, and
|
||||||
|
-- HasNotificationSince counts that row: an event is as new as its newest
|
||||||
|
-- row, so marking it seen also covers that row.
|
||||||
|
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
|
||||||
|
WHERE o.id = (
|
||||||
|
SELECT i.id FROM notification_outbox i
|
||||||
|
WHERE i.event_key = o.event_key
|
||||||
|
ORDER BY i.created_at DESC, i.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
ORDER BY o.created_at DESC, o.event_key DESC
|
||||||
|
LIMIT sqlc.arg(lim);
|
||||||
|
|||||||
@@ -154,3 +154,11 @@ RETURNING *;
|
|||||||
UPDATE chat_settings
|
UPDATE chat_settings
|
||||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||||
WHERE id = 1 AND webhook_url_ct = ?;
|
WHERE id = 1 AND webhook_url_ct = ?;
|
||||||
|
|
||||||
|
-- name: MarkUpdatesSeen :execrows
|
||||||
|
-- Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||||
|
-- already at or after seen_at, so it never moves back. It is not a settings
|
||||||
|
-- edit: updated_by and updated_at are left as they are.
|
||||||
|
UPDATE household_settings
|
||||||
|
SET updates_seen_at = CAST(sqlc.arg(seen_at) AS TEXT)
|
||||||
|
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(sqlc.arg(seen_at) AS TEXT));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react'
|
import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router'
|
import { Link, useNavigate } from 'react-router'
|
||||||
import { duration } from '../plan/PlanPage.tsx'
|
import { duration } from '../plan/PlanPage.tsx'
|
||||||
import { supplyRunScreen } from '../shopping/api.ts'
|
import { supplyRunScreen } from '../shopping/api.ts'
|
||||||
@@ -11,6 +12,7 @@ import { Photo } from '../ui/Photo.tsx'
|
|||||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
||||||
import styles from './HomePage.module.css'
|
import styles from './HomePage.module.css'
|
||||||
|
import { UpdatesSheet } from './UpdatesSheet.tsx'
|
||||||
|
|
||||||
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
|
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
|
||||||
|
|
||||||
@@ -108,11 +110,12 @@ function Tiles() {
|
|||||||
// Quick dinner and Cook from what I have tiles, and the status rows, all
|
// Quick dinner and Cook from what I have tiles, and the status rows, all
|
||||||
// from GET /api/v1/home.
|
// from GET /api/v1/home.
|
||||||
//
|
//
|
||||||
// The bell only shows whether there are unread updates; the Recent updates
|
// The bell carries a grape dot while there are unread updates and opens the
|
||||||
// sheet it opens is still to come.
|
// Recent updates sheet, which marks them seen.
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const home = useHome()
|
const home = useHome()
|
||||||
const data = home.data
|
const data = home.data
|
||||||
|
const [updatesOpen, setUpdatesOpen] = useState(false)
|
||||||
return (
|
return (
|
||||||
<div className={styles.page} aria-busy={home.isPending}>
|
<div className={styles.page} aria-busy={home.isPending}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
@@ -124,7 +127,11 @@ export function HomePage() {
|
|||||||
)}
|
)}
|
||||||
<h1 className={styles.title}>Tonight's quest</h1>
|
<h1 className={styles.title}>Tonight's quest</h1>
|
||||||
</div>
|
</div>
|
||||||
<IconButton label={data?.unread ? 'Recent updates, unread' : 'Recent updates'} className={styles.bell}>
|
<IconButton
|
||||||
|
label={data?.unread ? 'Recent updates, unread' : 'Recent updates'}
|
||||||
|
className={styles.bell}
|
||||||
|
onClick={() => setUpdatesOpen(true)}
|
||||||
|
>
|
||||||
<Icon icon={Bell} className={styles.bellIcon} />
|
<Icon icon={Bell} className={styles.bellIcon} />
|
||||||
{data?.unread && <span data-testid="unread-dot" className={styles.dot} />}
|
{data?.unread && <span data-testid="unread-dot" className={styles.dot} />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -142,6 +149,7 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data !== undefined && <StatusRows home={data} />}
|
{data !== undefined && <StatusRows home={data} />}
|
||||||
|
<UpdatesSheet open={updatesOpen} onClose={() => setUpdatesOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/* The Recent updates sheet the Home bell opens. */
|
||||||
|
.sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonRow {
|
||||||
|
height: 56px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||||
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
|
import { HOME } from '../test/ApiProviders.tsx'
|
||||||
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
|
import { HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts'
|
||||||
|
import { HomePage } from './HomePage.tsx'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
// minutesAgo is the stored form of the instant n minutes before now.
|
||||||
|
function minutesAgo(n: number): string {
|
||||||
|
return new Date(Date.now() - n * 60_000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATES are a GET /api/v1/updates body, newest first: the week drafted
|
||||||
|
// five minutes ago, new Side quests three hours ago and the Supply run two
|
||||||
|
// days ago.
|
||||||
|
const UPDATES = {
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
type: 'plan_ready',
|
||||||
|
title: 'Your week is drafted',
|
||||||
|
body: 'Take a look at the Draft board.',
|
||||||
|
url: '/plan',
|
||||||
|
created_at: minutesAgo(5),
|
||||||
|
},
|
||||||
|
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
|
||||||
|
{
|
||||||
|
type: 'list_ready',
|
||||||
|
title: 'Supply run ready',
|
||||||
|
body: 'Week 41 · Oct 5 – 11',
|
||||||
|
url: '/plan/shopping?week=2026-10-05',
|
||||||
|
created_at: minutesAgo(2 * 24 * 60),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||||
|
|
||||||
|
// stubServer answers "METHOD path" requests from responders, with /me; any
|
||||||
|
// other request fails the test. Home is unread until the updates are marked
|
||||||
|
// seen, as the server answers.
|
||||||
|
function stubServer(responders: Record<string, Responder> = {}) {
|
||||||
|
let seen = false
|
||||||
|
const all: Record<string, Responder> = {
|
||||||
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
|
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }),
|
||||||
|
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES),
|
||||||
|
[`POST ${UPDATES_SEEN_PATH}`]: () => {
|
||||||
|
seen = true
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
},
|
||||||
|
...responders,
|
||||||
|
}
|
||||||
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
|
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||||
|
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||||
|
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||||
|
return Promise.resolve(respond(init))
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
return fetchMock
|
||||||
|
}
|
||||||
|
|
||||||
|
// posts lists the bodies of the POST /updates/seen requests sent, each of
|
||||||
|
// which must carry the CSRF token.
|
||||||
|
function posts(fetchMock: Mock<typeof fetch>) {
|
||||||
|
return fetchMock.mock.calls
|
||||||
|
.filter(([input, init]) => init?.method === 'POST' && String(input) === UPDATES_SEEN_PATH)
|
||||||
|
.map(([, init]) => {
|
||||||
|
expect(new Headers(init?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
||||||
|
return String(init?.body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function homeReads(fetchMock: Mock<typeof fetch>) {
|
||||||
|
return fetchMock.mock.calls.filter(([input, init]) => String(input) === HOME_PATH && (init?.method ?? 'GET') === 'GET')
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where shows the route a row went to.
|
||||||
|
function Where() {
|
||||||
|
const location = useLocation()
|
||||||
|
return <p data-testid="where">{location.pathname + location.search}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderHome loads /me (for the CSRF token the POST needs) and renders Home.
|
||||||
|
async function renderHome() {
|
||||||
|
const api = createApiClient(fakeLocation('/'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
|
await api.loadMe()
|
||||||
|
const router = createMemoryRouter(
|
||||||
|
[
|
||||||
|
{ path: '/', element: <HomePage /> },
|
||||||
|
{ path: '*', element: <Where /> },
|
||||||
|
],
|
||||||
|
{ initialEntries: ['/'] },
|
||||||
|
)
|
||||||
|
render(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSheet taps the unread bell and answers the open sheet.
|
||||||
|
async function openSheet() {
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Recent updates, unread' }))
|
||||||
|
return screen.getByRole('dialog', { name: 'Recent updates' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rows(sheet: HTMLElement) {
|
||||||
|
return within(await within(sheet).findByRole('list', { name: 'Recent updates' })).getAllByRole('listitem')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Recent updates sheet', () => {
|
||||||
|
it('lists the updates newest first with how long ago each came', async () => {
|
||||||
|
stubServer()
|
||||||
|
await renderHome()
|
||||||
|
const listed = await rows(await openSheet())
|
||||||
|
|
||||||
|
expect(listed).toHaveLength(3)
|
||||||
|
expect(listed[0]).toHaveTextContent('Your week is drafted')
|
||||||
|
expect(listed[0]).toHaveTextContent('Take a look at the Draft board.')
|
||||||
|
expect(listed[0]).toHaveTextContent('5 min ago')
|
||||||
|
expect(listed[1]).toHaveTextContent('5 new Side quests')
|
||||||
|
expect(listed[1]).toHaveTextContent('3 hr ago')
|
||||||
|
expect(listed[2]).toHaveTextContent('Supply run ready')
|
||||||
|
expect(listed[2]).toHaveTextContent('2 days ago')
|
||||||
|
expect(within(listed[0]!).getByText('5 min ago')).toHaveAttribute('dateTime', UPDATES.updates[0]!.created_at)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the updates seen through the newest, and the grape dot clears and stays cleared', async () => {
|
||||||
|
const fetchMock = stubServer()
|
||||||
|
await renderHome()
|
||||||
|
expect(await screen.findByTestId('unread-dot')).toBeInTheDocument()
|
||||||
|
await openSheet()
|
||||||
|
|
||||||
|
await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: UPDATES.updates[0]!.created_at })]))
|
||||||
|
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||||
|
// Home is read again once the mark is saved, and the server says
|
||||||
|
// nothing is unread: the dot does not come back.
|
||||||
|
await waitFor(() => expect(homeReads(fetchMock)).toBe(2))
|
||||||
|
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||||
|
expect(await screen.findByRole('button', { name: 'Recent updates' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, '/plan'],
|
||||||
|
[1, '/discover'],
|
||||||
|
[2, '/plan/shopping?week=2026-10-05'],
|
||||||
|
])('row %i goes to %s and closes the sheet', async (i, to) => {
|
||||||
|
stubServer()
|
||||||
|
await renderHome()
|
||||||
|
const listed = await rows(await openSheet())
|
||||||
|
|
||||||
|
fireEvent.click(within(listed[i]!).getByRole('button'))
|
||||||
|
expect(await screen.findByTestId('where')).toHaveTextContent(to)
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says there are no updates yet, and marks nothing seen', async () => {
|
||||||
|
const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [] }) })
|
||||||
|
await renderHome()
|
||||||
|
const sheet = await openSheet()
|
||||||
|
|
||||||
|
expect(await within(sheet).findByText(/No updates yet/)).toBeInTheDocument()
|
||||||
|
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||||
|
expect(posts(fetchMock)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the dot back and says so when marking the updates seen fails', async () => {
|
||||||
|
let refuse!: (response: Response) => void
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`POST ${UPDATES_SEEN_PATH}`]: () => new Promise<Response>((resolve) => (refuse = resolve)),
|
||||||
|
})
|
||||||
|
await renderHome()
|
||||||
|
const sheet = await openSheet()
|
||||||
|
|
||||||
|
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
|
||||||
|
// Cleared at once, while the mark is in flight.
|
||||||
|
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
refuse(errorResponse(500, 'internal'))
|
||||||
|
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
|
||||||
|
"Couldn't mark your updates as seen. internal message",
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('unread-dot')).toBeInTheDocument()
|
||||||
|
// The updates are still listed.
|
||||||
|
expect(within(sheet).getByRole('list', { name: 'Recent updates' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports updates it cannot load, with Retry', async () => {
|
||||||
|
let fail = true
|
||||||
|
stubServer({ [`GET ${UPDATES_PATH}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, UPDATES)) })
|
||||||
|
await renderHome()
|
||||||
|
const sheet = await openSheet()
|
||||||
|
|
||||||
|
expect(await within(sheet).findByRole('alert')).toHaveTextContent("Couldn't load your updates. internal message")
|
||||||
|
fail = false
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Retry' }))
|
||||||
|
expect(await within(sheet).findByRole('list', { name: 'Recent updates' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an update that would open another site rather than listing it', async () => {
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [{ ...UPDATES.updates[0], url: '//evil.example/plan' }] }),
|
||||||
|
})
|
||||||
|
await renderHome()
|
||||||
|
const sheet = await openSheet()
|
||||||
|
|
||||||
|
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
|
||||||
|
'update url "//evil.example/plan" is not a path on this app',
|
||||||
|
)
|
||||||
|
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||||
|
expect(posts(fetchMock)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { CalendarDays, ChefHat, Compass, Repeat, ShoppingCart, type LucideIcon } from 'lucide-react'
|
||||||
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { ago } from '../plan/PlanPage.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { ListRow } from '../ui/ListRow.tsx'
|
||||||
|
import { Sheet } from '../ui/Sheet.tsx'
|
||||||
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
|
import { useMarkUpdatesSeen, useUpdates, type Update, type UpdateType } from './api.ts'
|
||||||
|
import styles from './UpdatesSheet.module.css'
|
||||||
|
|
||||||
|
const ICONS: Record<UpdateType, LucideIcon> = {
|
||||||
|
plan_ready: CalendarDays,
|
||||||
|
list_ready: ShoppingCart,
|
||||||
|
day_of_reminder: ChefHat,
|
||||||
|
new_suggestions: Compass,
|
||||||
|
rotation_review: Repeat,
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRow is one update: its icon, title and body, and how long ago it
|
||||||
|
// came. A tap opens its url.
|
||||||
|
function UpdateRow({ update, now, onOpen }: { update: Update; now: Date; onOpen: (url: string) => void }) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<ListRow
|
||||||
|
leading={<Icon icon={ICONS[update.type]} className={styles.icon} />}
|
||||||
|
title={update.title}
|
||||||
|
meta={update.body === '' ? undefined : update.body}
|
||||||
|
trailing={
|
||||||
|
<time dateTime={update.created_at} className={styles.time}>
|
||||||
|
{ago(new Date(update.created_at), now)}
|
||||||
|
</time>
|
||||||
|
}
|
||||||
|
onClick={() => onOpen(update.url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates lists the Recent updates, newest first, and marks them seen
|
||||||
|
// through the newest once they are shown, so the bell's dot clears.
|
||||||
|
function Updates({ onOpen }: { onOpen: (url: string) => void }) {
|
||||||
|
const updates = useUpdates()
|
||||||
|
const markSeen = useMarkUpdatesSeen()
|
||||||
|
const { mutate } = markSeen
|
||||||
|
const newest = updates.data?.[0]?.created_at
|
||||||
|
// The instant the relative times count from, fixed while the sheet is
|
||||||
|
// open.
|
||||||
|
const [now] = useState(() => new Date())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (newest !== undefined) mutate(newest)
|
||||||
|
}, [newest, mutate])
|
||||||
|
|
||||||
|
if (updates.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorBanner onRetry={() => void updates.refetch()}>Couldn't load your updates. {updates.error.message}</ErrorBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (updates.isPending) {
|
||||||
|
return (
|
||||||
|
<div aria-busy="true" aria-label="Updates loading" className={styles.rows}>
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className={styles.skeletonRow} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (updates.data.length === 0) {
|
||||||
|
return <p className={styles.empty}>No updates yet. Plans, Supply runs and new Side quests show up here.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{markSeen.isError && newest !== undefined && (
|
||||||
|
<ErrorBanner onRetry={() => mutate(newest)}>
|
||||||
|
Couldn't mark your updates as seen. {markSeen.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
<ul aria-label="Recent updates" className={styles.rows}>
|
||||||
|
{updates.data.map((update, i) => (
|
||||||
|
<UpdateRow key={`${i} ${update.created_at}`} update={update} now={now} onOpen={onOpen} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdatesSheetProps = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatesSheet is the Recent updates sheet the Home bell opens: the last
|
||||||
|
// ten notification events with how long ago each came, each opening the
|
||||||
|
// screen it is about. Opening it marks them seen.
|
||||||
|
export function UpdatesSheet({ open, onClose }: UpdatesSheetProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const openUpdate = (url: string) => {
|
||||||
|
onClose()
|
||||||
|
void navigate(url)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
|
||||||
|
<div className={styles.sheet}>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
Recent updates
|
||||||
|
</h2>
|
||||||
|
<Updates onOpen={openUpdate} />
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
+97
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useApi } from '../api/index.ts'
|
import { useApi } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { oneOf, photoId } from '../dinners/api.ts'
|
import { oneOf, photoId } from '../dinners/api.ts'
|
||||||
@@ -6,6 +6,9 @@ import { weekLabel } from '../plan/week.ts'
|
|||||||
|
|
||||||
export const HOME_PATH = '/api/v1/home'
|
export const HOME_PATH = '/api/v1/home'
|
||||||
export const HOME_KEY = ['home'] as const
|
export const HOME_KEY = ['home'] as const
|
||||||
|
export const UPDATES_PATH = '/api/v1/updates'
|
||||||
|
export const UPDATES_SEEN_PATH = '/api/v1/updates/seen'
|
||||||
|
export const UPDATES_KEY = ['updates'] as const
|
||||||
|
|
||||||
export const ORIGINS = ['regular', 'side_quest'] as const
|
export const ORIGINS = ['regular', 'side_quest'] as const
|
||||||
export type Origin = (typeof ORIGINS)[number]
|
export type Origin = (typeof ORIGINS)[number]
|
||||||
@@ -95,3 +98,96 @@ export function useHome() {
|
|||||||
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
|
||||||
|
export type UpdateType = (typeof UPDATE_TYPES)[number]
|
||||||
|
|
||||||
|
// Update is one of the Recent updates: a notification event, whatever
|
||||||
|
// channels it went to. url is the same-origin path it opens; created_at is
|
||||||
|
// when it was stored, the UTC RFC 3339 instant POST /api/v1/updates/seen
|
||||||
|
// takes back.
|
||||||
|
export type Update = { type: UpdateType; title: string; body: string; url: string; created_at: string }
|
||||||
|
|
||||||
|
// isAppPath says whether url is a path on the app's own origin, as the
|
||||||
|
// server only ever stores.
|
||||||
|
function isAppPath(url: string): boolean {
|
||||||
|
return url.startsWith('/') && !url.startsWith('//') && !url.includes('\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUpdate(where: string, body: unknown): Update {
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
typeof body.title !== 'string' ||
|
||||||
|
typeof body.body !== 'string' ||
|
||||||
|
typeof body.url !== 'string' ||
|
||||||
|
typeof body.created_at !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(`${where}: an update is not a notification event`)
|
||||||
|
}
|
||||||
|
if (!isAppPath(body.url)) throw new Error(`${where}: update url "${body.url}" is not a path on this app`)
|
||||||
|
if (Number.isNaN(Date.parse(body.created_at))) {
|
||||||
|
throw new Error(`${where}: update created_at "${body.created_at}" is not a time`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: oneOf(where, 'type', UPDATE_TYPES, body.type),
|
||||||
|
title: body.title,
|
||||||
|
body: body.body,
|
||||||
|
url: body.url,
|
||||||
|
created_at: body.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUpdates checks a GET /api/v1/updates body: the updates, newest first.
|
||||||
|
export function parseUpdates(where: string, body: unknown): Update[] {
|
||||||
|
if (!isRecord(body) || !Array.isArray(body.updates)) throw new Error(`${where}: response is not the updates`)
|
||||||
|
return body.updates.map((update) => parseUpdate(where, update))
|
||||||
|
}
|
||||||
|
|
||||||
|
// useUpdates is the Recent updates (GET /api/v1/updates): the last ten
|
||||||
|
// notification events, newest first.
|
||||||
|
export function useUpdates() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: UPDATES_KEY,
|
||||||
|
queryFn: async () => parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch<unknown>(UPDATES_PATH)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARK_SEEN_KEY = ['updates', 'seen'] as const
|
||||||
|
|
||||||
|
// useMarkUpdatesSeen records that the household has seen its updates
|
||||||
|
// through the created_at of the newest one shown (POST
|
||||||
|
// /api/v1/updates/seen). The bell's unread dot clears at once, and a Home
|
||||||
|
// read in flight is stopped so it cannot bring it back; if the server
|
||||||
|
// refuses, the dot is put back as it was before the first of the marks in
|
||||||
|
// flight. Once the last mark in flight settles Home is read again, so the
|
||||||
|
// dot shows only for an event stored since.
|
||||||
|
export function useMarkUpdatesSeen() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
// alone says whether this is the only mark in flight; each counts itself
|
||||||
|
// until it has settled.
|
||||||
|
const alone = () => queryClient.isMutating({ mutationKey: MARK_SEEN_KEY }) === 1
|
||||||
|
return useMutation({
|
||||||
|
mutationKey: MARK_SEEN_KEY,
|
||||||
|
mutationFn: async (through: string) => {
|
||||||
|
await apiFetch<unknown>(UPDATES_SEEN_PATH, { method: 'POST', body: { through } })
|
||||||
|
},
|
||||||
|
onMutate: async () => {
|
||||||
|
// A mark sent while another is in flight would snapshot that one's
|
||||||
|
// optimistic answer, so only the first snapshots.
|
||||||
|
const unread = alone() ? queryClient.getQueryData<Home>(HOME_KEY)?.unread : undefined
|
||||||
|
await queryClient.cancelQueries({ queryKey: HOME_KEY })
|
||||||
|
queryClient.setQueryData<Home>(HOME_KEY, (home) => home && { ...home, unread: false })
|
||||||
|
return { unread }
|
||||||
|
},
|
||||||
|
onError: (_error, _through, context) => {
|
||||||
|
if (context?.unread === undefined || !alone()) return
|
||||||
|
const { unread } = context
|
||||||
|
queryClient.setQueryData<Home>(HOME_KEY, (home) => home && { ...home, unread })
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
if (alone()) void queryClient.invalidateQueries({ queryKey: HOME_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user