Wire plan_ready, list_ready, new_suggestions and Send to Google Chat (Feature 19) (#167)
CI / ci (push) Successful in 7m2s
CI / ci (push) Successful in 7m2s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #167.
This commit is contained in:
@@ -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 }] }],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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