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
+1
View File
@@ -120,6 +120,7 @@ describe('App routes', () => {
title: null,
status: 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_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 }] }],
+1
View File
@@ -215,6 +215,7 @@ function supplyRun(needed: number) {
title: null,
status: 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: needed,
total_count: 32,
groups: [],
@@ -308,6 +308,7 @@ const FROZEN = {
title: 'Pizza, pot roast & the great orzo trial',
status: 'archived',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: 1,
total_count: 2,
groups: [
@@ -238,6 +238,10 @@
color: var(--color-accent-2-800);
}
.unconfirmed {
color: var(--color-accent-700);
}
.footer {
display: flex;
flex-direction: column;
+44
View File
@@ -53,6 +53,7 @@ function supplyRun(overrides: Record<string, unknown> = {}, weekStart = '2026-10
title: null,
status: 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: items.filter((item) => item.needed).length,
total_count: items.length,
groups: GROUPS,
@@ -446,6 +447,49 @@ describe('SupplyRunPage', () => {
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 () => {
const fetchMock = stubServer({
'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 { Link, useSearchParams } from 'react-router'
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 { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
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"
// 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 }) {
return (
<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
// 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
// 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
// opened from the Supply archive (readOnly), is read-only and tagged
// 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 run = toggles.shown
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 wide = layout !== 'mobile'
const failed = toggles.failed
@@ -165,7 +185,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{wide && (
<div className={styles.actions}>
<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>
)}
</header>
@@ -176,9 +196,12 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
</ErrorBanner>
)}
{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 && unconfirmed}
{run.groups.length === 0 ? (
<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 && (
<footer className={styles.footer}>
{run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
{unconfirmed}
<ArchiveLink layout={layout} />
</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 }
// 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
// sent_to_chat_at when it was sent to Google Chat, if it was.
// "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, 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 & {
weekStart: string
label: string
title: string | null
status: SupplyRunStatus
sent_to_chat_at: Date | null
chat_send_unconfirmed_at: Date | null
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.
function sentAt(where: string, value: unknown): Date | null {
// instant reads field, an RFC 3339 instant or null.
function instant(where: string, field: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
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
}
@@ -150,7 +153,8 @@ export function parseSupplyRun(where: string, weekStart: string, body: unknown):
label: body.label,
title: body.title,
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),
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
// /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
// again only with resend: without it the server answers with the Supply
// run as it is.
// sent_to_chat_at recorded, is the one shown. A week already sent, or with
// a send unconfirmed, is posted again only with resend: without it the
// 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) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
@@ -222,6 +227,7 @@ export function useSendToChat(weekStart: string) {
onSuccess: (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,
status: week === '2026-10-05' ? status : 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
...counts(week),
groups: [
{