このコミットが含まれているのは:
2026-10-25 22:16:20 +09:00
コミット 96a5bb8601
12個のファイルの変更、600行の追加、25行の削除
+16 -9
ファイルの表示
@@ -1,10 +1,11 @@
import { useEffect, useId, useState } from 'react'
import CountsStateFilters from '@/components/counts/CountsStateFilters'
import CountsFieldTree from '@/components/counts/CountsFieldTree'
import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import { normaliseCountsParams } from '@/lib/counts'
import { isCountsState, normaliseCountsParams } from '@/lib/counts'
import { fetchTagByName } from '@/lib/tags'
import { inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
@@ -42,7 +43,8 @@ const CountsControls = (
}, [order, nestedJSON])
const [error, setError] = useState (false)
const [pending, setPending] = useState (false)
const mode = draft.get ('mode') ?? 'summary'
const mode = draft.get ('mode') ?? 'state'
const state = isCountsState (draft)
const update = (key: string, value: string) => {
setDraft (previous => {
const next = new URLSearchParams (previous)
@@ -58,11 +60,12 @@ const CountsControls = (
try
{
const next = new URLSearchParams (draft)
for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
for (const key of (state || resource === 'posts') ? ['tag'] : ['resource_id'])
{
const value = next.get (key)?.trim () ?? ''
if (value && !/^[1-9][0-9]*$/.test (value))
{
if (state && next.get ('kind') === 'external') throw new Error ()
const tag = await fetchTagByName (value, locale)
if (tag == null)
throw new Error ()
@@ -96,10 +99,10 @@ const CountsControls = (
<select className={inputClass (false, 'w-auto py-1')}
aria-label={message ('mode').text} lang={message ('mode').language}
value={mode} onChange={event => update ('mode', event.target.value)}>
<option value="summary" lang={message ('summary').language}>
{message ('summary').text}</option>
<option value="details" lang={message ('details').language}>
{message ('details').text}</option>
{[['state', 'summary'], ['trend', 'state.trend'], ['snapshot', 'state.details'],
['summary', 'state.history'], ['details', 'state.historyDetails']].map (
([value, key]) => <option key={value} value={value} lang={message (key).language}>
{message (key).text}</option>)}
</select>
</div>
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
@@ -115,7 +118,9 @@ const CountsControls = (
}}/>
{label ('nested.asJSON')}
</label>
{mode === 'summary' ? (
{(mode === 'state' || mode === 'trend') ? (
<p className="text-sm">{label (
mode === 'trend' ? 'state.trend' : 'state.list')}</p>) : (mode === 'summary' ? (
<label className="flex flex-wrap items-center gap-2">
{label ('axis')}
<select className={inputClass (false, 'w-auto py-1')}
@@ -130,10 +135,11 @@ const CountsControls = (
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
update (key, value)
if (key === 'order') reorder (value)
}}/>)}
}}/>))}
</fieldset>
<fieldset className="min-w-0 space-y-2" data-counts-filters>
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
{state ? <CountsStateFilters params={draft} update={update}/> : <>
<label className="flex items-center gap-1">
<input type="checkbox" checked={draft.get ('latest') !== '0'}
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
@@ -161,6 +167,7 @@ const CountsControls = (
className={inputClass (false, 'py-1')}/>}
</FormField>))}
</div>
</>}
</fieldset>
</div>
</div>
+63
ファイルの表示
@@ -0,0 +1,63 @@
import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
const CountsStateFilters = (
{ params, update }: {
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { label, message } = useUITranslation ('counts')
const trend = params.get ('mode') === 'trend'
const dates = trend ? ['from', 'before'] : ['at']
return (
<>
<div className="grid gap-2 md:grid-cols-2">
{dates.map (key => {
const text = key === 'at' ? 'state.at' : `fields.${key}`
return <FormField key={key} label={label (text)}>
{() => <DateTimeField value={params.get (key) ?? ''}
aria-label={message (text).text} lang={message (text).language}
onChange={value => update (key, value ?? '')}/>}
</FormField>
})}
</div>
{trend && <FormField label={label ('state.grain')}>
{() => <select value={params.get ('grain') ?? 'month'}
className={inputClass (false, 'py-1')}
aria-label={message ('state.grain').text}
onChange={event => update ('grain', event.target.value)}>
{['day', 'month', 'year'].map (grain => (
<option key={grain} value={grain} lang={message (`state.grains.${grain}`).language}>
{message (`state.grains.${grain}`).text}</option>))}
</select>}
</FormField>}
<div className="grid gap-2 md:grid-cols-2">
<FormField label={label ('columns.kind')}>
{() => <select value={params.get ('kind') ?? 'internal'}
className={inputClass (false, 'py-1')} aria-label={message ('columns.kind').text}
onChange={event => update ('kind', event.target.value)}>
{['internal', 'external'].map (kind => (
<option key={kind} value={kind} lang={message (`kinds.${kind}`).language}>
{message (`kinds.${kind}`).text}</option>))}
</select>}
</FormField>
<FormField label={label ('fields.tag')}>
{() => <input value={params.get ('tag') ?? ''} required={trend}
aria-label={message ('fields.tag').text} className={inputClass (false, 'py-1')}
onChange={event => update ('tag', event.target.value)}/>}
</FormField>
<FormField label={label ('fields.limit')}>
{() => <input type="number" min={1} max={100}
value={params.get ('limit') ?? '20'} aria-label={message ('fields.limit').text}
className={inputClass (false, 'py-1')}
onChange={event => update ('limit', event.target.value)}/>}
</FormField>
</div>
</>)
}
export default CountsStateFilters
+56
ファイルの表示
@@ -0,0 +1,56 @@
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, readCountSort, writeCountSort } from '@/lib/counts'
import { countsStateCell, countsStateDrill, countsStateFields } from '@/lib/countsState'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResult } from '@/lib/counts'
const CountsStateResults = (
{ data, params, pending, update }: {
data: CountsResult
params: URLSearchParams
pending: boolean
update: (key: string, value: string) => void },
) => {
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const trend = params.get ('mode') === 'trend'
const fields = countsStateFields (trend)
const sorts = readCountSort (params.get ('sort') ?? (trend ? 'period:asc' : 'count:desc'))
return (
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
<CountsSelectableTable family={trend ? 'state_trend' : 'state_tags'}
columns={fields.map (field => field.key)} identity={params.toString ()}
className="text-left text-sm">
<thead className="sticky top-0 z-20"><tr>
{fields.map (field => (
<CountsSortHeader key={field.key} field={field.key} label={message (field.label)}
sorts={sorts} change={(key, additive) => update (
'sort', writeCountSort (changeCountSort (sorts, key, additive)))}/>))}
</tr></thead>
<tbody>{(data.stateRows ?? []).map (row => (
<tr key={`${row.kind}:${row.tagId}:${row.at}`}>
{fields.map (field => {
const value = countsStateCell (row, field.key, locale)
return <td key={field.key} data-counts-copy={value}
className="break-words border-b px-2 py-1 dark:border-slate-700">
{field.key === 'count' && row.count != null
? <PrefetchLink to={countsStateDrill (params, row.at, 'posts', row)}>
{value}</PrefetchLink>
: <LocalisedText language={message (field.label).language}>
{value}</LocalisedText>}
</td>
})}
</tr>))}</tbody>
</CountsSelectableTable>
{data.stateRows?.length === 0 && <p className="p-2">{label ('empty')}</p>}
</div>)
}
export default CountsStateResults
+39 -4
ファイルの表示
@@ -36,13 +36,35 @@ export type CountsVersion = {
category?: string
aliases?: { languageCode: string; name: string; scriptCode: string }[]
deprecated?: string | null }
export type CountsStateRow = {
kind: 'internal' | 'external'
tagId: number
name: string | null
deprecated?: string | null
period?: string
at: string
count: number | null }
export type CountsStateTotals = {
posts: number | null
tags: number | null
assignments: number | null
deprecatedTags: number | null
externalAssignments: number | null }
export type CountsResult = {
count: number
page: number
limit: number
groupCount?: number
groups?: { key: string | number | null; name: string | null; count: number }[]
versions?: CountsVersion[] }
versions?: CountsVersion[]
at?: string
from?: string
before?: string
grain?: string
totals?: CountsStateTotals
stateRows?: CountsStateRow[]
coverage?: { basis: string; exact: boolean
postHistoryFrom: string | null; tagHistoryFrom: string | null } }
export type CountSort = { key: string; direction: 'asc' | 'desc' }
export type CountField = { key: string; label: string; children?: CountField[] }
export type CountRecord = Record<string, unknown>
@@ -200,6 +222,8 @@ export const normaliseCountsParams = (
): URLSearchParams => {
const params = new URLSearchParams (source)
params.delete ('event_type')
if (!params.has ('mode'))
params.set ('mode', source.has ('axis') ? 'summary' : 'state')
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
params.set ('axis', 'month')
if (params.has ('columns'))
@@ -212,9 +236,18 @@ export const normaliseCountsParams = (
}
export const isCountsState = (params: URLSearchParams): boolean =>
['state', 'trend', 'snapshot'].includes (params.get ('mode') ?? 'state')
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
const result = new URLSearchParams (params)
for (const key of ['columns', 'children', 'child_sort', 'nested_json', 'order'])
const hidden = isCountsState (params)
? ['axis', 'latest', 'user_id', 'resource_id']
: ['at', 'grain', 'kind']
if (isCountsState (params) && params.get ('mode') !== 'trend')
hidden.push ('from', 'before')
for (const key of ['columns', 'children', 'child_sort', 'nested_json', 'order', ...hidden])
result.delete (key)
return result
}
@@ -224,8 +257,10 @@ export const fetchCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
): Promise<CountsResult> => apiGet (`/counts/${resource}`, {
locale, params: Object.fromEntries (countsRequestParams (params)) })
): Promise<CountsResult> => apiGet (isCountsState (params) ? '/counts/state' : `/counts/${resource}`, {
locale, params: { ...Object.fromEntries (countsRequestParams (params)),
...(isCountsState (params) ? { resource } : { }) } })
const stableValue = (value: unknown): unknown => {
+52
ファイルの表示
@@ -0,0 +1,52 @@
import { translateUIMessage } from '@/lib/i18n'
import type { CountField, CountsStateRow } from '@/lib/counts'
import type { LocaleCode } from '@/lib/locales'
export const countsStateFields = (trend: boolean): CountField[] => trend
? [{ key: 'period', label: 'counts:state.period' },
{ key: 'count', label: 'counts:state.postCount' }]
: [{ key: 'kind', label: 'counts:columns.kind' },
{ key: 'tag_id', label: 'counts:columns.resourceId' },
{ key: 'name', label: 'history:tagName' },
{ key: 'deprecated', label: 'counts:columns.deprecated' },
{ key: 'count', label: 'counts:state.postCount' }]
export const countsStateCell = (
row: CountsStateRow,
key: string,
locale: LocaleCode,
): string => {
switch (key)
{
case 'period': return row.period ?? ''
case 'count': return row.count == null ? '—' : String (row.count)
case 'tag_id': return String (row.tagId)
case 'kind': return translateUIMessage (`kinds.${row.kind}`, 'counts', locale).text
case 'name': return row.name ?? translateUIMessage ('notRegistered', 'counts', locale).text
case 'deprecated': return row.deprecated
? translateUIMessage ('common:states.deprecated', 'counts', locale).text : ''
default: return ''
}
}
export const countsStateDrill = (
params: URLSearchParams,
at: string,
resource = 'posts',
row?: CountsStateRow,
): string => {
const next = new URLSearchParams (params)
for (const key of ['sort', 'page', 'tag', 'from', 'before']) next.delete (key)
next.set ('mode', 'snapshot')
next.set ('at', at)
if (row != null)
{
next.set ('kind', row.kind)
next.set ('tag', String (row.tagId))
}
return `/counts/${resource}?${next}`
}
+1 -1
ファイルの表示
@@ -9,7 +9,7 @@ export const defaultCountWidths: Record<string, number> = {
video_ms: 120, original_created_at: 260, original_created_from: 240,
original_created_before: 240, name: 240, names: 480, category: 180,
aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160,
id: 88, versionNo: 88, kind: 140, sections: 260,
period: 200, tag_id: 88, id: 88, versionNo: 88, kind: 140, sections: 260,
languageCode: 180, scriptCode: 200, autoGenerated: 120 }
type Layout = {
+23 -1
ファイルの表示
@@ -89,5 +89,27 @@
"range": "{{begin}}~{{end}}",
"separator": "、"
},
"reorder": "{{column}}:出力項目の並べ替え"
"reorder": "{{column}}:出力項目の並べ替え",
"state": {
"list": "タグ別投稿件数一覧",
"trend": "タグ別投稿件数推移",
"details": "時点明細",
"history": "履歴集計",
"historyDetails": "履歴明細",
"at": "指定時点(端末の時刻帯・未満)",
"boundary": "指定時点(UTC・未満)",
"period": "時点(UTC)",
"grain": "粒度(UTC)",
"grains": {
"day": "日別",
"month": "月別",
"year": "年別"
},
"postCount": "投稿件数",
"totals": {
"posts": "投稿総数",
"tags": "タグ総数(内部タグ)",
"assignments": "タグ付け総件数"
}
}
}
+42 -9
ファイルの表示
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import CountsControls from '@/components/counts/CountsControls'
import CountsStateResults from '@/components/counts/CountsStateResults'
import CountsResults from '@/components/counts/CountsResults'
import CountsTableLayout from '@/components/counts/CountsTableLayout'
import PageHead from '@/components/common/PageHead'
@@ -13,8 +14,9 @@ import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
import { countsCSV, countsRequestParams, fieldsForCounts, fetchCounts,
normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsCSVCell } from '@/lib/countsDisplay'
import { countsStateCell, countsStateDrill, countsStateFields } from '@/lib/countsState'
import { countsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
@@ -35,14 +37,16 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const [sidebarOpen, setSidebarOpen] = useState (false)
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const mode = params.get ('mode') ?? 'summary'
const mode = params.get ('mode') ?? 'state'
const state = isCountsState (params)
const stateTable = mode === 'state' || mode === 'trend'
const axis = params.get ('axis') ?? 'month'
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
queryKey: countsKeys.index (resource, request.toString (), locale),
queryFn: () => fetchCounts (resource, request, locale), retry: false,
placeholderData: (previous, query) => {
const previousMode = new URLSearchParams (String (query?.queryKey[3] ?? ''))
.get ('mode') ?? 'summary'
.get ('mode') ?? 'state'
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
&& previousMode === mode) ? previous : undefined
} })
@@ -52,6 +56,18 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
setParams (canonical, { replace: true })
}, [canonical, source, setParams])
useEffect (() => {
if (!state || data?.at == null || isPlaceholderData) return
const next = new URLSearchParams (canonical)
if (!next.get ('at')) next.set ('at', data.at)
if (mode === 'trend')
{
if (!next.get ('from') && data.from) next.set ('from', data.from)
if (!next.get ('before') && data.before) next.set ('before', data.before)
}
if (next.toString () !== canonical) setParams (next, { replace: true })
}, [canonical, data, isPlaceholderData, mode, setParams, state])
const update = (key: string, value: string) => {
const next = new URLSearchParams (params)
if (value || key === 'sort') next.set (key, value)
@@ -61,7 +77,12 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
}
const exportCSV = () => {
const columns = selectedCountColumns (resource, params)
const rows = mode === 'summary'
const stateFields = countsStateFields (mode === 'trend')
const rows = stateTable
? [stateFields.map (field => message (field.label).text),
...(data?.stateRows ?? []).map (row => stateFields.map (
field => countsStateCell (row, field.key, locale)))]
: (mode === 'summary'
? [[message (`axes.${axis}`).text, message ('metric').text],
...(data?.groups ?? []).map (group => [
group.key == null ? message ('history:bot').text : group.name ?? String (group.key),
@@ -69,7 +90,7 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
: [columns.map (column => message (
fieldsForCounts (resource).find (field => field.key === column)!.label).text),
...(data?.versions ?? []).map (row => columns.map (
column => countsCSVCell (row, column, resource, params, locale)))]
column => countsCSVCell (row, column, resource, params, locale)))])
const url = URL.createObjectURL (new Blob ([countsCSV (rows)], {
type: 'text/csv;charset=utf-8' }))
const link = document.createElement ('a')
@@ -123,11 +144,23 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{data != null && <>
<p className="my-1 text-xs">{label ('total', { count: data.count })}</p>
<CountsResults resource={resource} data={data} params={params}
pending={isPlaceholderData} update={update}/>
{state && 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}`)}:
{key !== 'assignments' && data.totals![key] != null
? <PrefetchLink to={countsStateDrill (params, data.at!, key)}>
{data.totals![key]}</PrefetchLink>
: data.totals![key] ?? '—'}
</span>))}
</div>}
{!state && <p className="my-1 text-xs">{label ('total', { count: data.count })}</p>}
{stateTable ? <CountsStateResults data={data} params={params}
pending={isPlaceholderData} update={update}/> : (
<CountsResults resource={resource} data={data} params={params}
pending={isPlaceholderData} update={update}/>)}
<Pagination page={data.page} totalPages={Math.ceil (
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
((stateTable || mode === 'summary') ? data.groupCount ?? 0 : data.count) / data.limit)}/>
</>}
</MainArea>
</div>