Files
MealQuest/web/src/home/UpdatesSheet.tsx
T
dev_agent fd087e0ba3
CI / ci (pull_request) Successful in 8m6s
CI / ci (push) Successful in 11m26s
Feature 21: Home Dashboard (#181)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 20:07:53 -04:00

118 lines
3.9 KiB
TypeScript

import { CalendarDays, ChefHat, Compass, Repeat, ShoppingCart, type LucideIcon } from 'lucide-react'
import { useEffect, useId, useState } from 'react'
import { useNavigate } from 'react-router'
import { ago } from '../plan/PlanPage.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { ListRow } from '../ui/ListRow.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useMarkUpdatesSeen, useUpdates, type Update, type UpdateType } from './api.ts'
import styles from './UpdatesSheet.module.css'
const ICONS: Record<UpdateType, LucideIcon> = {
plan_ready: CalendarDays,
list_ready: ShoppingCart,
day_of_reminder: ChefHat,
new_suggestions: Compass,
rotation_review: Repeat,
}
// UpdateRow is one update: its icon, title and body, and how long ago it
// came. A tap opens its url.
function UpdateRow({ update, now, onOpen }: { update: Update; now: Date; onOpen: (url: string) => void }) {
return (
<li>
<ListRow
leading={<Icon icon={ICONS[update.type]} className={styles.icon} />}
title={update.title}
meta={update.body === '' ? undefined : update.body}
trailing={
<time dateTime={update.created_at} className={styles.time}>
{ago(new Date(update.created_at), now)}
</time>
}
onClick={() => onOpen(update.url)}
/>
</li>
)
}
// Updates lists the Recent updates, newest first, and marks them seen
// through the newest once they are shown, so the bell's dot clears.
function Updates({ onOpen }: { onOpen: (url: string) => void }) {
const updates = useUpdates()
const markSeen = useMarkUpdatesSeen()
const { mutate } = markSeen
// fresh says this opening's read has come back; until then a list still
// held from an earlier opening is neither shown nor marked seen.
const fresh = updates.isSuccess && updates.isFetchedAfterMount
const newest = fresh ? updates.data[0]?.created_at : undefined
// The instant the relative times count from, fixed while the sheet is
// open.
const [now] = useState(() => new Date())
useEffect(() => {
if (newest !== undefined) mutate(newest)
}, [newest, mutate])
if (updates.isError) {
return (
<ErrorBanner onRetry={() => void updates.refetch()}>Couldn't load your updates. {updates.error.message}</ErrorBanner>
)
}
if (!fresh) {
return (
<div aria-busy="true" aria-label="Updates loading" className={styles.rows}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className={styles.skeletonRow} />
))}
</div>
)
}
if (updates.data.length === 0) {
return <p className={styles.empty}>No updates yet. Plans, Supply runs and new Side quests show up here.</p>
}
return (
<>
{markSeen.isError && newest !== undefined && (
<ErrorBanner onRetry={() => mutate(newest)}>
Couldn't mark your updates as seen. {markSeen.error.message}
</ErrorBanner>
)}
<ul aria-label="Recent updates" className={styles.rows}>
{updates.data.map((update, i) => (
<UpdateRow key={`${i} ${update.created_at}`} update={update} now={now} onOpen={onOpen} />
))}
</ul>
</>
)
}
export type UpdatesSheetProps = {
open: boolean
onClose: () => void
}
// UpdatesSheet is the Recent updates sheet the Home bell opens: the last
// ten notification events with how long ago each came, each opening the
// screen it is about. Opening it marks them seen.
export function UpdatesSheet({ open, onClose }: UpdatesSheetProps) {
const titleId = useId()
const navigate = useNavigate()
const openUpdate = (url: string) => {
onClose()
void navigate(url)
}
return (
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.title}>
Recent updates
</h2>
<Updates onOpen={openUpdate} />
</div>
</Sheet>
)
}