Wire plan_ready, list_ready, new_suggestions and Send to Google Chat
CI / ci (push) Failing after 5m48s
CI / ci (pull_request) Successful in 6m58s

Connect the features to the notification outbox so real events notify,
each stored by the new notify.EmitTx in the transaction of the change it
is about (Wake after commit):

- plan_ready:{weekStart} (/plan) when a draft job stores the AI's draft;
  a redraft shares the key and a manual swap never emits. The title reads
  "Next week's plan is ready" for the coming week.
- list_ready:{weekStart} (/plan/shopping) when a week's Supply run is
  first created, by generate or after Push to Calendar.
- new_suggestions:{batchId} (/discover) when a scheduled or manual batch
  ends done with cards; replenish batches never notify.
- plan.Service.EmitDayOfReminder(date) emits day_of_reminder:{date} for
  the Feature 20 scheduler.

Add POST /api/v1/shopping/{weekStart}/send-chat: shopping.SendToChat posts
the dinners with needed items and a link through chat.PostSupplyRun,
records sent_to_chat_at, and is idempotent per week unless
{"resend": true}. The Supply run's Send to Google Chat button calls it
("Send again" once sent) and shows failures in an ErrorBanner; the unused
Unavailable placeholder is removed.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:42:16 -04:00
co-authored by Claude Opus 5.5
parent bbe031ec8d
commit 45c4f8a803
37 changed files with 1477 additions and 252 deletions
-65
View File
@@ -258,75 +258,10 @@
color: var(--color-neutral-700);
}
/* An action with nothing behind it yet (Send to Google Chat until Feature
19): it keeps focus, so hovering, tapping or focusing it shows the
tooltip. */
.unavailable {
position: relative;
display: flex;
flex-direction: column;
}
.pushButton {
min-height: 50px;
}
.unavailable [aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
/* Unavailable, it neither darkens nor presses. */
.unavailable .primary[aria-disabled='true']:hover,
.unavailable .primary[aria-disabled='true']:active {
background: var(--color-accent);
transform: none;
}
.unavailable .outline[aria-disabled='true']:hover,
.unavailable .outline[aria-disabled='true']:active {
background: transparent;
transform: none;
}
.hint {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
padding: 8px 12px;
border-radius: var(--radius-md);
background: var(--color-text);
color: var(--color-bg);
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
text-align: center;
box-shadow: var(--shadow-md);
visibility: hidden;
pointer-events: none;
}
/* In the plan manager's header the tooltip opens below, at the action's
right edge. */
.below .hint {
top: calc(100% + 8px);
bottom: auto;
left: auto;
right: 0;
transform: none;
width: 240px;
max-width: none;
z-index: 1;
}
.unavailable:hover .hint,
.unavailable [aria-disabled='true']:focus + .hint {
visibility: visible;
}
/* The plan manager's header: kicker and title beside the week buttons, over
the sync status and the actions. */
.wideHeader {
+2 -36
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { ApiError, useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
@@ -9,7 +9,7 @@ import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
@@ -672,40 +672,6 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
)
}
// Unavailable is an action with nothing behind it yet (Send to Google
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
// why, above it or, at the top of the page, below.
export function Unavailable({
hint,
variant = 'primary',
below = false,
className,
children,
}: {
hint: string
variant?: ButtonVariant
below?: boolean
className?: string
children: ReactNode
}) {
const hintId = useId()
return (
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
<Button
variant={variant}
className={[className, styles[variant]].filter(Boolean).join(' ')}
aria-disabled="true"
aria-describedby={hintId}
>
{children}
</Button>
<span id={hintId} role="tooltip" className={styles.hint}>
{hint}
</span>
</div>
)
}
// PlanPage is the Draft board on the week chosen with the previous / next
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
+67 -6
View File
@@ -13,6 +13,7 @@ import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx'
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
const GENERATE = `${THIS_WEEK}/generate`
const SEND_CHAT = `${THIS_WEEK}/send-chat`
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
type Item = { id: string; name: string; qty: string | null; needed: boolean }
@@ -372,19 +373,79 @@ describe('SupplyRunPage', () => {
expect(archive).toHaveAttribute('href', '/plan/shopping/archive')
expect(archive).toHaveClass(buttonStyles.outline!)
// Send to Google Chat waits for Feature 19: primary, unavailable, and
// its tooltip says so. Pressing it sends nothing.
const send = screen.getByRole('button', { name: 'Send to Google Chat' })
// Already sent, the primary action sends it again.
const send = screen.getByRole('button', { name: 'Send again' })
expect(send).toHaveClass(buttonStyles.primary!)
expect(send).toHaveAttribute('aria-disabled', 'true')
expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.')
fireEvent.click(send)
expect(send).toBeEnabled()
expect(writes(fetchMock)).toEqual([])
expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument()
})
it('sends the list to Google Chat once and shows when it was sent', async () => {
mockViewport(1180)
const sent = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`POST ${SEND_CHAT}`]: () => sent.response,
})
await renderPage()
await screen.findByText('4 / 6')
expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Send to Google Chat' }))
const sending = await screen.findByRole('button', { name: 'Sending…' })
expect(sending).toBeDisabled()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`])
// Saturday Oct 10, 8:00 am in New York.
sent.send(jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })))
expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Send again' })).toBeEnabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('sends a list already sent again with resend', async () => {
mockViewport(1440)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })),
[`POST ${SEND_CHAT}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T16:30:00Z' })),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Send again' }))
expect(await screen.findByText('Sent to Google Chat · Sat 12:30 pm')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":true}`])
})
it('says why a send to Google Chat failed, and retries it', async () => {
mockViewport(1180)
const answers = [
() => errorResponse(409, 'not_configured'),
() => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })),
]
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`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. not_configured message")
expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Send to Google Chat' })).toBeEnabled()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":false}`])
})
it('renders an archived list read-only, tagged Archived', async () => {
const fetchMock = stubServer({
'GET /api/v1/shopping/2026-09-28': () =>
+20 -12
View File
@@ -1,10 +1,10 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { Unavailable } from '../plan/PlanPage.tsx'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
@@ -14,6 +14,7 @@ import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
useSendToChat,
useSupplyRun,
useToggles,
WEEK_PARAM,
@@ -27,10 +28,6 @@ import styles from './SupplyRunPage.module.css'
// SUPPLY_ARCHIVE is the Supply archive screen.
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
// CHAT_HINT is why Send to Google Chat is unavailable.
// TODO(Feature 19): send the Supply run to Google Chat once it is built.
const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.'
// sentAt is when the Supply run was sent to Google Chat as its status line
// says it, in the household's zone: "Sat 8:00 am".
export function sentAt(at: Date, timezone: string): string {
@@ -104,6 +101,17 @@ function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
)
}
// SendToChat posts the Supply run to Google Chat: "Send to Google Chat"
// until it is sent, 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}>
<Icon icon={MessageSquare} size="inline" />
{pending ? 'Sending…' : sent ? 'Send again' : 'Send to Google Chat'}
</Button>
)
}
// ArchiveLink opens the Supply archive: an outline action from tablet up, a
// grape link on mobile.
function ArchiveLink({ layout }: { layout: Breakpoint }) {
@@ -120,13 +128,15 @@ 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. On a current Supply run each item toggles at once (useToggles); a
// header; a send that fails says why. 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.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const send = useSendToChat(weekStart)
const run = toggles.shown
const sent = run.sent_to_chat_at !== null
const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
@@ -155,12 +165,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{wide && (
<div className={styles.actions}>
<ArchiveLink layout={layout} />
{!viewOnly && (
<Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat
</Unavailable>
)}
{!viewOnly && <SendToChat sent={sent} pending={send.isPending} onSend={() => send.mutate(sent)} />}
</div>
)}
</header>
@@ -170,6 +175,9 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{failed.error.message}
</ErrorBanner>
)}
{send.isError && !viewOnly && (
<ErrorBanner onRetry={() => send.mutate(sent)}>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} />}
{run.groups.length === 0 ? (
<EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState>
+19
View File
@@ -206,6 +206,25 @@ 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.
export function useSendToChat(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (resend: boolean): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/send-chat`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST', body: { resend } }))
},
onSuccess: (run) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
},
})
}
// Toggle is one item marked to buy (needed) or "have it" (not needed).
export type Toggle = { id: string; needed: boolean }