From bf220eac3c8845f8b4689e4066fea142aeb17772 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 11 Aug 2026 12:53:45 +0200 Subject: [PATCH] feat(inbox): run the receipt hunt from the page it fills (#1519) * feat(inbox): run the receipt hunt from the page it fills The hunt could only be started from Settings. The page where a person notices that receipts are missing had no way to go and look for them, and the button that fixes it sat behind a different navigation item. That seam is the kind that makes a working feature look broken. "Leta i mejlen" now sits in the Underlag header, next to Ladda upp, and only when a mailbox is actually connected: offering it otherwise promises something it cannot do. The loop moves into a shared hook rather than being copied. It belongs to neither surface, and two implementations of "when does a run stop" would eventually disagree about the one thing that matters, which is that a pass finding nothing new means the mailboxes hold nothing more for the purchases still open. Each pass refreshes both lists, so a run fills the page as it goes instead of all at once at the end. That matters more here than in Settings: a pass can attach a document to a purchase, which moves a row out of "saknar underlag" and into the inbox, and watching that happen is the feedback that the button did something. Co-Authored-By: Claude Opus 5 (1M context) * fix(inbox): only offer the hunt when a mailbox can actually be searched Counting connection rows does not answer whether anything is searchable. A revoked or expired connection is still a row, and the hunt skips it, so the button promised a search that would return nothing on every pass. A dead mailbox that still looks healthy is the exact failure this feature exists to surface. Starting by doing it in its own header would be a poor joke. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .../general/InvoiceInboxWorkspace.tsx | 54 ++++++++++ .../general/MailConnectionsPanel.tsx | 64 +----------- .../extensions/general/use-receipt-hunt.ts | 98 +++++++++++++++++++ 3 files changed, 156 insertions(+), 60 deletions(-) create mode 100644 components/extensions/general/use-receipt-hunt.ts diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index f8d79a58..ca1ef160 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -41,6 +41,7 @@ import { } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency, formatDate } from '@/lib/utils' +import { useReceiptHunt } from '@/components/extensions/general/use-receipt-hunt' import { createClient } from '@/lib/supabase/client' import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout' import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy' @@ -448,6 +449,27 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const [purchases, setPurchases] = useState([]) const [selectedPurchaseId, setSelectedPurchaseId] = useState(null) + // Whether any mailbox can actually be searched. Counting rows would not + // answer that: a revoked or expired connection is still a row, and the hunt + // skips it, so the button would promise a search that returns nothing every + // pass. A dead mailbox looking healthy is the exact failure this feature + // exists to surface, so it must not start by doing it in its own header. + const [mailConnected, setMailConnected] = useState(false) + useEffect(() => { + void (async () => { + try { + const res = await fetch('/api/extensions/ext/mail/connections') + if (!res.ok) return + const json = (await res.json()) as { + data?: { connections?: { status?: string }[] } + } + setMailConnected((json.data?.connections ?? []).some((c) => c.status === 'active')) + } catch { + // The extension may not be enabled at all; stay quiet. + } + })() + }, []) + const fetchPurchases = useCallback(async () => { try { const res = await fetch('/api/extensions/ext/invoice-inbox/purchases') @@ -463,6 +485,18 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { void fetchPurchases() }, [fetchPurchases]) + // A pass can attach a document to a purchase, which moves a row from one + // list to the other, so both refresh as the run goes rather than at the end. + const { + hunt, + stop: stopHunt, + hunting, + progress: huntProgress, + } = useReceiptHunt(() => { + void fetchItems() + void fetchPurchases() + }) + const selectedPurchase = useMemo( () => purchases.find((p) => p.id === selectedPurchaseId) ?? null, [purchases, selectedPurchaseId], @@ -924,6 +958,26 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { className="hidden" onChange={handleFileInputChange} /> + {/* The hunt lived only in Settings, so the button that fills this + page sat on a different page. It runs in passes and reports as it + goes, because a backlog does not clear in one request. */} + {mailConnected && ( + + )} ) : null} diff --git a/components/extensions/general/use-receipt-hunt.ts b/components/extensions/general/use-receipt-hunt.ts new file mode 100644 index 00000000..14ef464d --- /dev/null +++ b/components/extensions/general/use-receipt-hunt.ts @@ -0,0 +1,98 @@ +'use client' + +/** + * Running the receipt hunt from a button. + * + * The loop was written for the settings panel and lived there. It belongs to + * neither surface: the Underlag page is where a person notices that receipts + * are missing, and asking them to walk to Settings to press the button that + * fixes it is the kind of seam that makes a feature look broken. Both call this + * now, so there is one definition of what a run does and when it stops. + * + * Why a client loop rather than one long request: a single pass is bounded by + * the serverless ceiling, and each receipt costs a download plus a model read, + * which is far too slow to clear a backlog inside one invocation. A queue + * drained by cron would be the other option, but the finest schedule this app + * runs is hourly, so pressing the button would mean waiting an hour. + * + * It stops when a pass finds nothing new. That is the honest signal that the + * mailboxes hold nothing more for the purchases still open, and it is why the + * cap below is a backstop rather than a budget. + */ +import { useCallback, useRef, useState } from 'react' + +export interface HuntResult { + searched: number + fetched: number + proposed: number + remaining: number + failed?: boolean +} + +export interface HuntProgress { + passes: number + fetched: number + proposed: number +} + +/** + * Each pass fetches a few receipts, so this is far more than any real backlog + * needs. It exists so a pass that keeps reporting work it never completes + * cannot run forever. + */ +export const MAX_PASSES = 25 + +export function useReceiptHunt(onPass?: () => void) { + const [hunting, setHunting] = useState(false) + const [progress, setProgress] = useState(null) + const [result, setResult] = useState(null) + const stopped = useRef(false) + + const stop = useCallback(() => { + stopped.current = true + }, []) + + const hunt = useCallback(async () => { + setHunting(true) + setResult(null) + stopped.current = false + + let passes = 0 + let fetched = 0 + let proposed = 0 + + try { + while (!stopped.current && passes < MAX_PASSES) { + const response = await fetch('/api/receipt-hunt/run', { method: 'POST' }) + if (!response.ok) { + setResult({ searched: 0, fetched, proposed, remaining: 0, failed: true }) + return + } + + const body = (await response.json()) as { data: HuntResult } + passes++ + fetched += body.data.fetched + proposed += body.data.proposed + setProgress({ passes, fetched, proposed }) + // Let the caller refresh whatever the pass just changed, so a long run + // fills the list as it goes instead of all at once at the end. + onPass?.() + + // Nothing new this pass: the mailboxes have no more for what is open. + if (body.data.fetched === 0) { + setResult({ ...body.data, fetched, proposed }) + return + } + } + + setResult({ searched: 0, fetched, proposed, remaining: 0 }) + } catch { + setResult({ searched: 0, fetched, proposed, remaining: 0, failed: true }) + } finally { + setHunting(false) + setProgress(null) + } + }, [onPass]) + + return { hunt, stop, hunting, progress, result, setResult } +}