このコミットが含まれているのは:
2026-10-27 01:11:13 +09:00
コミット 30b5fe5f05
17個のファイルの変更、766行の追加、168行の削除
+190 -61
ファイルの表示
@@ -15,12 +15,12 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
import { countsRequestParams, defaultCountsMode, countsExportPayload, fieldsForCounts, fetchCounts,
import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay'
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
countsResources, countsFieldLabel,
fetchCountsDefinition, useCountsDefinition }
fetchCountsDefinition }
from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
@@ -31,19 +31,44 @@ import { useUITranslation } from '@/lib/useUITranslation'
import { setCountState, readCountState } from '@/lib/queryState'
import type { CountsAxis } from '@/lib/countsDefinitions'
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { CountsResource } from '@/lib/counts'
type PendingCountsRun = {
key: ReturnType<typeof countsKeys.index>
origin: string
params: URLSearchParams
target?: string }
const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
const definition = useCountsDefinition ()
const CountsResourceContent = (
{ resource, definition, definitionError }: {
resource: CountsResource; definition?: CountsDefinition; definitionError: boolean },
) => {
const queryClient = useQueryClient ()
const [source, setParams] = useSearchParams ()
const [timezone, setTimezone] = useState<string | null> (null)
useEffect (() => {
setTimezone (Intl.DateTimeFormat ().resolvedOptions ().timeZone)
}, [])
const params = normaliseCountsParams (resource, source, definition)
if (!params.has ('timezone') && timezone != null) params.set ('timezone', timezone)
const canonical = params.toString ()
const request = countsRequestParams (params)
const [open, setOpen] = useState (false)
const controlsArea = useRef<HTMLDivElement> (null)
const [controlsHeight, setControlsHeight] = useState (80)
useEffect (() => {
const node = controlsArea.current
if (definition == null || node == null) return
const measure = () => setControlsHeight (node.getBoundingClientRect ().height)
measure ()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver (measure)
observer.observe (node)
return () => observer.disconnect ()
}, [definition])
const [sidebarOpen, setSidebarOpen] = useState (false)
const [expanded, setExpanded] = useState (() => new Set<string> ([resource]))
const motion = useClientMotion ()
@@ -57,42 +82,138 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
const state = isCountsState (params)
const cacheParams = new URLSearchParams (request)
cacheParams.set ('definition', state ? 'latest' : 'history')
const [running, setRunning] = useState (false)
const [runError, setRunError] = useState (false)
const pendingRun = useRef<PendingCountsRun | null> (null)
const applied = useRef (params)
const retainedParams = useRef (params)
const requestKey = request.toString ()
const [exporting, setExporting] = useState (false)
const exportInFlight = useRef (false)
const [exportError, setExportError] = useState (false)
const { data, dataUpdatedAt, isFetching, isPending, isError,
isPlaceholderData, refetch } = useQuery ({
enabled: params.has ('timezone'),
isPlaceholderData } = useQuery ({
enabled: definition != null && params.has ('timezone') && !running,
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
queryFn: () => fetchCounts (resource, params, locale), retry: false,
placeholderData: (previous, query) => {
const previousParams = new URLSearchParams (String (query?.queryKey[3] ?? ''))
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
&& previousParams.get ('mode') === mode
&& previousParams.get ('rows') === params.get ('rows')
&& previousParams.get ('cols') === params.get ('cols')
&& previousParams.get ('definition') === cacheParams.get ('definition'))
? previous : undefined
} })
queryFn: ({ signal }) => fetchCounts (resource, params, locale, signal), retry: false,
placeholderData: (previous, query) =>
(query?.queryKey[1] === resource && query?.queryKey[2] === locale
&& queryClient.getQueryData (query.queryKey) === previous)
? previous : undefined })
useEffect (() => {
applied.current = params
if (data != null && !isPlaceholderData && !isError) retainedParams.current = params
const pending = pendingRun.current
if (pending == null) return
if (pending.target === canonical)
{
pendingRun.current = null
setRunning (false)
}
else if (pending.origin !== requestKey)
{
pendingRun.current = null
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
setRunning (false)
}
}, [canonical, data, isError, isPlaceholderData, params, queryClient, requestKey])
useEffect (() => {
setRunning (false)
setRunError (false)
return () => {
const pending = pendingRun.current
pendingRun.current = null
if (pending != null)
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
}
}, [locale, queryClient, resource])
useEffect (() => {
if (running || data == null || isPlaceholderData || isFetching || isError
|| !params.has ('timezone')) return
const next = new URLSearchParams (canonical)
if (!next.has ('timezone'))
next.set ('timezone', Intl.DateTimeFormat ().resolvedOptions ().timeZone)
if (data?.executionAt != null && !isPlaceholderData && !isFetching && !isError
&& params.has ('timezone') && !params.has ('execution_at'))
if (data.executionAt != null && !params.has ('execution_at'))
{
next.set ('execution_at', data.executionAt)
// Pin the response under its server-confirmed time before changing the URL.
// Commit initial defaults, timezone and server time together after acquisition.
queryClient.setQueryData (
countsKeys.index (resource, countsRequestParams (next).toString (), locale),
data, { updatedAt: dataUpdatedAt })
countsKeys.index (resource, countsRequestParams (next).toString (), locale),
data, { updatedAt: dataUpdatedAt })
}
if (source.toString () !== next.toString ())
setParams (next, { replace: true })
setParams (next, { replace: true, preventScrollReset: true })
}, [canonical, data, dataUpdatedAt, isError, isFetching, isPlaceholderData,
locale, params, queryClient, resource, setParams, source])
locale, params, queryClient, resource, running, setParams, source])
const execute = async (draft: URLSearchParams) => {
if (pendingRun.current != null || definition == null) return
const next = normaliseCountsParams (resource, draft, definition)
next.delete ('execution_at')
if (!next.has ('timezone'))
next.set ('timezone', Intl.DateTimeFormat ().resolvedOptions ().timeZone)
const pending: PendingCountsRun = {
key: countsKeys.index (resource, countsRequestParams (next).toString (), locale),
origin: requestKey, params: new URLSearchParams (next) }
const previous = new URLSearchParams (params)
pendingRun.current = pending
setRunError (false)
setRunning (true)
try
{
const result = await queryClient.fetchQuery ({ queryKey: pending.key,
queryFn: ({ signal }) => fetchCounts (resource, next, locale, signal),
staleTime: 0, retry: false })
if (pendingRun.current !== pending) return
if (result.executionAt == null) throw new Error ('Missing Counts execution time')
// Preserve presentation changes made while this execution was in flight.
for (const key of ['columns', 'children', 'child_sort', 'order', 'nested_json', 'row_numbers'])
{
if (previous.get (key) === applied.current.get (key)) continue
if (key === 'columns')
{
const columns = selectedCountColumns (resource, next, definition)
.filter (column => column !== 'thumbnail')
const thumbnail = selectedCountColumns (resource, applied.current, definition)
.includes ('thumbnail')
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (' '))
continue
}
next.delete (key)
for (const value of applied.current.getAll (key)) next.append (key, value)
}
const committed = normaliseCountsParams (resource, next, definition)
committed.set ('execution_at', result.executionAt)
queryClient.setQueryData (
countsKeys.index (resource, countsRequestParams (committed).toString (), locale), result)
pending.target = committed.toString ()
// Commit only after the complete result is available under the final URL's key.
if (pending.target === canonical)
{
pendingRun.current = null
setRunning (false)
}
else setParams (committed, { preventScrollReset: true })
}
catch
{
if (pendingRun.current !== pending) return
pendingRun.current = null
setRunning (false)
setRunError (true)
}
}
const resultParams = isPlaceholderData ? retainedParams.current : params
const resultMode = resultParams.get ('mode') ?? 'summary'
const waiting = running || (!definitionError && (definition == null
|| isPending || isFetching || isPlaceholderData
|| (!params.has ('execution_at') && !isError)))
const loadingParams = pendingRun.current?.params ?? params
const compatible = data != null && ['definition', 'mode', 'rows', 'cols'].every (
key => loadingParams.get (key) === resultParams.get (key))
const update = (key: string, value: string, extra?: Record<string, string>) => {
@@ -110,7 +231,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
setParams (next)
}
const download = async (format: 'csv' | 'json') => {
if (exportInFlight.current) return
if (exportInFlight.current || pendingRun.current != null || definition == null) return
exportInFlight.current = true
setExporting (true)
setExportError (false)
@@ -160,7 +281,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
aria-expanded={sidebarOpen} aria-controls="counts-targets"
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
<SidebarComponent sidebarKey="counts" maxWidthRatio={.5}
<SidebarComponent sidebarKey="counts" maxWidthRatio={.5} animateLayout={false}
className={cn ('md:block', !sidebarOpen && 'hidden')}>
<nav id="counts-targets" aria-label={message ('target').text}
lang={message ('target').language}>
@@ -184,11 +305,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
</button>
<AnimatedCollapse open={expanded.has (target)} id={`counts-definition-${target}`}>
<ul className="ml-3">
{['latest', 'history'].map (definition => {
const next = new URLSearchParams ({
definition, mode: defaultCountsMode (definition) })
for (const key of ['timezone', 'at', 'current', 'row_numbers'])
if (params.get (key)) next.set (key, params.get (key)!)
{(['latest', 'history'] as const).map (definition => {
const next = countsDefinitionParams (definition, params)
const active = target === resource
&& params.get ('definition') === definition
return <li key={definition}><PrefetchLink
@@ -203,11 +321,13 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
</ul>
</nav>
</SidebarComponent>
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
<MainArea animateLayout={false} className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
<div className="shrink-0">
<CountsControls
<div ref={controlsArea} className="shrink-0"
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
resource={resource} applied={params}
pending={running || isFetching || !params.has ('execution_at')}
open={open} setOpen={setOpen}
reorder={value => update ('order', value)}
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
@@ -217,36 +337,48 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
.filter (column => column !== 'thumbnail')
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (' '))
}}
execute={next => {
if (next.toString () === canonical) void refetch ()
else setParams (next)
}} actions={<div className="ml-auto flex shrink-0 items-center gap-2">
execute={next => void execute (next)}
actions={<div className="ml-auto flex shrink-0 items-center gap-2">
{(['csv', 'json'] as const).map (format => <Button key={format}
type="button" variant="outline" size="sm"
disabled={data == null || isError || isPlaceholderData
|| exporting || !params.get ('execution_at')}
|| running || isFetching || exporting || !params.get ('execution_at')}
onClick={() => void download (format)}>
{label (`exportFormats.${format}`)}</Button>)}
</div>}/>
</div>}/>}
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
</div>
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{data != null && <>
<p className="my-1 flex flex-wrap gap-x-3 text-xs">
<span>{label ('recordTotal', { count: data.count })}</span>
{data.populationCount != null && <span>{label ('total', {
count: data.populationCount })}</span>}
</p>
{mode === 'summary' ? <CountsSummaryResults resource={resource}
data={data} params={params}
pending={isPlaceholderData} update={update}/> : (
<CountsResults resource={resource} data={data} params={params}
pending={isPlaceholderData} update={update}/>)}
{(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
aria-busy={waiting}>
<div inert={waiting} aria-hidden={waiting || undefined}
className={cn ('flex min-h-0 flex-1 flex-col',
waiting && (compatible ? 'pointer-events-none opacity-40' : 'invisible'))}>
{definition != null && data != null && <>
{data.excludedCount != null && (
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{label ('excludedRecords', { count: data.excludedCount })}</span>
</p>)}
{resultMode === 'summary' ? <CountsSummaryResults resource={resource}
data={data} params={resultParams}
pending={waiting} update={update}/> : (
<CountsResults resource={resource} data={data} params={resultParams}
pending={waiting} update={update}/>)}
<Pagination page={data.page} totalPages={Math.ceil (
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
(resultMode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
</>}
</div>
{waiting && <div role="status" aria-live="polite"
className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="rounded border border-slate-300 bg-white px-2 py-1
text-sm text-slate-900 dark:border-slate-600
dark:bg-slate-950 dark:text-slate-100">
{label ('shared:messages.loadingEllipsis')}</span>
</div>}
</div>
</MainArea>
</div>
</CountsTableLayout>)
@@ -254,14 +386,11 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const { label } = useUITranslation ('counts')
const { data, isError } = useQuery ({
queryKey: countsKeys.definition (resource),
queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
if (data == null) return <p>{label (
isError ? 'common:fetchError' : 'shared:messages.loadingEllipsis')}</p>
return <CountsDefinitionContext.Provider value={data}>
<CountsResourceContent resource={resource}/>
return <CountsDefinitionContext.Provider value={data ?? null}>
<CountsResourceContent resource={resource} definition={data} definitionError={isError}/>
</CountsDefinitionContext.Provider>
}