このコミットが含まれているのは:
@@ -0,0 +1,10 @@
|
|||||||
|
class CountsStatesController < ApplicationController
|
||||||
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
result = CountsStateQuery.new(request.query_parameters,
|
||||||
|
language_code: locale.language_code).call
|
||||||
|
render json: result
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
head :bad_request
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,11 +14,12 @@ class CountsQuery
|
|||||||
|
|
||||||
attr_reader :page, :limit, :mode, :axis
|
attr_reader :page, :limit, :mode, :axis
|
||||||
|
|
||||||
def initialize(scope, resource, params)
|
def initialize(scope, resource, params, latest_expression: nil)
|
||||||
@table = scope.klass.table_name
|
@table = scope.klass.table_name
|
||||||
@resource = resource
|
@resource = resource
|
||||||
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
||||||
@params = params
|
@params = params
|
||||||
|
@latest_expression = latest_expression
|
||||||
@scope = scope.except(:select, :order).joins(
|
@scope = scope.except(:select, :order).joins(
|
||||||
"LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id")
|
"LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id")
|
||||||
raise ArgumentError unless (params.keys - KEYS).empty?
|
raise ArgumentError unless (params.keys - KEYS).empty?
|
||||||
@@ -43,6 +44,8 @@ class CountsQuery
|
|||||||
def count = @scope.count
|
def count = @scope.count
|
||||||
|
|
||||||
def latest_expression
|
def latest_expression
|
||||||
|
return @latest_expression if @latest_expression
|
||||||
|
|
||||||
<<~SQL.squish
|
<<~SQL.squish
|
||||||
NOT EXISTS (SELECT 1 FROM #{ @table } newer
|
NOT EXISTS (SELECT 1 FROM #{ @table } newer
|
||||||
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
|
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# State at an exclusive UTC boundary, reconstructed only from recorded snapshots.
|
||||||
|
class CountsStateQuery
|
||||||
|
KEYS = ['locale', 'resource', 'mode', 'at', 'from', 'before', 'grain',
|
||||||
|
'kind', 'tag', 'page', 'limit', 'sort'].freeze
|
||||||
|
MAX_PERIODS = 366
|
||||||
|
|
||||||
|
def initialize(params, language_code:)
|
||||||
|
raise ArgumentError unless (params.keys - KEYS).empty?
|
||||||
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
|
@params = params
|
||||||
|
@connection = ApplicationRecord.connection
|
||||||
|
@language = language_code
|
||||||
|
@resource = params.fetch('resource', 'posts')
|
||||||
|
@mode = params.fetch('mode', 'state')
|
||||||
|
@kind = params.fetch('kind', 'internal')
|
||||||
|
@grain = params.fetch('grain', 'month')
|
||||||
|
raise ArgumentError unless ['posts', 'tags'].include?(@resource)
|
||||||
|
raise ArgumentError unless ['state', 'trend', 'snapshot'].include?(@mode)
|
||||||
|
raise ArgumentError unless ['internal', 'external', 'all'].include?(@kind)
|
||||||
|
raise ArgumentError unless ['day', 'month', 'year'].include?(@grain)
|
||||||
|
|
||||||
|
@page = integer('page', 1)
|
||||||
|
@limit = integer('limit', 20)
|
||||||
|
@tag = integer('tag') if params['tag'].present?
|
||||||
|
raise ArgumentError if @limit > 100 || @page > 1_000_000
|
||||||
|
raise ArgumentError if @tag && @kind == 'all'
|
||||||
|
raise ArgumentError if @mode == 'trend' && !@tag
|
||||||
|
|
||||||
|
@now = Time.current.utc
|
||||||
|
@at = timestamp('at') || @now.change(usec: 0)
|
||||||
|
raise ArgumentError if @at > @now
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
# Keep totals, rows, and detail pages on one repeatable-read database snapshot.
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
@post_start = PostVersion.minimum(:created_at)
|
||||||
|
@tag_start = TagVersion.minimum(:created_at)
|
||||||
|
result = { page: @page, limit: @limit, at: @at.iso8601(6),
|
||||||
|
coverage: { basis: 'recorded_snapshots', exact: false,
|
||||||
|
post_history_from: @post_start&.iso8601(6),
|
||||||
|
tag_history_from: @tag_start&.iso8601(6) } }
|
||||||
|
case @mode
|
||||||
|
when 'trend'
|
||||||
|
result.merge(trend)
|
||||||
|
when 'snapshot'
|
||||||
|
result.merge(totals: totals).merge(details)
|
||||||
|
else
|
||||||
|
result.merge(totals: totals).merge(listing)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def quote(value) = @connection.quote(value)
|
||||||
|
|
||||||
|
def integer(key, default = nil)
|
||||||
|
value = @params[key]
|
||||||
|
return default if value.blank? && default
|
||||||
|
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
|
||||||
|
|
||||||
|
value.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def timestamp(key)
|
||||||
|
value = @params[key].presence
|
||||||
|
return nil unless value
|
||||||
|
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
|
||||||
|
raise ArgumentError unless value.match?(pattern)
|
||||||
|
|
||||||
|
Date.iso8601(value.first(10))
|
||||||
|
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value).utc
|
||||||
|
end
|
||||||
|
|
||||||
|
# Version numbers order equal-timestamp writes. Do not filter tags before this selection.
|
||||||
|
def latest_condition(table, row, boundary)
|
||||||
|
key = table == 'post_versions' ? 'post_id' : 'tag_id'
|
||||||
|
<<~SQL.squish
|
||||||
|
#{ row }.created_at < #{ boundary } AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM #{ table } newer
|
||||||
|
WHERE newer.#{ key } = #{ row }.#{ key }
|
||||||
|
AND newer.created_at < #{ boundary }
|
||||||
|
AND newer.version_no > #{ row }.version_no)
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def state_sql(table, boundary, live: false)
|
||||||
|
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) }"
|
||||||
|
live ? "#{ sql } AND v.event_type <> 'discard'" : sql
|
||||||
|
end
|
||||||
|
|
||||||
|
def membership(row)
|
||||||
|
key = @kind == 'external' ? 'external_tag_id' : 'tag_id'
|
||||||
|
"JSON_CONTAINS(#{ row }.tags_json, JSON_OBJECT('#{ key }', #{ @tag }))"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deprecation keeps a tag in existence. Only an explicit discard removes it.
|
||||||
|
def tag_visible(boundary)
|
||||||
|
return '1 = 1' if @kind == 'external'
|
||||||
|
|
||||||
|
<<~SQL.squish
|
||||||
|
NOT EXISTS (SELECT 1 FROM tag_versions t
|
||||||
|
WHERE t.tag_id = #{ @tag } AND t.event_type = 'discard'
|
||||||
|
AND #{ latest_condition('tag_versions', 't', boundary) })
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def ctes
|
||||||
|
boundary = quote(@at)
|
||||||
|
<<~SQL
|
||||||
|
WITH post_at AS (#{ state_sql('post_versions', boundary, live: true) }),
|
||||||
|
tag_at AS (#{ state_sql('tag_versions', boundary) }),
|
||||||
|
nico_at AS (#{ state_sql('nico_tag_versions', boundary) }),
|
||||||
|
pairs AS (
|
||||||
|
SELECT DISTINCT p.post_id,
|
||||||
|
CASE WHEN entry.tag_id IS NULL THEN 'external' ELSE 'internal' END AS kind,
|
||||||
|
COALESCE(entry.tag_id, entry.external_tag_id) AS tag_id
|
||||||
|
FROM post_at p
|
||||||
|
JOIN JSON_TABLE(CAST(p.tags_json AS JSON), '$[*]' COLUMNS (
|
||||||
|
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY ERROR ON ERROR,
|
||||||
|
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY ERROR ON ERROR
|
||||||
|
)) entry
|
||||||
|
LEFT JOIN tag_at t ON t.tag_id = entry.tag_id
|
||||||
|
WHERE COALESCE(entry.tag_id, entry.external_tag_id) IS NOT NULL
|
||||||
|
AND (t.event_type IS NULL OR t.event_type <> 'discard')
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def totals
|
||||||
|
row = @connection.select_one(<<~SQL)
|
||||||
|
#{ ctes }
|
||||||
|
SELECT (SELECT COUNT(*) FROM post_at) AS posts,
|
||||||
|
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard') AS tags,
|
||||||
|
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard'
|
||||||
|
AND deprecated_at IS NOT NULL) AS deprecated_tags,
|
||||||
|
(SELECT COUNT(*) FROM pairs) AS assignments,
|
||||||
|
(SELECT COUNT(*) FROM pairs WHERE kind = 'external') AS external_assignments
|
||||||
|
SQL
|
||||||
|
row.transform_values!(&:to_i)
|
||||||
|
row.merge('posts' => available?(@post_start, @at) ? row['posts'] : nil,
|
||||||
|
'assignments' => available?(@post_start, @at) ? row['assignments'] : nil,
|
||||||
|
'external_assignments' => available?(@post_start, @at) ?
|
||||||
|
row['external_assignments'] : nil,
|
||||||
|
'tags' => available?(@tag_start, @at) ? row['tags'] : nil,
|
||||||
|
'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def available?(first, boundary) = first && first < boundary
|
||||||
|
|
||||||
|
def listing
|
||||||
|
path = quote("$.#{ JSON.generate(@language) }.name")
|
||||||
|
catalog = <<~SQL
|
||||||
|
, tag_counts AS (
|
||||||
|
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
|
||||||
|
FROM pairs membership GROUP BY membership.kind, membership.tag_id
|
||||||
|
), catalog AS (
|
||||||
|
SELECT 'internal' AS kind, tag_id,
|
||||||
|
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(names, #{ path })), name) AS name,
|
||||||
|
deprecated_at FROM tag_at WHERE event_type <> 'discard'
|
||||||
|
UNION ALL
|
||||||
|
SELECT tc.kind, tc.tag_id, external_version.name, NULL AS deprecated_at
|
||||||
|
FROM tag_counts tc
|
||||||
|
LEFT JOIN tag_at internal_version
|
||||||
|
ON tc.kind = 'internal' AND internal_version.tag_id = tc.tag_id
|
||||||
|
LEFT JOIN nico_at external_version
|
||||||
|
ON tc.kind = 'external' AND external_version.tag_id = tc.tag_id
|
||||||
|
WHERE tc.kind = 'external' OR internal_version.tag_id IS NULL
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
filters = []
|
||||||
|
filters << "c.kind = #{ quote(@kind) }" unless @kind == 'all'
|
||||||
|
filters << "c.tag_id = #{ @tag }" if @tag
|
||||||
|
where = filters.any? ? "WHERE #{ filters.join(' AND ') }" : ''
|
||||||
|
source = 'FROM catalog c LEFT JOIN tag_counts n ON n.kind = c.kind AND n.tag_id = c.tag_id'
|
||||||
|
prefix = "#{ ctes } #{ catalog }"
|
||||||
|
total = @connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i
|
||||||
|
order = order_sql({ 'name' => 'c.name', 'count' => 'COALESCE(n.post_count, 0)',
|
||||||
|
'tag_id' => 'c.tag_id', 'kind' => 'c.kind',
|
||||||
|
'deprecated' => 'c.deprecated_at' }, 'count:desc',
|
||||||
|
['c.kind ASC', 'c.tag_id ASC'])
|
||||||
|
rows = @connection.select_all(<<~SQL)
|
||||||
|
#{ prefix }
|
||||||
|
SELECT c.kind, c.tag_id, c.name, c.deprecated_at,
|
||||||
|
COALESCE(n.post_count, 0) AS post_count #{ source } #{ where }
|
||||||
|
ORDER BY #{ order } LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
|
||||||
|
SQL
|
||||||
|
{ count: total, group_count: total, state_rows: rows.map do |row|
|
||||||
|
{ kind: row['kind'], tag_id: row['tag_id'], name: row['name'],
|
||||||
|
deprecated: row['deprecated_at']&.iso8601,
|
||||||
|
count: available?(@post_start, @at) ? row['post_count'].to_i : nil,
|
||||||
|
at: @at.iso8601(6) }
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
|
||||||
|
def details
|
||||||
|
table = @resource == 'posts' ? 'post_versions' : 'tag_versions'
|
||||||
|
model = @resource == 'posts' ? PostVersion : TagVersion
|
||||||
|
scope = model.joins("INNER JOIN (#{ state_sql(table, quote(@at), live: true) }) " +
|
||||||
|
"counts_at ON counts_at.id = #{ table }.id")
|
||||||
|
if @tag
|
||||||
|
scope = if @resource == 'posts'
|
||||||
|
scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }")
|
||||||
|
elsif @kind == 'internal'
|
||||||
|
scope.where(tag_id: @tag)
|
||||||
|
else
|
||||||
|
scope.none
|
||||||
|
end
|
||||||
|
end
|
||||||
|
params = @params.slice('locale', 'sort', 'page', 'limit')
|
||||||
|
.merge('mode' => 'details', 'latest' => '0')
|
||||||
|
query = CountsQuery.new(scope, @resource, params, latest_expression: '1')
|
||||||
|
{ count: query.count,
|
||||||
|
versions: CountsVersionRepr.many(query.details, resource: @resource,
|
||||||
|
language_code: @language) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_boundary(time)
|
||||||
|
case @grain
|
||||||
|
when 'day' then time.beginning_of_day + 1.day
|
||||||
|
when 'month' then time.beginning_of_month.next_month
|
||||||
|
else time.beginning_of_year.next_year
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def periods
|
||||||
|
@before = timestamp('before') || @now.beginning_of_month
|
||||||
|
@from = timestamp('from') || @before.prev_year
|
||||||
|
raise ArgumentError if @from >= @before || @before > @now
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
boundary = next_boundary(@from)
|
||||||
|
while boundary <= @before
|
||||||
|
raise ArgumentError if rows.length >= MAX_PERIODS
|
||||||
|
|
||||||
|
format = { 'day' => '%Y-%m-%d', 'month' => '%Y-%m', 'year' => '%Y' }.fetch(@grain)
|
||||||
|
rows << [boundary, (boundary - 1.second).strftime(format)]
|
||||||
|
boundary = next_boundary(boundary)
|
||||||
|
end
|
||||||
|
rows
|
||||||
|
end
|
||||||
|
|
||||||
|
def trend
|
||||||
|
points = periods
|
||||||
|
metadata = { count: points.length, group_count: points.length, state_rows: [],
|
||||||
|
from: @from.iso8601, before: @before.iso8601, grain: @grain }
|
||||||
|
return metadata if points.empty?
|
||||||
|
|
||||||
|
values = points.map do |boundary, period|
|
||||||
|
"SELECT CAST(#{ quote(boundary) } AS DATETIME(6)) AS boundary, " +
|
||||||
|
"#{ quote(period) } AS period"
|
||||||
|
end.join(' UNION ALL ')
|
||||||
|
order = order_sql({ 'period' => 'boundary', 'count' => 'post_count' },
|
||||||
|
'period:asc', ['boundary ASC'])
|
||||||
|
known = @post_start ? "periods.boundary > #{ quote(@post_start) }" : 'FALSE'
|
||||||
|
rows = @connection.select_all(<<~SQL)
|
||||||
|
WITH periods AS (#{ values }), series AS (
|
||||||
|
SELECT periods.boundary, periods.period,
|
||||||
|
CASE WHEN #{ known } THEN COUNT(DISTINCT p.post_id) ELSE NULL END AS post_count
|
||||||
|
FROM periods
|
||||||
|
LEFT JOIN post_versions p ON p.event_type <> 'discard'
|
||||||
|
AND #{ membership('p') }
|
||||||
|
AND #{ latest_condition('post_versions', 'p', 'periods.boundary') }
|
||||||
|
AND #{ tag_visible('periods.boundary') }
|
||||||
|
GROUP BY periods.boundary, periods.period
|
||||||
|
)
|
||||||
|
SELECT * FROM series ORDER BY #{ order }
|
||||||
|
LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
|
||||||
|
SQL
|
||||||
|
metadata.merge(state_rows: rows.map do |row|
|
||||||
|
{ kind: @kind, tag_id: @tag, name: nil, period: row['period'],
|
||||||
|
at: row['boundary'].utc.iso8601(6), count: row['post_count']&.to_i }
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def order_sql(expressions, default, final)
|
||||||
|
raw = @params.fetch('sort', default)
|
||||||
|
sorts = raw.empty? ? [] : raw.split(',', -1).map { _1.split(':', -1) }
|
||||||
|
raise ArgumentError if sorts.map(&:first).uniq.length != sorts.length
|
||||||
|
raise ArgumentError if sorts.length > expressions.length
|
||||||
|
|
||||||
|
order = sorts.flat_map do |key, direction, *extra|
|
||||||
|
if extra.any? || !expressions.key?(key) || !['asc', 'desc'].include?(direction)
|
||||||
|
raise ArgumentError
|
||||||
|
end
|
||||||
|
expression = expressions.fetch(key)
|
||||||
|
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
|
||||||
|
end
|
||||||
|
(order + final).join(', ')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
get 'counts/state', to: 'counts_states#index'
|
||||||
get 'counts/posts', to: 'counts_posts#index'
|
get 'counts/posts', to: 'counts_posts#index'
|
||||||
get 'counts/tags', to: 'counts_tags#index'
|
get 'counts/tags', to: 'counts_tags#index'
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useId, useState } from 'react'
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
|
||||||
|
import CountsStateFilters from '@/components/counts/CountsStateFilters'
|
||||||
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { normaliseCountsParams } from '@/lib/counts'
|
import { isCountsState, normaliseCountsParams } from '@/lib/counts'
|
||||||
import { fetchTagByName } from '@/lib/tags'
|
import { fetchTagByName } from '@/lib/tags'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
@@ -42,7 +43,8 @@ const CountsControls = (
|
|||||||
}, [order, nestedJSON])
|
}, [order, nestedJSON])
|
||||||
const [error, setError] = useState (false)
|
const [error, setError] = useState (false)
|
||||||
const [pending, setPending] = 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) => {
|
const update = (key: string, value: string) => {
|
||||||
setDraft (previous => {
|
setDraft (previous => {
|
||||||
const next = new URLSearchParams (previous)
|
const next = new URLSearchParams (previous)
|
||||||
@@ -58,11 +60,12 @@ const CountsControls = (
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
const next = new URLSearchParams (draft)
|
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 () ?? ''
|
const value = next.get (key)?.trim () ?? ''
|
||||||
if (value && !/^[1-9][0-9]*$/.test (value))
|
if (value && !/^[1-9][0-9]*$/.test (value))
|
||||||
{
|
{
|
||||||
|
if (state && next.get ('kind') === 'external') throw new Error ()
|
||||||
const tag = await fetchTagByName (value, locale)
|
const tag = await fetchTagByName (value, locale)
|
||||||
if (tag == null)
|
if (tag == null)
|
||||||
throw new Error ()
|
throw new Error ()
|
||||||
@@ -96,10 +99,10 @@ const CountsControls = (
|
|||||||
<select className={inputClass (false, 'w-auto py-1')}
|
<select className={inputClass (false, 'w-auto py-1')}
|
||||||
aria-label={message ('mode').text} lang={message ('mode').language}
|
aria-label={message ('mode').text} lang={message ('mode').language}
|
||||||
value={mode} onChange={event => update ('mode', event.target.value)}>
|
value={mode} onChange={event => update ('mode', event.target.value)}>
|
||||||
<option value="summary" lang={message ('summary').language}>
|
{[['state', 'summary'], ['trend', 'state.trend'], ['snapshot', 'state.details'],
|
||||||
{message ('summary').text}</option>
|
['summary', 'state.history'], ['details', 'state.historyDetails']].map (
|
||||||
<option value="details" lang={message ('details').language}>
|
([value, key]) => <option key={value} value={value} lang={message (key).language}>
|
||||||
{message ('details').text}</option>
|
{message (key).text}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
|
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
|
||||||
@@ -115,7 +118,9 @@ const CountsControls = (
|
|||||||
}}/>
|
}}/>
|
||||||
{label ('nested.asJSON')}
|
{label ('nested.asJSON')}
|
||||||
</label>
|
</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 className="flex flex-wrap items-center gap-2">
|
||||||
{label ('axis')}
|
{label ('axis')}
|
||||||
<select className={inputClass (false, 'w-auto py-1')}
|
<select className={inputClass (false, 'w-auto py-1')}
|
||||||
@@ -130,10 +135,11 @@ const CountsControls = (
|
|||||||
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
|
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
|
||||||
update (key, value)
|
update (key, value)
|
||||||
if (key === 'order') reorder (value)
|
if (key === 'order') reorder (value)
|
||||||
}}/>)}
|
}}/>))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
||||||
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
||||||
|
{state ? <CountsStateFilters params={draft} update={update}/> : <>
|
||||||
<label className="flex items-center gap-1">
|
<label className="flex items-center gap-1">
|
||||||
<input type="checkbox" checked={draft.get ('latest') !== '0'}
|
<input type="checkbox" checked={draft.get ('latest') !== '0'}
|
||||||
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
|
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
|
||||||
@@ -161,6 +167,7 @@ const CountsControls = (
|
|||||||
className={inputClass (false, 'py-1')}/>}
|
className={inputClass (false, 'py-1')}/>}
|
||||||
</FormField>))}
|
</FormField>))}
|
||||||
</div>
|
</div>
|
||||||
|
</>}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -36,13 +36,35 @@ export type CountsVersion = {
|
|||||||
category?: string
|
category?: string
|
||||||
aliases?: { languageCode: string; name: string; scriptCode: string }[]
|
aliases?: { languageCode: string; name: string; scriptCode: string }[]
|
||||||
deprecated?: string | null }
|
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 = {
|
export type CountsResult = {
|
||||||
count: number
|
count: number
|
||||||
page: number
|
page: number
|
||||||
limit: number
|
limit: number
|
||||||
groupCount?: number
|
groupCount?: number
|
||||||
groups?: { key: string | number | null; name: string | null; count: 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 CountSort = { key: string; direction: 'asc' | 'desc' }
|
||||||
export type CountField = { key: string; label: string; children?: CountField[] }
|
export type CountField = { key: string; label: string; children?: CountField[] }
|
||||||
export type CountRecord = Record<string, unknown>
|
export type CountRecord = Record<string, unknown>
|
||||||
@@ -200,6 +222,8 @@ export const normaliseCountsParams = (
|
|||||||
): URLSearchParams => {
|
): URLSearchParams => {
|
||||||
const params = new URLSearchParams (source)
|
const params = new URLSearchParams (source)
|
||||||
params.delete ('event_type')
|
params.delete ('event_type')
|
||||||
|
if (!params.has ('mode'))
|
||||||
|
params.set ('mode', source.has ('axis') ? 'summary' : 'state')
|
||||||
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
|
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
|
||||||
params.set ('axis', 'month')
|
params.set ('axis', 'month')
|
||||||
if (params.has ('columns'))
|
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 => {
|
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
|
||||||
const result = new URLSearchParams (params)
|
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)
|
result.delete (key)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -224,8 +257,10 @@ export const fetchCounts = (
|
|||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
): Promise<CountsResult> => apiGet (`/counts/${resource}`, {
|
): Promise<CountsResult> => apiGet (isCountsState (params) ? '/counts/state' : `/counts/${resource}`, {
|
||||||
locale, params: Object.fromEntries (countsRequestParams (params)) })
|
locale, params: { ...Object.fromEntries (countsRequestParams (params)),
|
||||||
|
...(isCountsState (params) ? { resource } : { }) } })
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const stableValue = (value: unknown): unknown => {
|
const stableValue = (value: unknown): unknown => {
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export const defaultCountWidths: Record<string, number> = {
|
|||||||
video_ms: 120, original_created_at: 260, original_created_from: 240,
|
video_ms: 120, original_created_at: 260, original_created_from: 240,
|
||||||
original_created_before: 240, name: 240, names: 480, category: 180,
|
original_created_before: 240, name: 240, names: 480, category: 180,
|
||||||
aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160,
|
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 }
|
languageCode: 180, scriptCode: 200, autoGenerated: 120 }
|
||||||
|
|
||||||
type Layout = {
|
type Layout = {
|
||||||
|
|||||||
@@ -89,5 +89,27 @@
|
|||||||
"range": "{{begin}}~{{end}}",
|
"range": "{{begin}}~{{end}}",
|
||||||
"separator": "、"
|
"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": "タグ付け総件数"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom'
|
|||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import CountsControls from '@/components/counts/CountsControls'
|
import CountsControls from '@/components/counts/CountsControls'
|
||||||
|
import CountsStateResults from '@/components/counts/CountsStateResults'
|
||||||
import CountsResults from '@/components/counts/CountsResults'
|
import CountsResults from '@/components/counts/CountsResults'
|
||||||
import CountsTableLayout from '@/components/counts/CountsTableLayout'
|
import CountsTableLayout from '@/components/counts/CountsTableLayout'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
@@ -13,8 +14,9 @@ import MainArea from '@/components/layout/MainArea'
|
|||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsCSV, countsRequestParams, fieldsForCounts, fetchCounts,
|
import { countsCSV, countsRequestParams, fieldsForCounts, fetchCounts,
|
||||||
normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||||
import { countsCSVCell } from '@/lib/countsDisplay'
|
import { countsCSVCell } from '@/lib/countsDisplay'
|
||||||
|
import { countsStateCell, countsStateDrill, countsStateFields } from '@/lib/countsState'
|
||||||
import { countsKeys } from '@/lib/queryKeys'
|
import { countsKeys } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
@@ -35,14 +37,16 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState (false)
|
const [sidebarOpen, setSidebarOpen] = useState (false)
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const { label, message } = useUITranslation ('counts')
|
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 axis = params.get ('axis') ?? 'month'
|
||||||
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
|
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
|
||||||
queryKey: countsKeys.index (resource, request.toString (), locale),
|
queryKey: countsKeys.index (resource, request.toString (), locale),
|
||||||
queryFn: () => fetchCounts (resource, request, locale), retry: false,
|
queryFn: () => fetchCounts (resource, request, locale), retry: false,
|
||||||
placeholderData: (previous, query) => {
|
placeholderData: (previous, query) => {
|
||||||
const previousMode = new URLSearchParams (String (query?.queryKey[3] ?? ''))
|
const previousMode = new URLSearchParams (String (query?.queryKey[3] ?? ''))
|
||||||
.get ('mode') ?? 'summary'
|
.get ('mode') ?? 'state'
|
||||||
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
||||||
&& previousMode === mode) ? previous : undefined
|
&& previousMode === mode) ? previous : undefined
|
||||||
} })
|
} })
|
||||||
@@ -52,6 +56,18 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
setParams (canonical, { replace: true })
|
setParams (canonical, { replace: true })
|
||||||
}, [canonical, source, setParams])
|
}, [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 update = (key: string, value: string) => {
|
||||||
const next = new URLSearchParams (params)
|
const next = new URLSearchParams (params)
|
||||||
if (value || key === 'sort') next.set (key, value)
|
if (value || key === 'sort') next.set (key, value)
|
||||||
@@ -61,7 +77,12 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
}
|
}
|
||||||
const exportCSV = () => {
|
const exportCSV = () => {
|
||||||
const columns = selectedCountColumns (resource, params)
|
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],
|
? [[message (`axes.${axis}`).text, message ('metric').text],
|
||||||
...(data?.groups ?? []).map (group => [
|
...(data?.groups ?? []).map (group => [
|
||||||
group.key == null ? message ('history:bot').text : group.name ?? String (group.key),
|
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 (
|
: [columns.map (column => message (
|
||||||
fieldsForCounts (resource).find (field => field.key === column)!.label).text),
|
fieldsForCounts (resource).find (field => field.key === column)!.label).text),
|
||||||
...(data?.versions ?? []).map (row => columns.map (
|
...(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)], {
|
const url = URL.createObjectURL (new Blob ([countsCSV (rows)], {
|
||||||
type: 'text/csv;charset=utf-8' }))
|
type: 'text/csv;charset=utf-8' }))
|
||||||
const link = document.createElement ('a')
|
const link = document.createElement ('a')
|
||||||
@@ -123,11 +144,23 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
||||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
{data != null && <>
|
{data != null && <>
|
||||||
<p className="my-1 text-xs">{label ('total', { count: data.count })}</p>
|
{state && data.at && <div className="my-1 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<CountsResults resource={resource} data={data} params={params}
|
<span>{label ('state.boundary')}:{data.at.replace ('T', ' ')}</span>
|
||||||
pending={isPlaceholderData} update={update}/>
|
{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 (
|
<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>
|
</MainArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする