このコミットが含まれているのは:
2026-10-26 12:19:43 +09:00
コミット 3e8f209542
31個のファイルの変更、1676行の追加、394行の削除
+52 -38
ファイルの表示
@@ -16,12 +16,15 @@ import { Button } from '@/components/ui/button'
import { countsRequestParams, exportCounts, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay'
import { selectedCountsSummaryFields } from '@/lib/countsState'
import { countsAxisFields, countsResources, countsFieldLabel, readCountsJSON }
from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys'
import { cn, inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsAxis } from '@/lib/countsDefinitions'
import type { CountsResource } from '@/lib/counts'
@@ -40,19 +43,21 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const mode = params.get ('mode') ?? 'summary'
const state = isCountsState (params)
const cacheParams = new URLSearchParams (request)
cacheParams.set ('basis', state ? 'state' : 'history')
cacheParams.set ('definition', state ? 'latest' : 'history')
const [format, setFormat] = useState<'json' | 'csv'> ('json')
const [exporting, setExporting] = useState (false)
const [exportError, setExportError] = useState (false)
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
enabled: params.has ('timezone'),
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 ('axis') === params.get ('axis')
&& previousParams.get ('basis') === cacheParams.get ('basis')) ? previous : undefined
&& previousParams.get ('rows') === params.get ('rows')
&& previousParams.get ('cols') === params.get ('cols')
&& previousParams.get ('definition') === cacheParams.get ('definition')) ? previous : undefined
} })
useEffect (() => {
@@ -61,12 +66,21 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
}, [canonical, source, setParams])
useEffect (() => {
if (data?.at == null || isPlaceholderData) return
const next = new URLSearchParams (canonical)
if (!next.get ('at')) next.set ('at', data.at)
const timezone = Intl.DateTimeFormat ().resolvedOptions ().timeZone
if (params.get ('timezone') !== timezone)
{
const next = new URLSearchParams (canonical)
next.set ('timezone', timezone)
setParams (next, { replace: true })
}
else if (data?.executionAt != null && !isPlaceholderData && !params.has ('execution_at'))
{
const next = new URLSearchParams (canonical)
next.set ('execution_at', data.executionAt)
setParams (next, { replace: true })
}
}, [canonical, data, isPlaceholderData, params, setParams])
if (next.toString () !== canonical) setParams (next, { replace: true })
}, [canonical, data, isPlaceholderData, setParams])
const update = (key: string, value: string) => {
const next = new URLSearchParams (params)
@@ -80,18 +94,19 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
setExportError (false)
try
{
const fields = mode === 'summary' ? selectedCountsSummaryFields (resource, params)
const fields = mode === 'summary' ? [
...[...readCountsJSON<CountsAxis[]> (params, 'rows', []),
...readCountsJSON<CountsAxis[]> (params, 'cols', [])].map (axis => ({
key: axis.field, label: countsFieldLabel (axis.field) })),
{ key: 'count', label: 'count' }]
: selectedCountColumns (resource, params).map (column =>
fieldsForCounts (resource).find (field => field.key === column)!)
const file = await exportCounts (resource, params, locale, format,
await exportCounts (resource, params, locale, format,
fields.map (field => field.key), fields.map (field => message (field.label).text),
countsExportLabels (resource, locale))
const url = URL.createObjectURL (file)
const link = document.createElement ('a')
link.href = url
link.download = `counts-${resource}.${format}`
link.click ()
setTimeout (() => URL.revokeObjectURL (url), 1000)
{ ...countsExportLabels (resource, locale),
...Object.fromEntries (countsAxisFields (resource, !state).map (field => [
field.key, message (field.label).text])), count: message ('count').text })
}
catch
{
@@ -117,17 +132,23 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
lang={message ('target').language}>
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
<ul className="space-y-1">
{(['posts', 'tags'] as const).map (target => {
const next = new URLSearchParams (params)
for (const key of ['resource_id', 'tag', 'columns', 'summary_columns',
'sort', 'page', 'children', 'child_sort']) next.delete (key)
return <li key={target}><PrefetchLink
to={`/counts/${target}?${normaliseCountsParams (target, next)}`}
aria-current={target === resource ? 'page' : undefined}
className={cn ('block py-1', target === resource && 'font-bold underline')}>
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
</PrefetchLink></li>
})}
{countsResources.map (target => <li key={target}>
<span>{label (`resources.${target}`)}</span>
<ul className="ml-3">
{['latest', 'history'].map (definition => {
const next = new URLSearchParams ({ definition, mode })
for (const key of ['timezone', 'at', 'execution_at'])
if (params.get (key)) next.set (key, params.get (key)!)
const active = target === resource
&& params.get ('definition') === definition
return <li key={definition}><PrefetchLink
to={`/counts/${target}?${next}`}
aria-current={active ? 'page' : undefined}
className={cn ('block py-1', active && 'font-bold underline')}>
{label (definition)}</PrefetchLink></li>
})}
</ul>
</li>)}
</ul>
</nav>
</SidebarComponent>
@@ -153,7 +174,7 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
</select>
<Button type="button" variant="outline" size="sm"
disabled={data == null || isError || isPlaceholderData
|| exporting || !params.get ('at')}
|| exporting || !params.get ('execution_at')}
onClick={() => void download ()}>{label ('export')}</Button>
</>}/>
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
@@ -161,14 +182,7 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{data != null && <>
{data.at && <div className="my-1 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span>{label ('state.boundary')}:{data.at.replace ('T', ' ')}</span>
{data.totals && (['posts', 'tags', 'assignments'] as const).map (key => (
<span key={key}>{label (`state.totals.${key}`)}:
{data.totals![key] ?? '—'}
</span>))}
</div>}
{!state && <p className="my-1 text-xs">{label ('total', { count: data.count })}</p>}
<p className="my-1 text-xs">{label ('recordTotal', { count: data.count })}</p>
{mode === 'summary' ? <CountsSummaryResults resource={resource}
data={data} params={params}
pending={isPlaceholderData} update={update}/> : (