Add the Recent updates sheet behind the Home bell (Feature 21)
CI / ci (push) Successful in 7m48s
CI / ci (pull_request) Failing after 13m22s

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:
2026-10-10 09:03:47 -04:00
co-authored by Claude Opus 5.5
parent ea5399098d
commit 74e33a6236
19 changed files with 1165 additions and 8 deletions
+47
View File
@@ -182,6 +182,53 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref
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
UPDATE notification_outbox
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
+11
View File
@@ -212,6 +212,13 @@ type Querier interface {
// Every stored subscription with its keys, for the push sender, oldest
// first.
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).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
@@ -254,6 +261,10 @@ type Querier interface {
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
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
// changed, any other keeps its status.
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
+17
View File
@@ -161,6 +161,23 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
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
UPDATE chat_settings
SET last_test_at = ?, last_test_status = ?, updated_at = ?