Feature 19: Notifications - Web Push and Google Chat (#169)
CI / ci (push) Successful in 8m31s
CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #169.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
@@ -52,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,
|
||||
@@ -372,19 +374,122 @@ 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('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': () =>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 { 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'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { Checkbox } from '../ui/Checkbox.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
@@ -14,6 +15,7 @@ import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import {
|
||||
useGenerateSupplyRun,
|
||||
useSendToChat,
|
||||
useSupplyRun,
|
||||
useToggles,
|
||||
WEEK_PARAM,
|
||||
@@ -27,10 +29,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 +102,29 @@ 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, 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}>
|
||||
<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 +141,22 @@ 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, 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.
|
||||
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
|
||||
// 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
|
||||
@@ -155,12 +185,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={resend} pending={send.isPending} onSend={() => send.mutate(resend)} />}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
@@ -170,7 +195,13 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
{failed.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{send.isError && !viewOnly && (
|
||||
<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>
|
||||
) : (
|
||||
@@ -183,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>
|
||||
)}
|
||||
|
||||
+31
-6
@@ -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)),
|
||||
}
|
||||
@@ -206,6 +210,27 @@ 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, 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()
|
||||
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)
|
||||
},
|
||||
onError: () => queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) }),
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle is one item marked to buy (needed) or "have it" (not needed).
|
||||
export type Toggle = { id: string; needed: boolean }
|
||||
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user