このコミットが含まれているのは:
2026-10-25 23:08:48 +09:00
コミット 8642225b94
21個のファイルの変更、920行の追加、360行の削除
+67 -32
ファイルの表示
@@ -1,28 +1,31 @@
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 { isCountsState, normaliseCountsParams } from '@/lib/counts'
import { countsSummaryFields } from '@/lib/countsState'
import { fetchTagByName } from '@/lib/tags'
import { inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { ReactNode } from 'react'
import type { CountsResource } from '@/lib/counts'
const CountsControls = (
{ resource, applied, open, setOpen, execute, displayJSON, reorder }: {
{ resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: {
resource: CountsResource
applied: URLSearchParams
open: boolean
setOpen: (open: boolean) => void
execute: (params: URLSearchParams) => void
displayJSON: (enabled: boolean) => void
reorder: (value: string) => void },
reorder: (value: string) => void
actions?: ReactNode },
) => {
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
@@ -43,13 +46,18 @@ const CountsControls = (
}, [order, nestedJSON])
const [error, setError] = useState (false)
const [pending, setPending] = useState (false)
const mode = draft.get ('mode') ?? 'state'
const mode = draft.get ('mode') ?? 'summary'
const state = isCountsState (draft)
const axes = (state && resource === 'posts') ? ['tags', 'month', 'user'] : ['month', 'user']
const update = (key: string, value: string) => {
setDraft (previous => {
const next = new URLSearchParams (previous)
if (key === 'mode') next.delete ('sort')
if (value || key === 'columns' || key === 'children') next.set (key, value)
if (['mode', 'axis', 'basis'].includes (key)) next.delete ('sort')
if (key === 'axis' || key === 'basis') next.delete ('summary_columns')
if (key === 'basis' && value === 'history' && next.get ('axis') === 'tags')
next.set ('axis', 'month')
if (value || ['columns', 'children', 'summary_columns'].includes (key))
next.set (key, value)
else next.delete (key)
return next
})
@@ -60,7 +68,7 @@ const CountsControls = (
try
{
const next = new URLSearchParams (draft)
for (const key of (state || resource === 'posts') ? ['tag'] : ['resource_id'])
for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
{
const value = next.get (key)?.trim () ?? ''
if (value && !/^[1-9][0-9]*$/.test (value))
@@ -99,14 +107,16 @@ 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)}>
{[['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>)}
{['summary', 'details'].map (value => (
<option key={value} value={value} lang={message (value).language}>
{message (value).text}</option>))}
</select>
{actions}
</div>
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
<div id={id} hidden={!open} className="border-b py-2 dark:border-slate-700">
<div id={id} hidden={!open}
className="max-h-[40dvh] overflow-y-auto overscroll-contain border-b py-2
dark:border-slate-700 md:max-h-[45dvh]">
<div className="grid gap-4 md:grid-cols-2">
<fieldset className="min-w-0" data-counts-output>
<legend className="mb-2 font-semibold">{label ('output')}</legend>
@@ -118,33 +128,59 @@ const CountsControls = (
}}/>
{label ('nested.asJSON')}
</label>
{(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')}
value={draft.get ('axis') ?? 'month'}
aria-label={message ('axis').text} lang={message ('axis').language}
onChange={event => update ('axis', event.target.value)}>
{['month', 'user'].map (axis => (
<option key={axis} value={axis} lang={message (`axes.${axis}`).language}>
{message (`axes.${axis}`).text}</option>))}
</select>
</label>) : (
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
<FormField label={label ('measure')}>
{() => <select value={state ? 'state' : 'history'}
className={inputClass (false, 'w-auto py-1')}
aria-label={message ('measure').text} lang={message ('measure').language}
onChange={event => update ('basis', event.target.value)}>
<option value="state" lang={message (`state.totals.${resource}`).language}>
{message (`state.totals.${resource}`).text}</option>
<option value="history" lang={message ('metric').language}>
{message ('metric').text}</option>
</select>}
</FormField>
{mode === 'summary' && <label className="my-2 flex flex-wrap items-center gap-2">
{label ('axis')}
<select className={inputClass (false, 'w-auto py-1')}
value={draft.get ('axis')
?? ((state && resource === 'posts') ? 'tags' : 'month')}
aria-label={message ('axis').text} lang={message ('axis').language}
onChange={event => update ('axis', event.target.value)}>
{axes.map (axis => <option key={axis} value={axis}
lang={message (`axes.${axis}`).language}>
{message (`axes.${axis}`).text}</option>)}
</select>
</label>}
<CountsFieldTree resource={resource} params={draft}
fields={mode === 'summary' ? countsSummaryFields (resource, draft) : undefined}
selectionKey={mode === 'summary' ? 'summary_columns' : 'columns'}
parent={mode === 'summary' ? 'summary' : ''} 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">
<FormField label={label ('state.at')}>
{() => <DateTimeField value={draft.get ('at') ?? ''}
aria-label={message ('state.at').text} lang={message ('state.at').language}
onChange={value => update ('at', value ?? '')}/>}
</FormField>
{!state && <label className="flex items-center gap-1">
<input type="checkbox" checked={draft.get ('latest') !== '0'}
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
{label ('latestOnly')}
</label>
</label>}
{state && resource === 'posts' && <FormField label={label ('columns.kind')}>
{() => <select value={draft.get ('kind') ?? 'internal'}
className={inputClass (false, 'w-auto py-1')}
aria-label={message ('columns.kind').text} lang={message ('columns.kind').language}
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>}
<div className="grid gap-2 md:grid-cols-2">
{['from', 'before'].map (key => (
<FormField key={key} label={label (`fields.${key}`)}>
@@ -167,7 +203,6 @@ const CountsControls = (
className={inputClass (false, 'py-1')}/>}
</FormField>))}
</div>
</>}
</fieldset>
</div>
</div>
+23 -13
ファイルの表示
@@ -2,7 +2,7 @@ import { DndContext, MouseSensor, TouchSensor, closestCenter,
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields,
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
@@ -36,7 +36,8 @@ const FieldBranch = (
{label (field.label)}
</label>
return (
<div ref={drop.setNodeRef} className={cn ('flex items-start gap-1',
<div ref={drop.setNodeRef} className={cn ('max-w-full rounded border border-slate-200 px-1',
'dark:border-slate-700',
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
<div ref={drag.setNodeRef} className="flex min-w-0 flex-1 items-start gap-1"
style={drag.transform == null ? undefined : {
@@ -60,7 +61,7 @@ const FieldBranch = (
{field.children ? (
<details className="my-1 min-w-0 flex-1">
<summary className="cursor-pointer">{control}</summary>
<div className="ml-4 border-l pl-2 dark:border-slate-700">
<div className="ml-2 border-l pl-1 dark:border-slate-700">
<FieldGroup fields={field.children} path={path} params={params}
enabled={enabled && checked} selected={selected} select={select}
update={update}/>
@@ -99,6 +100,7 @@ const FieldGroup = (
onDragEnd={({ active, over }) => {
if (over != null && active.id !== over.id) move (String (active.id), String (over.id))
}}>
<div className="flex flex-wrap items-start gap-1">
{ordered.map (field => {
const position = movable.findIndex (item => item.key === field.key)
return <FieldBranch key={field.key} field={field}
@@ -107,28 +109,36 @@ const FieldGroup = (
select={select} update={update} move={move}
previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/>
})}
</div>
</DndContext>)
}
const CountsFieldTree = (
{ resource, params, update }: {
{ resource, params, update, fields: suppliedFields,
selectionKey = 'columns', parent = '' }: {
resource: CountsResource
fields?: CountField[]
selectionKey?: string
parent?: string
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { label } = useUITranslation ('counts')
const fields = fieldsForCounts (resource)
const roots = selectedCountColumns (resource, params)
const fields = suppliedFields ?? fieldsForCounts (resource)
const roots = suppliedFields == null ? selectedCountColumns (resource, params)
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
const selected = selectedCountChildren (params)
for (const root of roots) selected.add (root)
const rootPath = (key: string) => parent ? `${parent}.${key}` : key
for (const root of roots) selected.add (rootPath (root))
const select = (path: string, checked: boolean) => {
if (!path.includes ('.'))
const root = fields.find (field => rootPath (field.key) === path)
if (root != null)
{
const next = new Set<string> (roots)
if (checked) next.add (path)
else next.delete (path)
update ('columns', fields.filter (field => next.has (field.key))
if (checked) next.add (root.key)
else next.delete (root.key)
update (selectionKey, fields.filter (field => next.has (field.key))
.map (field => field.key).join (','))
return
}
@@ -139,9 +149,9 @@ const CountsFieldTree = (
}
return (
<>
<FieldGroup fields={fields} path="" params={params} enabled selected={selected}
<FieldGroup fields={fields} path={parent} params={params} enabled selected={selected}
select={select} update={update}/>
<p className="mt-2 text-xs">{label ('nested.selection')}</p>
{suppliedFields == null && <p className="mt-2 text-xs">{label ('nested.selection')}</p>}
</>)
}
+7 -46
ファイルの表示
@@ -27,34 +27,11 @@ const CountsResults = (
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const dialogue = useDialogue ()
const summary = (params.get ('mode') ?? 'summary') === 'summary'
const axis = params.get ('axis') ?? 'month'
const columns = selectedCountColumns (resource, params)
const fields = fieldsForCounts (resource)
const sorts = readCountSort (params.get ('sort') ?? (summary ? 'group:asc' : 'created_at:desc'))
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
const sort = (key: string, additive: boolean) =>
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
const drillDown = (key: string | number | null): string => {
const next = new URLSearchParams (params)
next.set ('mode', 'details')
next.delete ('page')
next.delete ('sort')
if (axis === 'month')
{
const first = `${key}-01T00:00Z`
const date = new Date (first)
date.setUTCMonth (date.getUTCMonth () + 1)
const last = date.toISOString ().slice (0, 16) + 'Z'
const from = next.get ('from')
const before = next.get ('before')
next.set ('from',
(from != null && Date.parse (from) > Date.parse (first)) ? from : first)
next.set ('before',
(before != null && Date.parse (before) < Date.parse (last)) ? before : last)
}
else next.set ('user_id', String (key ?? 'none'))
return `?${next}`
}
const cell = (row: CountsVersion, column: CountColumn) => {
const field = fields.find (field => field.key === column)!
const value = countsCellText (row, column, locale)
@@ -109,20 +86,15 @@ const CountsResults = (
}
return (
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
{!summary && columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
<CountsSelectableTable family={summary ? 'summary' : resource}
columns={summary ? ['group', 'count'] : columns}
{columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
<CountsSelectableTable family={resource}
columns={columns}
identity={`${resource}:${countsRequestParams (params)}:${columns.join (',')}:${
(data.versions ?? []).map (row => row.id)}`}
className="text-left text-sm">
<thead className="sticky top-0 z-20 bg-background text-foreground
dark:bg-background dark:text-foreground"><tr>
{summary ? <>
<CountsSortHeader field="group" label={message (`axes.${axis}`)}
sorts={sorts} change={sort}/>
<CountsSortHeader field="count" label={message ('metric')}
sorts={sorts} change={sort}/>
</> : columns.map (column => column === 'thumbnail'
{columns.map (column => column === 'thumbnail'
? <th key={column} data-counts-heading={message (countColumns[column]).text}
className="relative px-2 py-1">
<span className="sr-only">{label (countColumns[column])}</span>
@@ -134,18 +106,7 @@ const CountsResults = (
field => field.key === column)?.children != null}/>)}
</tr></thead>
<tbody>
{summary ? (data.groups ?? []).map (group => (
<tr key={String (group.key)} className="border-b dark:border-slate-700">
<td className="break-words border-b px-2 py-1 dark:border-slate-700"
data-counts-copy={group.key == null ? message (
'history:bot').text : group.name ?? String (group.key)}>
<PrefetchLink to={drillDown (group.key)}>
{group.key == null
? label ('history:bot') : group.name ?? String (group.key)}
</PrefetchLink></td>
<td className="border-b px-2 py-1 dark:border-slate-700"
data-counts-copy={String (group.count)}>{group.count}</td>
</tr>)) : (data.versions ?? []).map (row => (
{(data.versions ?? []).map (row => (
<tr key={row.id} className="border-b dark:border-slate-700">
{columns.map (column => (
<td key={column}
@@ -158,7 +119,7 @@ const CountsResults = (
</tr>))}
</tbody>
</CountsSelectableTable>)}
{(summary ? data.groups?.length ?? 0 : data.versions?.length ?? 0) === 0
{(data.versions?.length ?? 0) === 0
&& <p className="p-2">{label ('empty')}</p>}
</div>)
}
-63
ファイルの表示
@@ -1,63 +0,0 @@
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
ファイルの表示
@@ -1,56 +0,0 @@
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
+63
ファイルの表示
@@ -0,0 +1,63 @@
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
import { countsSummaryCell, countsSummaryDrill, countsSummaryRows,
selectedCountsSummaryFields } from '@/lib/countsState'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource, CountsResult } from '@/lib/counts'
const CountsSummaryResults = (
{ resource, data, params, pending, update }: {
resource: CountsResource
data: CountsResult
params: URLSearchParams
pending: boolean
update: (key: string, value: string) => void },
) => {
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const fields = selectedCountsSummaryFields (resource, params)
const rows = countsSummaryRows (data)
const sorts = readCountSort (params.get ('sort') ?? (
params.get ('axis') === 'tags' ? 'count:desc' : 'group:asc'))
return (
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
{fields.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
<CountsSelectableTable family={`summary_${params.get ('axis')}`}
columns={fields.map (field => field.key)}
identity={`${countsRequestParams (params)}:${fields.map (field => field.key)}`}
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>{rows.map (row => (
<tr key={String (row.key)}>
{fields.map (field => {
const value = countsSummaryCell (row, field.key, locale)
const content = value.language == null ? value.text : (
<LocalisedText language={value.language}>{value.text}</LocalisedText>)
return <td key={field.key} data-counts-copy={value.text}
className="break-words border-b px-2 py-1 dark:border-slate-700">
{(['count', 'group'].includes (field.key) && row.count != null)
? <PrefetchLink to={countsSummaryDrill (resource, params, row)}>
{content}</PrefetchLink>
: content}
</td>
})}
</tr>))}</tbody>
</CountsSelectableTable>)}
{rows.length === 0 && <p className="p-2">{label ('empty')}</p>}
</div>)
}
export default CountsSummaryResults
+53 -15
ファイルの表示
@@ -1,4 +1,4 @@
import { apiGet } from '@/lib/api'
import { apiGet, apiPost } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types'
@@ -216,16 +216,42 @@ export const changeCountSort = (
}
export const selectedCountFields = (
fields: CountField[],
params: URLSearchParams,
selectionKey: string,
parent = '',
): CountField[] => {
const selected = params.has (selectionKey)
? new Set (params.get (selectionKey)!.split (',')) : null
return orderedCountFields (fields, params, parent)
.filter (field => selected == null || selected.has (field.key))
}
export const normaliseCountsParams = (
resource: CountsResource,
source: URLSearchParams,
): 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')
const legacy = params.get ('mode')
if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
{
params.set ('mode', legacy === 'snapshot' ? 'details' : 'summary')
if (legacy !== 'snapshot') params.set ('axis', resource === 'posts' ? 'tags' : 'month')
if (legacy === 'trend')
{
if (params.get ('before')) params.set ('at', params.get ('before')!)
params.delete ('from')
params.delete ('before')
}
}
if (!params.has ('mode')) params.set ('mode', 'summary')
const axes = (resource === 'posts' && params.get ('basis') !== 'history')
? ['tags', 'month', 'user'] : ['month', 'user']
if (!axes.includes (params.get ('axis') ?? '')) params.set ('axis', axes[0])
params.delete ('grain')
if (params.has ('columns'))
{
const selected = new Set (params.get ('columns')!.split (','))
@@ -237,17 +263,14 @@ export const normaliseCountsParams = (
export const isCountsState = (params: URLSearchParams): boolean =>
['state', 'trend', 'snapshot'].includes (params.get ('mode') ?? 'state')
params.get ('basis') !== 'history'
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
const result = new URLSearchParams (params)
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])
const hidden = isCountsState (params) ? ['latest'] : ['kind']
for (const key of ['columns', 'summary_columns', 'children', 'child_sort',
'nested_json', 'order', 'basis', 'grain', ...hidden])
result.delete (key)
return result
}
@@ -257,9 +280,24 @@ export const fetchCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
): Promise<CountsResult> => apiGet (isCountsState (params) ? '/counts/state' : `/counts/${resource}`, {
locale, params: { ...Object.fromEntries (countsRequestParams (params)),
...(isCountsState (params) ? { resource } : { }) } })
): Promise<CountsResult> => apiGet (
isCountsState (params) ? '/counts/state' : `/counts/${resource}`, {
locale, params: { ...Object.fromEntries (countsRequestParams (params)),
...(isCountsState (params) ? { resource } : { }) } })
export const exportCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
format: 'json' | 'csv',
columns: string[],
headers: string[],
labels: Record<string, string>,
): Promise<Blob> => apiPost ('/counts/export', {
...Object.fromEntries (params), resource, format,
columns: columns.join (','), headers: JSON.stringify (headers),
labels: JSON.stringify (labels) }, { locale, responseType: 'blob' })
+32
ファイルの表示
@@ -212,3 +212,35 @@ export const countsImageURL = (value: string | null | undefined): string | null
return null
}
}
export const countsExportLabels = (
resource: CountsResource,
locale: LocaleCode,
): Record<string, string> => {
const aliases: Record<string, string> = { versionNo: 'version_no',
languageCode: 'language', scriptCode: 'script', autoGenerated: 'auto_generated' }
const labels: Record<string, string> = { }
const visit = (fields: CountField[]) => {
for (const field of fields)
{
labels[aliases[field.key] ?? field.key] = translateUIMessage (
field.label, 'counts', locale).text
if (field.children) visit (field.children)
}
}
visit (fieldsForCounts (resource))
for (const key of ['internal', 'external'])
labels[`kind:${key}`] = translateUIMessage (`kinds.${key}`, 'counts', locale).text
for (const key of ['deerjikist', 'meme', 'character', 'general', 'material', 'meta'])
labels[`category:${key}`] = translateUIMessage (
`common:categories.${key}`, 'counts', locale).text
for (const key of ['true', 'false'])
labels[`boolean:${key}`] = translateUIMessage (`boolean.${key}`, 'counts', locale).text
for (const key of ['ja', 'ain'])
labels[`language:${key}`] = translateUIMessage (
`history:languageNames.${key}`, 'counts', locale).text
labels.latest = translateUIMessage ('latestValue', 'counts', locale).text
labels.notRegistered = translateUIMessage ('notRegistered', 'counts', locale).text
return labels
}
+66 -24
ファイルの表示
@@ -1,52 +1,94 @@
import { isCountsState, selectedCountFields } from '@/lib/counts'
import { translateUIMessage } from '@/lib/i18n'
import type { CountField, CountsStateRow } from '@/lib/counts'
import type { CountField, CountsResource, CountsResult } from '@/lib/counts'
import type { LocaleCode } from '@/lib/locales'
export type CountsSummaryRow = {
key: string | number | null
name: string | null
count: number | null
kind?: 'internal' | 'external'
tagId?: number
deprecated?: string | null
at?: string }
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' },
export const countsSummaryFields = (
resource: CountsResource,
params: URLSearchParams,
): CountField[] => (isCountsState (params) && params.get ('axis') === 'tags')
? [{ 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' }]
: [{ key: 'group', label: `counts:axes.${params.get ('axis') ?? 'month'}` },
{ key: 'count', label: isCountsState (params)
? `counts:state.totals.${resource}` : 'counts:metric' }]
export const countsStateCell = (
row: CountsStateRow,
export const selectedCountsSummaryFields = (
resource: CountsResource,
params: URLSearchParams,
): CountField[] => selectedCountFields (
countsSummaryFields (resource, params), params, 'summary_columns', 'summary')
export const countsSummaryRows = (data: CountsResult): CountsSummaryRow[] =>
data.stateRows != null
? data.stateRows.map (row => ({ ...row, key: `${row.kind}:${row.tagId}` }))
: data.groups ?? []
export const countsSummaryCell = (
row: CountsSummaryRow,
key: string,
locale: LocaleCode,
): string => {
): { text: string; language?: 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 'group': return row.key == null
? translateUIMessage ('history:bot', 'counts', locale)
: { text: row.name ?? String (row.key) }
case 'count': return { text: row.count == null ? '—' : String (row.count) }
case 'tag_id': return { text: String (row.tagId) }
case 'kind': return translateUIMessage (`kinds.${row.kind}`, 'counts', locale)
case 'name': return row.name == null
? translateUIMessage ('notRegistered', 'counts', locale) : { text: row.name }
case 'deprecated': return row.deprecated
? translateUIMessage ('common:states.deprecated', 'counts', locale).text : ''
default: return ''
? translateUIMessage ('common:states.deprecated', 'counts', locale) : { text: '' }
default: return { text: '' }
}
}
export const countsStateDrill = (
export const countsSummaryDrill = (
resource: CountsResource,
params: URLSearchParams,
at: string,
resource = 'posts',
row?: CountsStateRow,
row: CountsSummaryRow,
): 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)
for (const key of ['sort', 'page']) next.delete (key)
next.set ('mode', 'details')
if (row.at) next.set ('at', row.at)
if (row.tagId != null)
{
next.set ('kind', row.kind)
next.set ('kind', row.kind!)
next.set ('tag', String (row.tagId))
}
else if (params.get ('axis') === 'user') next.set ('user_id', String (row.key ?? 'none'))
else
{
const first = `${row.key}-01T00:00:00Z`
const date = new Date (first)
date.setUTCMonth (date.getUTCMonth () + 1)
const last = date.toISOString ()
const from = next.get ('from')
const before = next.get ('before')
next.set ('from', (from != null && Date.parse (from) > Date.parse (first)) ? from : first)
next.set ('before',
(before != null && Date.parse (before) < Date.parse (last)) ? before : last)
}
return `/counts/${resource}?${next}`
}
+11 -3
ファイルの表示
@@ -14,7 +14,8 @@
"axis": "集計軸",
"axes": {
"month": "月別(UTC)",
"user": "$t(history:actor)"
"user": "$t(history:actor)",
"tags": "タグ別"
},
"metric": "履歴レコード件数",
"total": "履歴レコード件数:{{count}}",
@@ -96,8 +97,8 @@
"details": "時点明細",
"history": "履歴集計",
"historyDetails": "履歴明細",
"at": "指定時点(端末の時刻帯・未満)",
"boundary": "指定時点(UTC・未満)",
"at": "集計時点",
"boundary": "集計時点(UTC)",
"period": "時点(UTC)",
"grain": "粒度(UTC)",
"grains": {
@@ -111,5 +112,12 @@
"tags": "タグ総数(内部タグ)",
"assignments": "タグ付け総件数"
}
},
"measure": "指標",
"export": "エクスポート",
"exportFormat": "エクスポート形式",
"exportFormats": {
"json": "JSON",
"csv": "CSV"
}
}
+78 -63
ファイルの表示
@@ -4,7 +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 CountsSummaryResults from '@/components/counts/CountsSummaryResults'
import CountsResults from '@/components/counts/CountsResults'
import CountsTableLayout from '@/components/counts/CountsTableLayout'
import PageHead from '@/components/common/PageHead'
@@ -13,12 +13,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 { countsCSV, countsRequestParams, fieldsForCounts, fetchCounts,
import { countsRequestParams, exportCounts, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsCSVCell } from '@/lib/countsDisplay'
import { countsStateCell, countsStateDrill, countsStateFields } from '@/lib/countsState'
import { countsExportLabels } from '@/lib/countsDisplay'
import { selectedCountsSummaryFields } from '@/lib/countsState'
import { countsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import { cn, inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
@@ -37,18 +37,22 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const [sidebarOpen, setSidebarOpen] = useState (false)
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const mode = params.get ('mode') ?? 'state'
const mode = params.get ('mode') ?? 'summary'
const state = isCountsState (params)
const stateTable = mode === 'state' || mode === 'trend'
const axis = params.get ('axis') ?? 'month'
const cacheParams = new URLSearchParams (request)
cacheParams.set ('basis', state ? 'state' : '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 ({
queryKey: countsKeys.index (resource, request.toString (), locale),
queryFn: () => fetchCounts (resource, request, locale), retry: false,
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
queryFn: () => fetchCounts (resource, params, locale), retry: false,
placeholderData: (previous, query) => {
const previousMode = new URLSearchParams (String (query?.queryKey[3] ?? ''))
.get ('mode') ?? 'state'
const previousParams = new URLSearchParams (String (query?.queryKey[3] ?? ''))
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
&& previousMode === mode) ? previous : undefined
&& previousParams.get ('mode') === mode
&& previousParams.get ('axis') === params.get ('axis')
&& previousParams.get ('basis') === cacheParams.get ('basis')) ? previous : undefined
} })
useEffect (() => {
@@ -57,16 +61,12 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
}, [canonical, source, setParams])
useEffect (() => {
if (!state || data?.at == null || isPlaceholderData) return
if (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])
}, [canonical, data, isPlaceholderData, setParams])
const update = (key: string, value: string) => {
const next = new URLSearchParams (params)
@@ -75,29 +75,32 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
if (key === 'sort') next.delete ('page')
setParams (next)
}
const exportCSV = () => {
const columns = selectedCountColumns (resource, params)
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),
String (group.count)])]
: [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)))])
const url = URL.createObjectURL (new Blob ([countsCSV (rows)], {
type: 'text/csv;charset=utf-8' }))
const link = document.createElement ('a')
link.href = url
link.download = `counts-${resource}.csv`
link.click ()
URL.revokeObjectURL (url)
const download = async () => {
setExporting (true)
setExportError (false)
try
{
const fields = mode === 'summary' ? selectedCountsSummaryFields (resource, params)
: selectedCountColumns (resource, params).map (column =>
fieldsForCounts (resource).find (field => field.key === column)!)
const file = 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)
}
catch
{
setExportError (true)
}
finally
{
setExporting (false)
}
}
return (
@@ -114,19 +117,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 => (
<li key={target}><PrefetchLink to={`/counts/${target}`}
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>))}
{(['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>
})}
</ul>
</nav>
</SidebarComponent>
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
<div className="relative shrink-0 pr-16">
<div className="shrink-0">
<CountsControls key={`${resource}:${controlParams}`}
resource={resource} applied={params}
open={open} setOpen={setOpen}
@@ -135,32 +142,40 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
execute={next => {
if (next.toString () === canonical) void refetch ()
else setParams (next)
}}/>
<Button type="button" variant="outline" size="sm" className="absolute right-0 top-0"
title={message ('csvScope').text} lang={message ('csvScope').language}
disabled={data == null || isError || isPlaceholderData} onClick={exportCSV}>
{label ('csv')}</Button>
}} actions={<>
<select value={format} aria-label={message ('exportFormat').text}
lang={message ('exportFormat').language}
className={inputClass (false, 'w-auto py-1')}
onChange={event => setFormat (event.target.value as 'json' | 'csv')}>
{(['json', 'csv'] as const).map (value => (
<option key={value} value={value}>
{message (`exportFormats.${value}`).text}</option>))}
</select>
<Button type="button" variant="outline" size="sm"
disabled={data == null || isError || isPlaceholderData
|| exporting || !params.get ('at')}
onClick={() => void download ()}>{label ('export')}</Button>
</>}/>
{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 && <>
{state && data.at && <div className="my-1 flex flex-wrap gap-x-4 gap-y-1 text-xs">
{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] ?? '—'}
{data.totals![key] ?? '—'}
</span>))}
</div>}
{!state && <p className="my-1 text-xs">{label ('total', { count: data.count })}</p>}
{stateTable ? <CountsStateResults data={data} params={params}
{mode === 'summary' ? <CountsSummaryResults resource={resource}
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 (
((stateTable || mode === 'summary') ? data.groupCount ?? 0 : data.count) / data.limit)}/>
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
</>}
</MainArea>
</div>