Make new_suggestions and Send to Google Chat recoverable
CI / ci (push) Successful in 6m48s
CI / ci (pull_request) Successful in 6m26s

MQ-167-01: a scheduled or manual batch now emits new_suggestions:{batchId}
in the transaction that first stores its Side quests, so the cards and
their notification commit together. A later round that fails, or a
failure recording the batch's end, no longer loses the event; a later
round's emit is a no-op by key; a notification that cannot be stored
fails that store, leaving no unannounced cards. Replenish batches still
never notify. finishBatch goes back to recording status only.

MQ-167-02: migration 0017 adds shopping_lists.chat_send_unconfirmed_at.
SendToChat records the send as begun before posting, clears it with
sent_to_chat_at once Google Chat accepts, and clears it alone when Chat
refuses. A post Chat does not answer, or one it accepts that cannot be
recorded, is ErrSendUnconfirmed (502 chat_unconfirmed, not retryable)
and leaves the marker, as does a crash mid-send; a repeat without resend
never reposts, and resend posts again. The Supply run screen re-reads
the list after a failed send, shows an unconfirmed send, offers no Retry
for it, and sends again only with resend.

Task: f0cd9d50-9c11-41be-a945-7bae9f5f28a4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:26:19 -04:00
co-authored by Claude Opus 5.5
parent 45c4f8a803
commit 80a8dd0edf
23 changed files with 586 additions and 175 deletions
+20 -11
View File
@@ -104,7 +104,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, frozen_at. | | `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. |
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. | | `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
@@ -669,8 +669,11 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
run is first created, by `POST /shopping/{weekStart}/generate` or run is first created, by `POST /shopping/{weekStart}/generate` or
after Push to Calendar; remaking it does not notify. after Push to Calendar; remaking it does not notify.
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or - `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
`manual` suggestion batch ends done having added Side quests, in the `manual` suggestion batch first adds Side quests to the deck, in the
transaction recording it done. A `replenish` batch never notifies. transaction storing them, so they are announced even when a later
round or recording the batch's end fails; it names that round's Side
quests, and a later round's emit stores nothing. A `replenish` batch
never notifies.
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through - `day_of_reminder:{date}` (URL `/dinner/{id}`) through
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned `plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
on the date and emits nothing for a week with no plan. The scheduler on the date and emits nothing for a week with no plan. The scheduler
@@ -889,7 +892,8 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
## Supply run API (Feature 17) ## Supply run API (Feature 17)
- `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title, - `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title,
status, sent_to_chat_at, needed_count, total_count, groups: [{dinner: status, sent_to_chat_at, chat_send_unconfirmed_at, needed_count,
total_count, groups: [{dinner:
{id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is {id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is
the week label (shown while `title` is null), `day` the plan day's the week label (shown while `title` is null), `day` the plan day's
weekday ("Monday"), `qty` null when the recipe gives none, weekday ("Monday"), `qty` null when the recipe gives none,
@@ -905,13 +909,18 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
its title (or week label), each dinner with items still needed and those its title (or week label), each dinner with items still needed and those
items, and a card whose "Open" button links to items, and a card whose "Open" button links to
`/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It `/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It
records `sent_to_chat_at` and answers with the Supply run as `GET` gives records `chat_send_unconfirmed_at` before posting, then
it. A week already sent is answered as it is, with no second post, `sent_to_chat_at` (clearing it) once Google Chat accepts, and answers
unless `resend` is true. No webhook stored is 409 `not_configured`; with the Supply run as `GET` gives it. A week already sent, or with a
Google Chat not answering is 502 `chat_unreachable` (retryable) and send unconfirmed, is answered as it is, with no second post, unless
answering with a status other than 2xx 502 `chat_refused` (retryable `resend` is true. No webhook stored is 409 `not_configured`; Google Chat
only for 429 or 5xx), and neither answering with a status other than 2xx is 502 `chat_refused` (retryable
records `sent_to_chat_at`. 404 when the week has no Supply run, 409 only for 429 or 5xx), clearing the send begun; Google Chat not
answering, or accepting a post that cannot then be recorded, is 502
`chat_unconfirmed` (not retryable: the post may have been made), which
leaves `chat_send_unconfirmed_at` set, as does the process stopping
mid-send. The screen shows an unconfirmed send and sends again only on
request. 404 when the week has no Supply run, 409
`archived` when it is in the Supply archive, 400 `invalid_request` for a `archived` when it is in the Supply archive, 400 `invalid_request` for a
bad body. `shopping.SendToChat(weekStart, resend)` is what bad body. `shopping.SendToChat(weekStart, resend)` is what
`shopping_chat_autosend` calls (Feature 20). `shopping_chat_autosend` calls (Feature 20).
+1
View File
@@ -285,6 +285,7 @@ type ShoppingList struct {
FrozenAt sql.NullString FrozenAt sql.NullString
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
ChatSendUnconfirmedAt sql.NullString
} }
type Suggestion struct { type Suggestion struct {
+8 -2
View File
@@ -12,6 +12,10 @@ import (
type Querier interface { type Querier interface {
// An unknown id returns sql.ErrNoRows. // An unknown id returns sql.ErrNoRows.
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error)
// Leases the pending notification due longest ago by moving its // Leases the pending notification due longest ago by moving its
// next_attempt_at to lease_until, and returns it. A claimed row is not due // next_attempt_at to lease_until, and returns it. A claimed row is not due
// again until the lease ends, so no other claim takes it while it is being // again until the lease ends, so no other claim takes it while it is being
@@ -26,6 +30,8 @@ type Querier interface {
// sets last_sync_error, keeping the chosen calendar. Affects no row when // sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected. // nothing is connected.
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error) ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
// Clears the send of list id begun, once Google Chat refused it.
ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error)
// Drops references to the runs created before the cutoff, so the purge // Drops references to the runs created before the cutoff, so the purge
// can delete them. // can delete them.
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error) ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
@@ -235,8 +241,8 @@ type Querier interface {
// Records a delivered notification; updated_at is when it was sent. The // Records a delivered notification; updated_at is when it was sent. The
// lease must still be the claim's. // lease must still be the claim's.
MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error)
// Records list id sent to Google Chat at sent_to_chat_at, unless it is // Records list id sent to Google Chat at sent_to_chat_at, its send
// 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)
// 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.
+52 -6
View File
@@ -10,6 +10,49 @@ import (
"database/sql" "database/sql"
) )
const beginShoppingListChatSend = `-- name: BeginShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = ?, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
type BeginShoppingListChatSendParams struct {
ChatSendUnconfirmedAt sql.NullString
UpdatedAt string
ID string
}
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
func (q *Queries) BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, beginShoppingListChatSend, arg.ChatSendUnconfirmedAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const clearShoppingListChatSend = `-- name: ClearShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ?
`
type ClearShoppingListChatSendParams struct {
UpdatedAt string
ID string
}
// Clears the send of list id begun, once Google Chat refused it.
func (q *Queries) ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, clearShoppingListChatSend, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const createShoppingGroup = `-- name: CreateShoppingGroup :exec const createShoppingGroup = `-- name: CreateShoppingGroup :exec
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at) INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -135,7 +178,7 @@ func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingList
} }
const getShoppingItemList = `-- name: GetShoppingItemList :one const getShoppingItemList = `-- name: GetShoppingItemList :one
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at, shopping_lists.chat_send_unconfirmed_at
FROM shopping_items FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
@@ -161,12 +204,13 @@ func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppi
&i.ShoppingList.FrozenAt, &i.ShoppingList.FrozenAt,
&i.ShoppingList.CreatedAt, &i.ShoppingList.CreatedAt,
&i.ShoppingList.UpdatedAt, &i.ShoppingList.UpdatedAt,
&i.ShoppingList.ChatSendUnconfirmedAt,
) )
return i, err return i, err
} }
const getShoppingList = `-- name: GetShoppingList :one const getShoppingList = `-- name: GetShoppingList :one
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
WHERE week_start = ? WHERE week_start = ?
` `
@@ -185,6 +229,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
&i.FrozenAt, &i.FrozenAt,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
) )
return i, err return i, err
} }
@@ -322,7 +367,7 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
} }
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
WHERE week_start <= ? WHERE week_start <= ?
ORDER BY week_start DESC ORDER BY week_start DESC
` `
@@ -348,6 +393,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
&i.FrozenAt, &i.FrozenAt,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -364,7 +410,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows
UPDATE shopping_lists UPDATE shopping_lists
SET sent_to_chat_at = ?, updated_at = ? SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL WHERE id = ? AND status = 'current' AND frozen_at IS NULL
` `
@@ -374,8 +420,8 @@ type MarkShoppingListSentToChatParams struct {
ID string ID string
} }
// Records list id sent to Google Chat at sent_to_chat_at, unless it is // Records list id sent to Google Chat at sent_to_chat_at, its send
// frozen into the Supply archive. // confirmed, unless it is frozen into the Supply archive.
func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) { func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID) result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID)
if err != nil { if err != nil {
+13 -7
View File
@@ -29,6 +29,9 @@ type supplyRun struct {
Title *string `json:"title"` Title *string `json:"title"`
Status string `json:"status"` Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"` SentToChatAt *string `json:"sent_to_chat_at"`
// ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is
// not known began.
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
shopping.Counts shopping.Counts
Groups []supplyRunGroup `json:"groups"` Groups []supplyRunGroup `json:"groups"`
} }
@@ -132,11 +135,13 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
// postSupplyRunSendChat posts the week's Supply run to Google Chat and // postSupplyRunSendChat posts the week's Supply run to Google Chat and
// answers with it, sent_to_chat_at recorded. Its body is a JSON object, // answers with it, sent_to_chat_at recorded. Its body is a JSON object,
// {} or {"resend": true}: a week already sent is answered as it is, // {} or {"resend": true}: a week already sent, or with a send
// without a second post, unless resend is true. No webhook stored is 409 // unconfirmed, is answered as it is, without a second post, unless resend
// not_configured; Google Chat not answering is 502 chat_unreachable, // is true. No webhook stored is 409 not_configured; a send whose outcome is
// retryable, and refusing the message 502 chat_refused, retryable only for // not known (Google Chat did not answer, or its acceptance could not be
// 429 or 5xx. // recorded) 502 chat_unconfirmed, not retryable, since retrying could post
// twice; and Google Chat refusing the message 502 chat_refused, retryable
// only for 429 or 5xx.
func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger) week, ok := weekStartOrFail(w, r, logger)
@@ -154,9 +159,9 @@ func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.Hand
case errors.Is(err, chat.ErrNotConfigured): case errors.Is(err, chat.ErrNotConfigured):
writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured; add one in Settings") writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured; add one in Settings")
return return
case errors.Is(err, chat.ErrUnreachable): case errors.Is(err, shopping.ErrSendUnconfirmed):
logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err) logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err)
writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_unreachable", Message: "Google Chat did not answer", Retryable: true}) writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_unconfirmed", Message: shopping.ErrSendUnconfirmed.Error()})
return return
case errors.As(err, &refused): case errors.As(err, &refused):
logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err) logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err)
@@ -292,6 +297,7 @@ func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
Title: l.Title, Title: l.Title,
Status: l.Status, Status: l.Status,
SentToChatAt: l.SentToChatAt, SentToChatAt: l.SentToChatAt,
ChatSendUnconfirmedAt: l.ChatSendUnconfirmedAt,
Counts: l.Counts(), Counts: l.Counts(),
Groups: groups, Groups: groups,
}, nil }, nil
+13 -8
View File
@@ -88,6 +88,7 @@ type supplyRunJSON struct {
Title *string `json:"title"` Title *string `json:"title"`
Status string `json:"status"` Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"` SentToChatAt *string `json:"sent_to_chat_at"`
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
NeededCount int `json:"needed_count"` NeededCount int `json:"needed_count"`
TotalCount int `json:"total_count"` TotalCount int `json:"total_count"`
Groups []struct { Groups []struct {
@@ -185,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("generate = %d %v", status, raw) t.Fatalf("generate = %d %v", status, raw)
} }
wantTop := []string{"groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"} wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
if got := keys(raw); !reflect.DeepEqual(got, wantTop) { if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
t.Errorf("generate fields = %v, want %v", got, wantTop) t.Errorf("generate fields = %v, want %v", got, wantTop)
} }
@@ -888,15 +889,19 @@ func TestSupplyRunSendChatErrors(t *testing.T) {
if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != false { if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != false {
t.Errorf("send Google Chat answers 404 = %d %v, want 502 chat_refused, not retryable", status, body) t.Errorf("send Google Chat answers 404 = %d %v, want 502 chat_refused, not retryable", status, body)
} }
e.chat.Close()
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
if status != http.StatusBadGateway || errorCode(body) != "chat_unreachable" {
t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unreachable", status, body)
}
var l supplyRunJSON var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.SentToChatAt != nil { if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil {
t.Errorf("sent_to_chat_at after failed sends = %s, want null", *l.SentToChatAt) t.Errorf("after refused sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want both null", l.SentToChatAt, l.ChatSendUnconfirmedAt)
}
e.chat.Close()
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
if status != http.StatusBadGateway || errorCode(body) != "chat_unconfirmed" || body["error"].(map[string]any)["retryable"] != false {
t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unconfirmed, not retryable", status, body)
}
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil {
t.Errorf("after an unanswered send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want null and set", l.SentToChatAt, l.ChatSendUnconfirmedAt)
} }
e.archive(t) e.archive(t)
+40 -10
View File
@@ -3,6 +3,7 @@ package shopping
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
@@ -16,10 +17,17 @@ import (
// Chat (chat.PostSupplyRun): each dinner with its items still needed, and a // Chat (chat.PostSupplyRun): each dinner with its items still needed, and a
// link to the Supply run. It records sent_to_chat_at and returns the list. // link to the Supply run. It records sent_to_chat_at and returns the list.
// //
// A week is sent once: a Supply run already sent is returned as it is, // A week is sent once: a Supply run already sent, or with a send
// without posting again, unless resend is true. A week with no Supply run // unconfirmed, is returned as it is, without posting again, unless resend
// is ErrListNotFound and one frozen into the Supply archive ErrFrozen. A // is true. A week with no Supply run is ErrListNotFound and one frozen into
// post that fails returns chat.PostSupplyRun's error and records nothing. // the Supply archive ErrFrozen.
//
// The send is recorded as begun, in chat_send_unconfirmed_at, before it is
// posted, so a post whose outcome is lost is never taken for one never
// made. A post Google Chat refuses, or that is never made, returns
// chat.PostSupplyRun's error and clears it. A post Google Chat does not
// answer, or one it accepts that cannot then be recorded, is
// ErrSendUnconfirmed and leaves it, as does the process stopping mid-send.
func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, resend bool) (List, error) { func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, resend bool) (List, error) {
s.sending.Lock() s.sending.Lock()
defer s.sending.Unlock() defer s.sending.Unlock()
@@ -30,7 +38,7 @@ func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, res
if l.Status != StatusCurrent { if l.Status != StatusCurrent {
return List{}, ErrFrozen return List{}, ErrFrozen
} }
if l.SentToChatAt != nil && !resend { if (l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil) && !resend {
return l, nil return l, nil
} }
name, err := household.WeekLabel(weekStart) name, err := household.WeekLabel(weekStart)
@@ -54,20 +62,42 @@ func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, res
groups[i].Items = append(groups[i].Items, item) groups[i].Items = append(groups[i].Items, item)
} }
} }
if err := s.chat.PostSupplyRun(ctx, weekStart, name, groups); err != nil { q := db.New(s.st.Writer)
return List{}, err n, err := q.BeginShoppingListChatSend(ctx, db.BeginShoppingListChatSendParams{
ChatSendUnconfirmedAt: sql.NullString{String: store.Now().String(), Valid: true},
UpdatedAt: store.Now().String(),
ID: l.ID,
})
if err != nil {
return List{}, fmt.Errorf("shopping: record send to Google Chat begun: %w", err)
} }
// The post is made: record it even if the client has gone. if n != 1 {
return List{}, ErrFrozen
}
postErr := s.chat.PostSupplyRun(ctx, weekStart, name, groups)
// The post's outcome is known: record it even if the client has gone.
write, cancel := jobs.FinalWriteContext(ctx) write, cancel := jobs.FinalWriteContext(ctx)
defer cancel() defer cancel()
if errors.Is(postErr, chat.ErrUnreachable) {
return List{}, fmt.Errorf("%w: %w", ErrSendUnconfirmed, postErr)
}
if postErr != nil {
if _, err := q.ClearShoppingListChatSend(write, db.ClearShoppingListChatSendParams{
UpdatedAt: store.Now().String(),
ID: l.ID,
}); err != nil {
return List{}, errors.Join(postErr, fmt.Errorf("shopping: clear refused send to Google Chat: %w", err))
}
return List{}, postErr
}
now := store.Now().String() now := store.Now().String()
n, err := db.New(s.st.Writer).MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{ n, err = q.MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{
SentToChatAt: sql.NullString{String: now, Valid: true}, SentToChatAt: sql.NullString{String: now, Valid: true},
UpdatedAt: now, UpdatedAt: now,
ID: l.ID, ID: l.ID,
}) })
if err != nil { if err != nil {
return List{}, fmt.Errorf("shopping: record sent to Google Chat: %w", err) return List{}, fmt.Errorf("%w: record sent to Google Chat: %w", ErrSendUnconfirmed, err)
} }
if n != 1 { if n != 1 {
return List{}, ErrFrozen return List{}, ErrFrozen
+81 -8
View File
@@ -127,8 +127,8 @@ func TestSendToChat(t *testing.T) {
} }
// TestSendToChatFailuresRecordNothing: a send with no webhook, or one // TestSendToChatFailuresRecordNothing: a send with no webhook, or one
// Google Chat refuses or does not answer, leaves sent_to_chat_at unset; a // Google Chat refuses, leaves sent_to_chat_at and chat_send_unconfirmed_at
// week with no list, or a frozen one, is refused without posting. // unset; a week with no list, or a frozen one, is refused without posting.
func TestSendToChatFailuresRecordNothing(t *testing.T) { func TestSendToChatFailuresRecordNothing(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
@@ -148,12 +148,8 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) { if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) {
t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err) t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err)
} }
e.chat.Close() if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil {
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrUnreachable) { t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
t.Errorf("SendToChat unanswered = %v, want chat.ErrUnreachable", err)
}
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil {
t.Errorf("sent_to_chat_at after failed sends = %v, %v, want unset", l.SentToChatAt, err)
} }
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
@@ -167,3 +163,80 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
t.Errorf("a frozen list was posted") t.Errorf("a frozen list was posted")
} }
} }
// TestSendToChatUnconfirmed: a send whose outcome is not known, because
// Google Chat accepted the post but recording it failed, or did not answer,
// is ErrSendUnconfirmed and leaves the send unconfirmed rather than unsent:
// a send without resend does not post again, and one with resend does,
// confirming it.
func TestSendToChatUnconfirmed(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.generate(t)
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_sent BEFORE UPDATE OF sent_to_chat_at ON shopping_lists BEGIN SELECT RAISE(ABORT, 'sent refused'); END`); err != nil {
t.Fatal(err)
}
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrSendUnconfirmed) {
t.Fatalf("SendToChat accepted but not recorded = %v, want ErrSendUnconfirmed", err)
}
if n := len(e.chat.Posts()); n != 1 {
t.Fatalf("Google Chat got %d posts, want 1", n)
}
l, err := e.svc.Get(t.Context(), week)
if err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil {
t.Fatalf("after an unrecorded send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want unset and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
}
if _, err := e.st.Writer.Exec(`DROP TRIGGER refuse_sent`); err != nil {
t.Fatal(err)
}
again, err := e.svc.SendToChat(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
if n := len(e.chat.Posts()); n != 1 || again.ChatSendUnconfirmedAt == nil || *again.ChatSendUnconfirmedAt != *l.ChatSendUnconfirmedAt {
t.Errorf("send again without resend: %d posts, chat_send_unconfirmed_at %v, want 1 post and %s", n, again.ChatSendUnconfirmedAt, *l.ChatSendUnconfirmedAt)
}
resent, err := e.svc.SendToChat(t.Context(), week, true)
if err != nil {
t.Fatal(err)
}
if n := len(e.chat.Posts()); n != 2 || resent.SentToChatAt == nil || resent.ChatSendUnconfirmedAt != nil {
t.Errorf("resend: %d posts, sent_to_chat_at %v, chat_send_unconfirmed_at %v, want 2 posts, set and unset", n, resent.SentToChatAt, resent.ChatSendUnconfirmedAt)
}
e.chat.Close()
if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrSendUnconfirmed) || !errors.Is(err, chat.ErrUnreachable) {
t.Errorf("SendToChat unanswered = %v, want ErrSendUnconfirmed and chat.ErrUnreachable", err)
}
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt == nil || *l.SentToChatAt != *resent.SentToChatAt || l.ChatSendUnconfirmedAt == nil {
t.Errorf("after an unanswered resend sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want %s and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err, *resent.SentToChatAt)
}
}
// TestSendToChatPostsOnlyOnceBegun: a send that cannot be recorded as
// begun is not posted.
func TestSendToChatPostsOnlyOnceBegun(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.generate(t)
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_begin BEFORE UPDATE OF chat_send_unconfirmed_at ON shopping_lists WHEN NEW.chat_send_unconfirmed_at IS NOT NULL BEGIN SELECT RAISE(ABORT, 'begin refused'); END`); err != nil {
t.Fatal(err)
}
if _, err := e.svc.SendToChat(t.Context(), week, false); err == nil {
t.Fatal("SendToChat not recorded as begun succeeded, want an error")
}
if n := len(e.chat.Posts()); n != 0 {
t.Errorf("Google Chat got %d posts of a send not recorded as begun, want 0", n)
}
}
+11
View File
@@ -82,6 +82,12 @@ var ErrItemNotFound = errors.New("no Supply run has that item")
// the household's time zone. // the household's time zone.
var ErrWeekNotEnded = errors.New("the week has not ended") var ErrWeekNotEnded = errors.New("the week has not ended")
// ErrSendUnconfirmed is a send to Google Chat whose outcome is not known:
// Google Chat did not answer, or accepted the post but recording it failed.
// The post may have been made, so the Supply run keeps the send as
// unconfirmed and is posted again only with resend.
var ErrSendUnconfirmed = errors.New("the shopping list may have reached Google Chat, but that could not be confirmed; check the space before sending again")
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and // ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
// ByYear. // ByYear.
var ErrInvalidPeriod = errors.New("by must be week, month or year") var ErrInvalidPeriod = errors.New("by must be week, month or year")
@@ -158,6 +164,10 @@ type List struct {
// SentToChatAt is when the list was sent to Google Chat; nil until it // SentToChatAt is when the list was sent to Google Chat; nil until it
// is. // is.
SentToChatAt *string `json:"sent_to_chat_at"` SentToChatAt *string `json:"sent_to_chat_at"`
// ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is
// not known began: one under way, or one that may or may not have been
// posted. nil when there is none.
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
Groups []Group `json:"groups"` Groups []Group `json:"groups"`
} }
@@ -569,6 +579,7 @@ func listOf(list db.ShoppingList, groups []Group) List {
Title: nullString(list.Title), Title: nullString(list.Title),
Status: list.Status, Status: list.Status,
SentToChatAt: nullString(list.SentToChatAt), SentToChatAt: nullString(list.SentToChatAt),
ChatSendUnconfirmedAt: nullString(list.ChatSendUnconfirmedAt),
Groups: groups, Groups: groups,
} }
} }
+37 -4
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got) t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
} }
} }
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got) t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
} }
if !tableExists(t, s, "dinners") { if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created") t.Error("table from 0001_init was not created")
@@ -889,3 +889,36 @@ func TestMigrateShoppingListUntitledRefusesUndatedGroup(t *testing.T) {
t.Error("shopping_groups_copy left behind") t.Error("shopping_groups_copy left behind")
} }
} }
// TestMigrateShoppingListChatSendUnconfirmed applies 0017 over a list
// already sent: it keeps every value, and the list has no send
// unconfirmed.
func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0017")
for _, stmt := range []string{
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
VALUES ('plan-1', '2026-10-12', 'synced', 'c', 'u')`,
`INSERT INTO shopping_lists (id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at)
VALUES ('list-1', '2026-10-12', 'plan-1', 'Noodle week', 'current', 's', NULL, 'c1', 'u1')`,
} {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
var got string
if err := s.Reader.QueryRowContext(ctx, `SELECT
id || '|' || title || '|' || status || '|' || sent_to_chat_at || '|' || COALESCE(chat_send_unconfirmed_at, 'NULL') || '|' || updated_at
FROM shopping_lists`).Scan(&got); err != nil {
t.Fatal(err)
}
if want := "list-1|Noodle week|current|s|NULL|u1"; got != want {
t.Errorf("shopping list after 0017 = %s, want %s", got, want)
}
}
+45 -43
View File
@@ -15,7 +15,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"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/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
) )
@@ -58,12 +57,12 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// shutdown, the job is left running and the batch with it, for the next // shutdown, the job is left running and the batch with it, for the next
// start to fail. // start to fail.
// //
// A scheduled or manual batch that ends done having added Side quests to // A scheduled or manual batch emits new_suggestions, keyed by the batch,
// the deck emits new_suggestions, keyed by the batch, in the transaction // in the transaction that first adds Side quests to the deck, so the
// that records it done. A replenish batch, which tops up the deck as the // household is told of every Side quest stored, whether or not the batch
// household swipes, never notifies. A notification that cannot be stored // then ends done: a later round, or recording the batch's end, may fail.
// fails the done write with it, as any failure recording the batch's end // A replenish batch, which tops up the deck as the household swipes, never
// does: the job fails, and its Side quests stay in the deck. // notifies. See store.
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) { func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID) batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
if err != nil { if err != nil {
@@ -79,19 +78,14 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
if n == 0 { if n == 0 {
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID) return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
} }
f, err := s.fill(ctx, p.BatchID, publish) res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
if err == nil { if err == nil {
var ev *notify.Event if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
if batch.Trigger != TriggerReplenish && len(f.names) > 0 {
e := chat.NewSuggestions(p.BatchID, household.WeekStart(f.today), f.names)
ev = &e
}
if err := s.finishBatch(ctx, p.BatchID, BatchDone, ev); err != nil {
return batchResult{}, err return batchResult{}, err
} }
return f.res, nil return res, nil
} }
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed, nil) finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed)
if ctx.Err() != nil { if ctx.Err() != nil {
return batchResult{}, errors.Join(ctx.Err(), finishErr) return batchResult{}, errors.Join(ctx.Err(), finishErr)
} }
@@ -104,60 +98,56 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
return batchResult{}, errors.Join(err, finishErr) return batchResult{}, errors.Join(err, finishErr)
} }
// filled is what fill did: the batch's result, the names of the Side
// quests it added to the deck, and the household's date it ran on.
type filled struct {
res batchResult
names []string
today store.LocalDate
}
// fill does runBatch's work, returning a *jobs.Failure for a failure the // fill does runBatch's work, returning a *jobs.Failure for a failure the
// household may see. // household may see. When notifies is true, store emits new_suggestions
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (filled, error) { // with the Side quests it adds.
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}} res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil { if err := publish(res); err != nil {
return filled{}, err return batchResult{}, err
} }
in, err := s.request(ctx) in, err := s.request(ctx)
if err != nil { if err != nil {
return filled{}, err return batchResult{}, err
} }
out, err := s.suggest(ctx, batchID, in) out, err := s.suggest(ctx, batchID, in)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
return filled{}, ctx.Err() return batchResult{}, ctx.Err()
} }
return filled{}, &jobs.Failure{Code: ReasonAIFailed} return batchResult{}, &jobs.Failure{Code: ReasonAIFailed}
} }
res.Stage = StageGrounding res.Stage = StageGrounding
res.Dropped = len(out.Dropped) res.Dropped = len(out.Dropped)
if err := publish(res); err != nil { if err := publish(res); err != nil {
return filled{}, err return batchResult{}, err
}
var week *store.LocalDate
if notifies {
w := household.WeekStart(in.Context.Today)
week = &w
} }
var names []string
left := out.Suggestions left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 { for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))] round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
left = left[len(round):] left = left[len(round):]
grounded, err := s.ground(ctx, round) grounded, err := s.ground(ctx, round)
if err != nil { if err != nil {
return filled{}, err return batchResult{}, err
} }
res.Dropped += len(round) - len(grounded) res.Dropped += len(round) - len(grounded)
ids, stored, err := s.store(ctx, batchID, grounded) ids, err := s.store(ctx, batchID, week, grounded)
if err != nil { if err != nil {
return filled{}, err return batchResult{}, err
} }
res.Dropped += len(grounded) - len(ids) res.Dropped += len(grounded) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...) res.SuggestionIDs = append(res.SuggestionIDs, ids...)
names = append(names, stored...)
if err := publish(res); err != nil { if err := publish(res); err != nil {
return filled{}, err return batchResult{}, err
} }
} }
res.Stage = StageDone res.Stage = StageDone
return filled{res: res, names: names, today: in.Context.Today}, nil return res, nil
} }
// request reads what suggest_side_quests is given: the household context, // request reads what suggest_side_quests is given: the household context,
@@ -232,9 +222,15 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it // suggest_side_quests ran, a dinner it repeats was added, a suggestion it
// repeats passed or a Rotation check dropped, or a Regular it cites renamed // repeats passed or a Rotation check dropped, or a Regular it cites renamed
// or archived. Passed names are read in the same transaction as the store, // or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed. The names stored // so a Pass or drop committed before it is never missed.
// are returned with the ids. //
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, []string, error) { // When week is not nil and a Side quest is stored, new_suggestions:{batchID}
// naming those stored is emitted in the same transaction, for the week
// starting week, so the Side quests and their notification commit together
// or not at all: a notification that cannot be stored fails the store, and
// the batch with it. The event's key makes the first round to store Side
// quests the one that notifies; a later round's emit stores nothing.
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) {
ids := []string{} ids := []string{}
var stored []string var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error { err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -302,12 +298,18 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu
ids = append(ids, id) ids = append(ids, id)
stored = append(stored, d.Name) stored = append(stored, d.Name)
} }
if week == nil || len(stored) == 0 {
return nil return nil
}
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
}) })
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
return ids, stored, nil if week != nil && len(stored) > 0 {
s.notifier.Wake()
}
return ids, nil
} }
// citedIDs returns the ids of the Regulars named cited, false when one // citedIDs returns the ids of the Regulars named cited, false when one
+8 -20
View File
@@ -257,39 +257,27 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
} }
// finishBatch records running batch id as status, and emits ev with it // finishBatch records running batch id as status, with
// when ev is not nil, with jobs.FinalWriteContext: the write is made even // jobs.FinalWriteContext: the write is made even when ctx has been
// when ctx has been cancelled, but a writer that stays busy cannot hold its // cancelled, but a writer that stays busy cannot hold its worker past
// worker past jobs.FinalWriteTimeout. Its job's handler returns any error, so the job // jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
// never ends succeeded with the batch still running; the runner keeps only // never ends succeeded with the batch still running; the runner keeps only
// a code, so the error is logged too. // a code, so the error is logged too.
func (s *Service) finishBatch(ctx context.Context, id, status string, ev *notify.Event) error { func (s *Service) finishBatch(ctx context.Context, id, status string) error {
write, cancel := jobs.FinalWriteContext(ctx) write, cancel := jobs.FinalWriteContext(ctx)
defer cancel() defer cancel()
err := s.st.WithTx(write, func(tx *sql.Tx) error { n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
n, err := db.New(tx).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
Status: status, Status: status,
UpdatedAt: store.Now().String(), UpdatedAt: store.Now().String(),
ID: id, ID: id,
}) })
if err != nil { if err == nil && n != 1 {
return err err = errors.New("batch is not running")
} }
if n != 1 {
return errors.New("batch is not running")
}
if ev == nil {
return nil
}
return s.notifier.EmitTx(write, tx, *ev)
})
if err != nil { if err != nil {
s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err) s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err)
return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err) return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err)
} }
if ev != nil {
s.notifier.Wake()
}
return nil return nil
} }
+97 -2
View File
@@ -454,6 +454,100 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
} }
} }
// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with
// the first round to add Side quests, so a batch whose later round fails
// still tells the household of those in the deck, once; a later round that
// succeeds adds no second notification; a replenish batch notifies nothing.
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
for _, tc := range []struct {
trigger string
lastFails bool
notify bool
}{
{suggest.TriggerScheduled, true, true},
{suggest.TriggerManual, true, true},
{suggest.TriggerManual, false, true},
{suggest.TriggerReplenish, true, false},
} {
t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) {
e := newEnv(t)
e.start(t)
// The first round grounds BatchSize Side quests but B; the
// second round grounds F.
var quests []string
for i := range suggest.BatchSize + 1 {
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
e.search.Results[name] = nil
if i != 1 {
e.recipe(t, name)
}
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
}
e.reply(quests...)
if tc.lastFails {
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil {
t.Fatal(err)
}
}
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, b.JobID)
want, deck := jobs.StatusSucceeded, suggest.BatchSize
if tc.lastFails {
want, deck = jobs.StatusFailed, suggest.BatchSize-1
}
if job.Status != want {
t.Fatalf("batch job %s, want %s", job.Status, want)
}
if got := e.deckNames(t); len(got) != deck {
t.Fatalf("deck = %v, want %d Side quests", got, deck)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
if !tc.notify {
if len(rows) != 0 {
t.Errorf("new_suggestions rows = %+v, want none", rows)
}
return
}
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" {
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID)
}
})
}
}
// TestBatchFailsWhenItsNotificationCannotBeStored: Side quests are stored
// only with their notification, so a notification that cannot be stored
// fails the batch with an empty deck rather than leaving Side quests the
// household is never told of.
func TestBatchFailsWhenItsNotificationCannotBeStored(t *testing.T) {
e := newEnv(t)
e.start(t)
e.recipe(t, "Chili Verde")
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_outbox BEFORE INSERT ON notification_outbox BEGIN SELECT RAISE(ABORT, 'outbox refused'); END`); err != nil {
t.Fatal(err)
}
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed {
t.Fatalf("batch job %s, want failed", job.Status)
}
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
t.Errorf("batch status = %s, want failed", got)
}
if got := e.deckNames(t); len(got) != 0 {
t.Errorf("deck = %v with no notification stored, want empty", got)
}
}
func TestBatchKeepsAtMostBatchSize(t *testing.T) { func TestBatchKeepsAtMostBatchSize(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.start(t) e.start(t)
@@ -1204,8 +1298,9 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
t.Errorf("batch status = %s with its job failed, want failed", got) t.Errorf("batch status = %s with its job failed, want failed", got)
} }
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 { // Its Side quests stay in the deck, and the household was told of them.
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want none", rows) if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" {
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
} }
} }
@@ -0,0 +1,10 @@
-- 0017_shopping_list_chat_send_unconfirmed: a send of a Supply run to
-- Google Chat is recorded as begun, in chat_send_unconfirmed_at, before it
-- is posted, and cleared when Google Chat's answer is known: with
-- sent_to_chat_at when it accepted the post, alone when it refused it. A
-- value left behind (Google Chat did not answer, the process stopped, or
-- recording the answer failed) means the post may or may not have been
-- made, so it is never posted again without resend. Existing rows have no
-- send under way.
ALTER TABLE shopping_lists ADD COLUMN chat_send_unconfirmed_at TEXT;
+18 -4
View File
@@ -89,13 +89,27 @@ UPDATE shopping_lists
SET title = ?, updated_at = ? SET title = ?, updated_at = ?
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL; WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
-- name: MarkShoppingListSentToChat :execrows -- name: BeginShoppingListChatSend :execrows
-- Records list id sent to Google Chat at sent_to_chat_at, unless it is -- Records a send of list id to Google Chat begun at
-- frozen into the Supply archive. -- chat_send_unconfirmed_at, before it is posted, unless the list is frozen
-- into the Supply archive.
UPDATE shopping_lists UPDATE shopping_lists
SET sent_to_chat_at = ?, updated_at = ? SET chat_send_unconfirmed_at = ?, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL; WHERE id = ? AND status = 'current' AND frozen_at IS NULL;
-- name: MarkShoppingListSentToChat :execrows
-- Records list id sent to Google Chat at sent_to_chat_at, its send
-- confirmed, unless it is frozen into the Supply archive.
UPDATE shopping_lists
SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL;
-- name: ClearShoppingListChatSend :execrows
-- Clears the send of list id begun, once Google Chat refused it.
UPDATE shopping_lists
SET chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ?;
-- name: FreezeShoppingList :execrows -- name: FreezeShoppingList :execrows
-- Freezes list id into the Supply archive at frozen_at, unless it is -- Freezes list id into the Supply archive at frozen_at, unless it is
-- frozen already. -- frozen already.
+1
View File
@@ -120,6 +120,7 @@ describe('App routes', () => {
title: null, title: null,
status: 'current', status: 'current',
sent_to_chat_at: null, sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: 1, needed_count: 1,
total_count: 1, total_count: 1,
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }], groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
+1
View File
@@ -215,6 +215,7 @@ function supplyRun(needed: number) {
title: null, title: null,
status: 'current', status: 'current',
sent_to_chat_at: null, sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: needed, needed_count: needed,
total_count: 32, total_count: 32,
groups: [], groups: [],
@@ -308,6 +308,7 @@ const FROZEN = {
title: 'Pizza, pot roast & the great orzo trial', title: 'Pizza, pot roast & the great orzo trial',
status: 'archived', status: 'archived',
sent_to_chat_at: null, sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: 1, needed_count: 1,
total_count: 2, total_count: 2,
groups: [ groups: [
@@ -238,6 +238,10 @@
color: var(--color-accent-2-800); color: var(--color-accent-2-800);
} }
.unconfirmed {
color: var(--color-accent-700);
}
.footer { .footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+44
View File
@@ -53,6 +53,7 @@ function supplyRun(overrides: Record<string, unknown> = {}, weekStart = '2026-10
title: null, title: null,
status: 'current', status: 'current',
sent_to_chat_at: null, sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: items.filter((item) => item.needed).length, needed_count: items.filter((item) => item.needed).length,
total_count: items.length, total_count: items.length,
groups: GROUPS, groups: GROUPS,
@@ -446,6 +447,49 @@ describe('SupplyRunPage', () => {
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":false}`]) expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":false}`])
}) })
it('says a send whose outcome is not known was not confirmed, offers no retry, and sends again only with resend', async () => {
mockViewport(1180)
let run = supplyRun()
const answers = [
() => {
// Saturday Oct 10, 8:00 am in New York.
run = supplyRun({ chat_send_unconfirmed_at: '2026-10-10T12:00:00Z' })
return errorResponse(502, 'chat_unconfirmed')
},
() => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:05:00Z' })),
]
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, run),
[`POST ${SEND_CHAT}`]: () => answers.shift()!(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Send to Google Chat' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't send the shopping list to Google Chat. chat_unconfirmed message")
expect(within(alert).queryByRole('button')).not.toBeInTheDocument()
// The Supply run is read again, and says the send is unconfirmed.
expect(
await screen.findByText('Send to Google Chat not confirmed · Sat 8:00 am — check the space before sending again'),
).toBeInTheDocument()
expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Send again' }))
expect(await screen.findByText('Sent to Google Chat · Sat 8:05 am')).toBeInTheDocument()
expect(screen.queryByText(/not confirmed/)).not.toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":true}`])
})
it('shows an unconfirmed send on mobile', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ chat_send_unconfirmed_at: '2026-10-10T12:00:00Z' })) })
await renderPage()
expect(
await screen.findByText('Send to Google Chat not confirmed · Sat 8:00 am — check the space before sending again'),
).toBeInTheDocument()
})
it('renders an archived list read-only, tagged Archived', async () => { it('renders an archived list read-only, tagged Archived', async () => {
const fetchMock = stubServer({ const fetchMock = stubServer({
'GET /api/v1/shopping/2026-09-28': () => 'GET /api/v1/shopping/2026-09-28': () =>
+28 -4
View File
@@ -2,6 +2,7 @@ import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { Link, useSearchParams } from 'react-router' import { Link, useSearchParams } from 'react-router'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts' import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { ApiError } from '../api/errors.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx' import { Button } from '../ui/Button.tsx'
@@ -101,8 +102,20 @@ function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
) )
} }
// UnconfirmedStatus is the status line while a send to Google Chat whose
// outcome is not known is recorded: it may or may not have been posted.
function UnconfirmedStatus({ at, timezone }: { at: Date; timezone: string }) {
return (
<p className={`${styles.sent} ${styles.unconfirmed}`}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat not confirmed · {sentAt(at, timezone)} — check the space before sending again
</p>
)
}
// SendToChat posts the Supply run to Google Chat: "Send to Google Chat" // SendToChat posts the Supply run to Google Chat: "Send to Google Chat"
// until it is sent, then "Send again", which posts it once more. // until it is sent, or a send is unconfirmed, then "Send again", which
// posts it once more.
function SendToChat({ sent, pending, onSend }: { sent: boolean; pending: boolean; onSend: () => void }) { function SendToChat({ sent, pending, onSend }: { sent: boolean; pending: boolean; onSend: () => void }) {
return ( return (
<Button className={styles.action} disabled={pending} onClick={onSend}> <Button className={styles.action} disabled={pending} onClick={onSend}>
@@ -128,7 +141,8 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// the week's dates, the title with the count still to buy of all items, and // the week's dates, the title with the count still to buy of all items, and
// one card per dinner, stacked on mobile and in three masonry columns from // one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the // tablet up, where Archive and Send to Google Chat are actions in the
// header; a send that fails says why. On a current Supply run each item toggles at once (useToggles); a // header; a send that fails says why, and one whose outcome is not known
// offers no retry and leaves the status line saying so. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one, or any // toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged // opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again. // Archived, and a toggle it refused is not offered again.
@@ -137,6 +151,12 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const send = useSendToChat(weekStart) const send = useSendToChat(weekStart)
const run = toggles.shown const run = toggles.shown
const sent = run.sent_to_chat_at !== null const sent = run.sent_to_chat_at !== null
// A send whose outcome is not known is posted again only on request.
const resend = sent || run.chat_send_unconfirmed_at !== null
const unconfirmed = !send.isPending && run.chat_send_unconfirmed_at !== null && (
<UnconfirmedStatus at={run.chat_send_unconfirmed_at} timezone={timezone} />
)
const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed'
const viewOnly = readOnly || run.status === 'archived' const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile' const wide = layout !== 'mobile'
const failed = toggles.failed const failed = toggles.failed
@@ -165,7 +185,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{wide && ( {wide && (
<div className={styles.actions}> <div className={styles.actions}>
<ArchiveLink layout={layout} /> <ArchiveLink layout={layout} />
{!viewOnly && <SendToChat sent={sent} pending={send.isPending} onSend={() => send.mutate(sent)} />} {!viewOnly && <SendToChat sent={resend} pending={send.isPending} onSend={() => send.mutate(resend)} />}
</div> </div>
)} )}
</header> </header>
@@ -176,9 +196,12 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
</ErrorBanner> </ErrorBanner>
)} )}
{send.isError && !viewOnly && ( {send.isError && !viewOnly && (
<ErrorBanner onRetry={() => send.mutate(sent)}>Couldn't send the shopping list to Google Chat. {send.error.message}</ErrorBanner> <ErrorBanner onRetry={sendUnconfirmed ? undefined : () => send.mutate(resend)}>
Couldn't send the shopping list to Google Chat. {send.error.message}
</ErrorBanner>
)} )}
{wide && run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />} {wide && run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
{wide && unconfirmed}
{run.groups.length === 0 ? ( {run.groups.length === 0 ? (
<EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState> <EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState>
) : ( ) : (
@@ -191,6 +214,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{!wide && ( {!wide && (
<footer className={styles.footer}> <footer className={styles.footer}>
{run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />} {run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
{unconfirmed}
<ArchiveLink layout={layout} /> <ArchiveLink layout={layout} />
</footer> </footer>
)} )}
+15 -9
View File
@@ -72,14 +72,17 @@ export type SupplyRunGroup = { dinner: { id: string; name: string }; date: strin
export type Counts = { needed_count: number; total_count: number } export type Counts = { needed_count: number; total_count: number }
// SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label, // SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label,
// "Week 41 · Oct 5 – 11", title the AI's title once written, and // "Week 41 · Oct 5 – 11", title the AI's title once written,
// sent_to_chat_at when it was sent to Google Chat, if it was. // sent_to_chat_at when it was sent to Google Chat, if it was, and
// chat_send_unconfirmed_at when a send whose outcome is not known began,
// if one did: it may or may not have reached Google Chat.
export type SupplyRun = Counts & { export type SupplyRun = Counts & {
weekStart: string weekStart: string
label: string label: string
title: string | null title: string | null
status: SupplyRunStatus status: SupplyRunStatus
sent_to_chat_at: Date | null sent_to_chat_at: Date | null
chat_send_unconfirmed_at: Date | null
groups: SupplyRunGroup[] groups: SupplyRunGroup[]
} }
@@ -125,12 +128,12 @@ function parseGroup(where: string, body: unknown): SupplyRunGroup {
} }
} }
// sentAt reads sent_to_chat_at, an RFC 3339 instant or null. // instant reads field, an RFC 3339 instant or null.
function sentAt(where: string, value: unknown): Date | null { function instant(where: string, field: string, value: unknown): Date | null {
if (value === null) return null if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null const at = typeof value === 'string' ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) { if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: sent_to_chat_at ${JSON.stringify(value)} is not an instant`) throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not an instant`)
} }
return at return at
} }
@@ -150,7 +153,8 @@ export function parseSupplyRun(where: string, weekStart: string, body: unknown):
label: body.label, label: body.label,
title: body.title, title: body.title,
status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status), status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status),
sent_to_chat_at: sentAt(where, body.sent_to_chat_at), sent_to_chat_at: instant(where, 'sent_to_chat_at', body.sent_to_chat_at),
chat_send_unconfirmed_at: instant(where, 'chat_send_unconfirmed_at', body.chat_send_unconfirmed_at),
...parseCounts(where, body), ...parseCounts(where, body),
groups: body.groups.map((group) => parseGroup(where, group)), groups: body.groups.map((group) => parseGroup(where, group)),
} }
@@ -208,9 +212,10 @@ export function useGenerateSupplyRun(weekStart: string) {
// useSendToChat posts the week's Supply run to Google Chat (POST // useSendToChat posts the week's Supply run to Google Chat (POST
// /api/v1/shopping/{weekStart}/send-chat); the Supply run it answers with, // /api/v1/shopping/{weekStart}/send-chat); the Supply run it answers with,
// sent_to_chat_at recorded, is the one shown. A week already sent is posted // sent_to_chat_at recorded, is the one shown. A week already sent, or with
// again only with resend: without it the server answers with the Supply // a send unconfirmed, is posted again only with resend: without it the
// run as it is. // server answers with the Supply run as it is. A send that fails re-reads
// the Supply run, so a send left unconfirmed shows as such.
export function useSendToChat(weekStart: string) { export function useSendToChat(weekStart: string) {
const { apiFetch } = useApi() const { apiFetch } = useApi()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -222,6 +227,7 @@ export function useSendToChat(weekStart: string) {
onSuccess: (run) => { onSuccess: (run) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run) queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
}, },
onError: () => queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) }),
}) })
} }
+1
View File
@@ -93,6 +93,7 @@ function stubServer(
title: null, title: null,
status: week === '2026-10-05' ? status : 'current', status: week === '2026-10-05' ? status : 'current',
sent_to_chat_at: null, sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
...counts(week), ...counts(week),
groups: [ groups: [
{ {