* 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>
99 lines
3.2 KiB
TypeScript
99 lines
3.2 KiB
TypeScript
'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 }
|
|
}
|