このコミットが含まれているのは:
2026-10-28 19:28:11 +09:00
コミット 8ea3d211dc
49個のファイルの変更、967行の追加、356行の削除
+47 -9
ファイルの表示
@@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from 'react'
import LocalisedText from '@/components/LocalisedText'
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
import { useClientMotion } from '@/lib/useClientMotion'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn } from '@/lib/utils'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { LocaleCode } from '@/lib/locales'
@@ -10,7 +12,8 @@ import type { LocaleCode } from '@/lib/locales'
export type CountsExportHandle = { cancel: () => Promise<boolean> }
type ExportStatus = {
status: 'starting' | 'queued' | 'running' | 'cancelling' | 'cancelled' | 'ready' | 'failed'
processed: number; total: number | null; token?: string }
processed: number; total: number | null; token?: string
phase?: 'queued' | 'aggregating' | 'writing'; heartbeatAt?: number; updatedAt?: number }
const CountsExportProgress = (
@@ -19,6 +22,7 @@ const CountsExportProgress = (
handle: CountsExportHandle; controls: DialogueFormControls },
) => {
const { label, message } = useUITranslation ('counts')
const motion = useClientMotion ()
const [state, setState] = useState<ExportStatus> ({
status: 'starting', processed: 0, total: null })
const [cancelError, setCancelError] = useState (false)
@@ -27,40 +31,55 @@ const CountsExportProgress = (
const job = useRef<string | null> (null)
const creation = useRef<Promise<{ job: string }> | null> (null)
const downloaded = useRef (false)
const pollRequest = useRef<AbortController | null> (null)
useEffect (() => {
handle.cancel = async () => {
if (terminal.current) return true
cancelled.current = true
if (job.current != null)
{
const controller = new AbortController ()
const deadline = setTimeout (() => controller.abort (), 15_000)
try
{
setCancelError (false)
const result = await apiDelete<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (job.current)}`, { locale })
`/counts/export/jobs/${encodeURIComponent (job.current)}`, {
locale, signal: controller.signal })
setState (result)
}
catch
{
setCancelError (true)
}
finally
{
clearTimeout (deadline)
}
}
return false
}
}, [handle, locale])
useEffect (() => {
let active = true
let lastResponse = Date.now ()
let timer: ReturnType<typeof setTimeout> | undefined
const poll = async (token: string) => {
const controller = new AbortController ()
pollRequest.current = controller
const deadline = setTimeout (() => controller.abort (), 15_000)
try
{
const response = await apiGet<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale })
`/counts/export/jobs/${encodeURIComponent (token)}`, {
locale, signal: controller.signal })
if (!active) return
const result = (cancelled.current && response.status === 'ready')
? await apiDelete<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale }) : response
`/counts/export/jobs/${encodeURIComponent (token)}`, {
locale, signal: controller.signal }) : response
if (!active) return
lastResponse = Date.now ()
setState (result)
terminal.current = ['ready', 'failed', 'cancelled'].includes (result.status)
if (result.status === 'ready' && result.token != null && !downloaded.current)
@@ -76,7 +95,8 @@ const CountsExportProgress = (
{
if (active)
{
if (isApiError (error) && error.response?.status === 404)
if ((isApiError (error) && error.response?.status === 404)
|| Date.now () - lastResponse > 30_000)
{
terminal.current = true
setState (previous => ({ ...previous, status: 'failed' }))
@@ -86,8 +106,19 @@ const CountsExportProgress = (
timer = setTimeout (() => poll (token), 2000)
}
}
finally
{
clearTimeout (deadline)
if (pollRequest.current === controller) pollRequest.current = null
}
}
if (creation.current == null)
{
const controller = new AbortController ()
const deadline = setTimeout (() => controller.abort (), 15_000)
creation.current = apiPost<{ job: string }> ('/counts/export/jobs', payload, {
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
}
creation.current ??= apiPost<{ job: string }> ('/counts/export/jobs', payload, { locale })
creation.current.then (async result => {
if (!active) return
job.current = result.job
@@ -103,6 +134,7 @@ const CountsExportProgress = (
return () => {
active = false
if (timer != null) clearTimeout (timer)
pollRequest.current?.abort ()
}
}, [handle, locale, payload])
useEffect (() => {
@@ -110,11 +142,17 @@ const CountsExportProgress = (
}, [controls])
const percent = state.total == null ? null : (state.total === 0 ? 100
: Math.min (100, Math.floor (state.processed / state.total * 100)))
const phase = message (`exportProgress.${state.status}`)
return <div className="space-y-3 py-2">
const busy = !['ready', 'failed', 'cancelled'].includes (state.status)
const phase = message (`exportProgress.${state.status === 'running'
? state.phase ?? 'running' : state.status}`)
return <div className="space-y-3 py-2" aria-busy={busy}>
<p>{label ('exportProgress.format', {
format: message (`exportFormats.${payload.format}`).text })}</p>
<p role="status" aria-live="polite">
<p role="status" aria-live="polite" className="flex items-center gap-2">
{busy && <span aria-hidden="true" className={cn (
'size-4 shrink-0 rounded-full border-2 border-current border-r-transparent',
!motion.reduced && 'animate-spin')}
style={{ animationDuration: `${motion.duration ('collapse') * 4}ms` }}/>}
<LocalisedText language={phase.language}>{phase.text}</LocalisedText>
</p>
<progress className="h-3 w-full" max={100} value={percent ?? undefined}