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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
11b82cbb91
commit
bf220eac3c
@@ -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<PurchaseWithoutUnderlag[]>([])
|
||||
const [selectedPurchaseId, setSelectedPurchaseId] = useState<string | null>(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 && (
|
||||
<Button variant="ghost" size="sm" onClick={hunting ? stopHunt : hunt} disabled={false}>
|
||||
{hunting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
{huntProgress
|
||||
? `Letar… ${huntProgress.fetched} hittade`
|
||||
: 'Letar…'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-3.5 w-3.5 mr-1.5" />
|
||||
Leta i mejlen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -15,14 +15,6 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
|
||||
interface HuntResult {
|
||||
searched: number
|
||||
fetched: number
|
||||
proposed: number
|
||||
remaining: number
|
||||
failed?: boolean
|
||||
}
|
||||
|
||||
interface MailConnection {
|
||||
id: string
|
||||
provider: 'gmail' | 'microsoft'
|
||||
@@ -33,14 +25,9 @@ interface MailConnection {
|
||||
lastErrorCode: string | null
|
||||
}
|
||||
|
||||
const BASE = '/api/extensions/ext/mail'
|
||||
import { useReceiptHunt } from '@/components/extensions/general/use-receipt-hunt'
|
||||
|
||||
/**
|
||||
* Backstop on the loop. 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.
|
||||
*/
|
||||
const MAX_PASSES = 25
|
||||
const BASE = '/api/extensions/ext/mail'
|
||||
|
||||
export function MailConnectionsPanel() {
|
||||
const t = useTranslations('mail')
|
||||
@@ -49,12 +36,9 @@ export function MailConnectionsPanel() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [pendingDisconnect, setPendingDisconnect] = useState<MailConnection | null>(null)
|
||||
const [hunting, setHunting] = useState(false)
|
||||
const [huntResult, setHuntResult] = useState<HuntResult | null>(null)
|
||||
const [progress, setProgress] = useState<{ passes: number; fetched: number; proposed: number } | null>(null)
|
||||
const { hunt, stop: stopHunt, hunting, progress, result: huntResult } = useReceiptHunt(() => void load())
|
||||
// Read inside the loop, so pressing Stop takes effect on the current pass
|
||||
// rather than after every remaining pass has run.
|
||||
const stopped = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -114,46 +98,6 @@ export function MailConnectionsPanel() {
|
||||
* the mailboxes hold nothing more for the purchases still open. The cap is a
|
||||
* backstop against a pass that keeps reporting work it cannot finish.
|
||||
*/
|
||||
async function hunt() {
|
||||
setHunting(true)
|
||||
setHuntResult(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) {
|
||||
setHuntResult({ 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 })
|
||||
void load()
|
||||
|
||||
// Nothing new this pass: the mailboxes have no more for what is open.
|
||||
if (body.data.fetched === 0) {
|
||||
setHuntResult({ ...body.data, fetched, proposed })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setHuntResult({ searched: 0, fetched, proposed, remaining: 0 })
|
||||
} catch {
|
||||
setHuntResult({ searched: 0, fetched, proposed, remaining: 0, failed: true })
|
||||
} finally {
|
||||
setHunting(false)
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function disconnect(connection: MailConnection) {
|
||||
await fetch(`${BASE}/connections?id=${encodeURIComponent(connection.id)}`, { method: 'DELETE' })
|
||||
@@ -218,7 +162,7 @@ export function MailConnectionsPanel() {
|
||||
</SettingsRowNote>
|
||||
) : null}
|
||||
{hunting ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => { stopped.current = true }}>
|
||||
<Button variant="ghost" size="sm" onClick={stopHunt}>
|
||||
{t('hunt_stop')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -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<HuntProgress | null>(null)
|
||||
const [result, setResult] = useState<HuntResult | null>(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 }
|
||||
}
|
||||
Reference in New Issue
Block a user