このコミットが含まれているのは:
2026-10-25 23:08:48 +09:00
コミット 8642225b94
21個のファイルの変更、920行の追加、360行の削除
+2 -1
ファイルの表示
@@ -6,7 +6,8 @@ module CountsListing
resource = is_a?(CountsPostsController) ? 'posts' : 'tags' resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale') query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
.merge('locale' => locale.code)) .merge('locale' => locale.code))
result = { count: query.count, page: query.page, limit: query.limit } result = { count: query.count, page: query.page, limit: query.limit,
at: request.query_parameters['at'].presence || Time.current.utc.iso8601(6) }
if query.mode == 'summary' if query.mode == 'summary'
result.merge!(query.summary) result.merge!(query.summary)
else else
+20
ファイルの表示
@@ -0,0 +1,20 @@
class CountsExportsController < ApplicationController
wrap_parameters false
def index
locale = resolve_locale!
exporter = CountsExporter.new(request.request_parameters.merge(request.query_parameters),
language_code: locale.language_code)
response.headers['Content-Type'] = exporter.format == 'json' ?
'application/json; charset=utf-8' : 'text/csv; charset=utf-8'
response.headers['Content-Disposition'] =
"attachment; filename=\"counts-#{ exporter.resource }.#{ exporter.format }\""
response.headers['Cache-Control'] = 'private, no-store'
response.headers['X-Accel-Buffering'] = 'no'
# Rack::ETag otherwise materialises the response to calculate a digest.
response.headers['Last-Modified'] = Time.current.httpdate
self.response_body = exporter.body
rescue ArgumentError, TypeError, JSON::ParserError
head :bad_request
end
end
+1 -7
ファイルの表示
@@ -38,13 +38,7 @@ class PostVersionsController < ApplicationController
private private
def version_scope def version_scope
PostVersion.joins(<<~SQL.squish) PostVersion.with_previous_version
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*', .select('post_versions.*',
'prev.title AS prev_title', 'prev.title AS prev_title',
'prev.url AS prev_url', 'prev.url AS prev_url',
+5
ファイルの表示
@@ -1,6 +1,11 @@
class PostVersion < ApplicationRecord class PostVersion < ApplicationRecord
include VersionRecord include VersionRecord
scope :with_previous_version, -> {
joins('LEFT JOIN post_versions prev ON prev.post_id = post_versions.post_id ' +
'AND prev.version_no = post_versions.version_no - 1')
}
belongs_to :post belongs_to :post
belongs_to :parent, class_name: 'Post', optional: true belongs_to :parent, class_name: 'Post', optional: true
+10 -4
ファイルの表示
@@ -1,11 +1,13 @@
# Counts uses recorded values only; history/diff endpoints retain their own contract. # Counts uses recorded values only; history/diff endpoints retain their own contract.
class CountsVersionRepr class CountsVersionRepr
def self.many(rows, resource:, language_code:) def self.many(rows, resource:, language_code:, at: nil, inclusive: true)
new(rows.to_a, resource:, language_code:).serialise new(rows.to_a, resource:, language_code:, at:, inclusive:).serialise
end end
def initialize(rows, resource:, language_code:) def initialize(rows, resource:, language_code:, at:, inclusive:)
@rows = rows @rows = rows
@at = at
@inclusive = inclusive
@resource = resource @resource = resource
@language = language_code @language = language_code
@users = User.where(id: rows.map(&:created_by_user_id).compact).pluck(:id, :name).to_h @users = User.where(id: rows.map(&:created_by_user_id).compact).pluck(:id, :name).to_h
@@ -45,7 +47,11 @@ class CountsVersionRepr
.uniq .uniq
pairs.each_slice(500) do |batch| pairs.each_slice(500) do |batch|
placeholders = batch.map { '(?, ?)' }.join(', ') placeholders = batch.map { '(?, ?)' }.join(', ')
TagVersion.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten]) scope = TagVersion.all
if @at
scope = scope.where("created_at #{ @inclusive ? '<=' : '<' } ?", @at)
end
scope.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten])
.select(:tag_id, :version_no, :name, :category, :names).each do |version| .select(:tag_id, :version_no, :name, :category, :names).each do |version|
@tag_versions[[version.tag_id, version.version_no]] = version @tag_versions[[version.tag_id, version.version_no]] = version
end end
+229
ファイルの表示
@@ -0,0 +1,229 @@
require 'set'
# Export identifiers are stable; UI translations are supplied separately as column labels.
class CountsExportProjection
ROOT_KEYS = { 'resource_id' => 'resource_id', 'version_no' => 'version_no',
'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url',
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
'original_created_at' => 'original_created_at',
'original_created_from' => 'original_created_from',
'original_created_before' => 'original_created_before',
'user' => 'created_by_user', 'created_at' => 'created_at',
'name' => 'name', 'names' => 'names', 'category' => 'category',
'aliases' => 'aliases', 'deprecated' => 'deprecated' }.freeze
CHILD_KEYS = { 'id' => 'id', 'versionNo' => 'version_no', 'kind' => 'kind',
'name' => 'name', 'category' => 'category', 'sections' => 'sections',
'names' => 'names', 'aliases' => 'aliases',
'languageCode' => 'language', 'scriptCode' => 'script',
'autoGenerated' => 'auto_generated' }.freeze
NAME_FIELDS = ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated'].freeze
CHILDREN = {
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
'names' => NAME_FIELDS,
'aliases' => ['languageCode', 'name', 'scriptCode'],
'parents' => ['id'] }.freeze
attr_reader :columns, :headers
def initialize(params, resource:, summary:, tags:)
@params = params
@labels = JSON.parse(params.fetch('labels', '{}'))
raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100
raise ArgumentError unless @labels.all? { |key, value| valid_label?(key, value) }
@summary = summary
allowed = if summary
tags ? ['kind', 'tag_id', 'name', 'deprecated', 'count'] : ['group', 'count']
else
CountsQuery::COLUMNS.fetch(resource)
end
@columns = params.fetch('columns', allowed.join(',')).split(',')
raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns
@headers = JSON.parse(params.fetch('headers', JSON.generate(@columns)))
unless (@headers.is_a?(Array) && @headers.length == @columns.length &&
@headers.all? { _1.is_a?(String) && _1.length <= 200 })
raise ArgumentError
end
@order = params.fetch('order', '').split(',')
@sorts = params.fetch('child_sort', '').split(',').map do |entry|
parts = entry.split(':', -1)
raise ArgumentError unless parts.length == 2
path, direction = parts
raise ArgumentError unless ['asc', 'desc'].include?(direction)
[path, direction]
end
@selected = params.key?('children') ? params['children'].split(',').to_set : nil
valid = CHILDREN.flat_map do |key, fields|
paths(fields, key)
end
raise ArgumentError if @selected && (@selected.to_a - valid).any?
allowed_sorts = valid.map do |path|
parts = path.split('.')
field = parts.pop
"#{ parts.join('.') }/#{ field }"
end
if ((@sorts.map(&:first) - allowed_sorts).any? ||
@sorts.map(&:first).uniq.length != @sorts.length)
raise ArgumentError
end
end
def metadata
@columns.each_with_index.map do |key, index|
{ key: @summary ? key : ROOT_KEYS.fetch(key), label: @headers[index] }
end
end
def project(row)
@columns.to_h do |key|
if @summary
value = key == 'group' ? { key: row[:key], name: row[:name] } : row[key.to_sym]
[key, value]
else
value = case key
when 'thumbnail' then row[:thumbnail_base]
when 'original_created_at'
{ from: row[:original_created_from], before: row[:original_created_before] }
else row[ROOT_KEYS.fetch(key).to_sym]
end
value = nested(value || [], CHILDREN.fetch(key), key) if CHILDREN.key?(key)
[ROOT_KEYS.fetch(key), value]
end
end
end
def csv_record(record, row)
record.map do |key, value|
key == 'group' ? row[:name] || row[:key] : csv_display(value, key)
end
end
private
def valid_label?(key, value)
key.is_a?(String) && value.is_a?(String) && key.length <= 100 && value.length <= 300
end
def csv_display(value, key)
return value ? @labels.fetch('latest', 'true') : '' if key == 'is_latest'
return @labels.fetch("kind:#{ value }", value) if key == 'kind'
return @labels.fetch("category:#{ value }", value) if key == 'category'
if ['language', 'script'].include?(key) && value.is_a?(Hash)
return @labels.fetch("#{ key }:#{ value[:code] }",
value[:name] || @labels.fetch('notRegistered', ''))
end
return @labels.fetch("boolean:#{ value }", value) if value == true || value == false
return value.map { csv_display(_1, key) } if value.is_a?(Array)
if value.is_a?(Hash)
return value.to_h do |field, item|
[@labels.fetch(field.to_s, field), csv_display(item, field.to_s)]
end
end
value
end
def fields_for(key)
return NAME_FIELDS if key == 'names'
return ['name', 'scriptCode'] if key == 'aliases'
[]
end
def paths(fields, prefix)
fields.flat_map do |key|
path = "#{ prefix }.#{ key }"
[path, *paths(fields_for(key), path)]
end
end
def fetch(row, key) = row.key?(key) ? row[key] : row[key.to_sym]
def named_code(row, kind)
code = fetch(row, "#{ kind }_code")
name = fetch(row, "#{ kind }_name")
{ code:, name: name.present? && name.to_s.downcase != code.to_s.downcase ? name : nil }
end
def stable(value)
case value
when Hash
value.stringify_keys.sort.to_h.transform_values { stable(_1) }
when Array
value.map { stable(_1) }
else
value
end
end
def sort_value(row, key)
if key == 'languageCode'
language = named_code(row, 'language')
return @labels.fetch("language:#{ language[:code] }", language[:name])
end
return named_code(row, 'script')[:name] if key == 'scriptCode'
fetch(row, CHILD_KEYS.fetch(key))
end
def comparison(left, right)
if left.is_a?(Array) && right.is_a?(Array) && left.length != right.length
return left.length <=> right.length
end
return left <=> right if left.is_a?(Numeric) && right.is_a?(Numeric)
if [true, false].include?(left) && [true, false].include?(right)
return (left ? 1 : 0) <=> (right ? 1 : 0)
end
a = left.is_a?(Hash) || left.is_a?(Array) ? JSON.generate(stable(left)) : left.to_s
b = right.is_a?(Hash) || right.is_a?(Array) ? JSON.generate(stable(right)) : right.to_s
a <=> b
end
def sorted(rows, prefix, fields)
sorts = @sorts.filter_map do |path, direction|
parent, key = path.split('/', 2)
next unless parent == prefix
raise ArgumentError unless fields.include?(key)
[key, direction]
end
rows.each_with_index.sort do |(left, a), (right, b)|
result = 0
sorts.each do |key, direction|
first = sort_value(left, key)
last = sort_value(right, key)
result = if first.nil? || last.nil?
first.nil? == last.nil? ? 0 : (first.nil? ? 1 : -1)
else
comparison(first, last) * (direction == 'asc' ? 1 : -1)
end
break unless result.zero?
end
result.zero? ? a <=> b : result
end.map(&:first)
end
def nested(rows, fields, prefix)
keys = fields.select { !@selected || @selected.include?("#{ prefix }.#{ _1 }") }
ordered = (@order + keys.map { "#{ prefix }.#{ _1 }" }).uniq
.filter_map { |path| keys.find { "#{ prefix }.#{ _1 }" == path } }
sorted(rows, prefix, fields).map do |row|
ordered.to_h do |key|
stable = CHILD_KEYS.fetch(key)
value = case key
when 'languageCode' then named_code(row, 'language')
when 'scriptCode' then named_code(row, 'script')
else fetch(row, stable)
end
if fields_for(key).any?
value = nested(value || [], fields_for(key), "#{ prefix }.#{ key }")
end
[stable, value]
end
end
end
end
+138
ファイルの表示
@@ -0,0 +1,138 @@
require 'csv'
# One read view and a fixed point for the entire download; only one page is held in memory.
class CountsExporter
EXTRA_KEYS = ['basis', 'format', 'resource', 'headers', 'children', 'order',
'summary_columns', 'nested_json', 'child_sort', 'labels'].freeze
attr_reader :format, :resource
def initialize(params, language_code:)
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
raise ArgumentError if (params.keys - allowed).any?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@language = language_code
@format = params.fetch('format', 'json')
@resource = params.fetch('resource', 'posts')
@mode = params.fetch('mode', 'summary')
@history = params.fetch('basis', 'state') == 'history'
raise ArgumentError unless ['json', 'csv'].include?(format)
raise ArgumentError unless ['posts', 'tags'].include?(resource)
raise ArgumentError unless ['summary', 'details'].include?(@mode)
raise ArgumentError unless ['state', 'history'].include?(params.fetch('basis', 'state'))
@query_params = params.except(*EXTRA_KEYS, 'columns', 'page', 'limit')
.merge('mode' => @mode, 'page' => '1', 'limit' => '100')
point = CountsStateQuery.new(@query_params.slice('at').merge('resource' => resource),
language_code: @language).at
@query_params['at'] = point.iso8601(6)
@query_params['axis'] ||= !@history && resource == 'posts' ? 'tags' : 'month'
@projection = CountsExportProjection.new(params, resource:,
summary: @mode == 'summary',
tags: !@history && @query_params['axis'] == 'tags')
if @history
@query_params.delete('kind')
history_query
else
@query_params.delete('latest')
@state_query = CountsStateQuery.new(@query_params.merge('resource' => resource),
language_code: @language).validate_query!
end
end
def body
Enumerator.new do |output|
ApplicationRecord.transaction(isolation: :repeatable_read) do
first_row = true
started = false
pages do |page|
unless started
meta = { resource:, mode: @mode, basis: @history ? 'history' : 'state',
at: @query_params['at'], boundary: 'inclusive',
filters: @query_params.except('sort', 'page', 'limit', 'mode'),
sort: export_sort,
child_sort: sort_metadata(@params.fetch('child_sort', '')),
columns: @projection.metadata,
children: @params['children']&.split(','),
field_order: @params.fetch('order', '').split(','), timezone: 'UTC',
count: @mode == 'summary' ? page[:group_count] : page[:count],
totals: page[:totals], coverage: page[:coverage] }
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
if format == 'csv'
output << CSV.generate_line(
@projection.headers.map { csv_value(_1) }, row_sep: "\r\n")
end
started = true
end
(page[:versions] || page[:state_rows] || page[:groups] || []).each do |row|
record = @projection.project(row)
if format == 'json'
output << ',' unless first_row
output << record.to_json
else
values = @projection.csv_record(record, row).map { csv_value(_1) }
output << CSV.generate_line(values, row_sep: "\r\n")
end
first_row = false
end
end
output << ']}' if format == 'json'
end
end
end
private
def export_sort
sort_metadata(@query_params.fetch('sort', default_sort))
end
def sort_metadata(value)
value.split(',').map do |entry|
key, direction = entry.split(':', 2)
{ key:, direction: }
end
end
def default_sort
return 'created_at:desc' if @mode == 'details'
!@history && @query_params['axis'] == 'tags' ? 'count:desc' : 'group:asc'
end
def history_query
scope = resource == 'posts' ? PostVersion.with_previous_version : TagVersion.all
CountsQuery.new(scope, resource, @query_params)
end
def pages
unless @history
@state_query.each_page { yield _1 }
return
end
query = history_query
total = @mode == 'summary' ? nil : query.count
loop do
result = if @mode == 'summary'
query.summary(include_count: @query_params['page'] == '1')
else
{ count: total,
versions: CountsVersionRepr.many(query.details, resource:,
language_code: @language,
at: Time.iso8601(@query_params['at'])) }
end
total ||= result[:group_count]
yield result
break if @query_params['page'].to_i * 100 >= total
@query_params['page'] = (@query_params['page'].to_i + 1).to_s
query = history_query
end
end
def csv_value(value)
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
end
end
+13 -6
ファイルの表示
@@ -10,7 +10,7 @@ class CountsQuery
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze 'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
KEYS = [ KEYS = [
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest', 'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
'user_id', 'resource_id', 'tag', 'columns', 'sort'].freeze 'user_id', 'resource_id', 'tag', 'columns', 'sort', 'at'].freeze
attr_reader :page, :limit, :mode, :axis attr_reader :page, :limit, :mode, :axis
@@ -41,15 +41,19 @@ class CountsQuery
apply_filters apply_filters
end end
def relation = @scope
def count = @scope.count def count = @scope.count
def latest_expression def latest_expression
return @latest_expression if @latest_expression return @latest_expression if @latest_expression
at = timestamp('at')
cutoff = at ? "AND newer.created_at <= #{ @scope.klass.connection.quote(at) }" : ''
<<~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 }
AND newer.version_no > #{ @table }.version_no) AND newer.version_no > #{ @table }.version_no #{ cutoff })
SQL SQL
end end
@@ -59,7 +63,7 @@ class CountsQuery
.limit(limit).offset((page - 1) * limit) .limit(limit).offset((page - 1) * limit)
end end
def summary def summary(include_count: true)
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" : expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
"#{ @table }.created_by_user_id" "#{ @table }.created_by_user_id"
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
@@ -68,13 +72,13 @@ class CountsQuery
"#{ group_order } AS group_name")).to_sql "#{ group_order } AS group_name")).to_sql
connection = @scope.klass.connection connection = @scope.klass.connection
source = "FROM (#{ sql }) counts_groups" source = "FROM (#{ sql }) counts_groups"
groups = connection.select_value("SELECT COUNT(*) #{ source }") groups = include_count ? connection.select_value("SELECT COUNT(*) #{ source }") : nil
expressions = { 'group' => ['group_name'], 'count' => ['record_count'] } expressions = { 'group' => ['group_name'], 'count' => ['record_count'] }
order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC']) order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC'])
rows = connection.select_all( rows = connection.select_all(
"SELECT group_key, group_name, record_count #{ source } " + "SELECT group_key, group_name, record_count #{ source } " +
"ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }") "ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }")
{ group_count: groups.to_i, { group_count: groups&.to_i,
groups: rows.map do |row| groups: rows.map do |row|
{ key: row['group_key'], { key: row['group_key'],
name: axis == 'user' ? row['group_name'] : nil, name: axis == 'user' ? row['group_name'] : nil,
@@ -168,18 +172,21 @@ class CountsQuery
def timestamp(key) def timestamp(key)
value = @params[key].presence value = @params[key].presence
return nil unless value return nil unless value
raise ArgumentError unless value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z\z/) 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)) Date.iso8601(value.first(10))
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value) Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value)
end end
def apply_filters def apply_filters
at = timestamp('at')
first = timestamp('from') first = timestamp('from')
last = timestamp('before') last = timestamp('before')
raise ArgumentError if first && last && first >= last raise ArgumentError if first && last && first >= last
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1')) raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1'))
@scope = @scope.where("#{ @table }.created_at <= ?", at) if at
@scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0' @scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0'
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first @scope = @scope.where("#{ @table }.created_at >= ?", first) if first
@scope = @scope.where("#{ @table }.created_at < ?", last) if last @scope = @scope.where("#{ @table }.created_at < ?", last) if last
+98 -25
ファイルの表示
@@ -1,9 +1,11 @@
# State at an exclusive UTC boundary, reconstructed only from recorded snapshots. # Restores one recorded version per resource before applying record filters.
class CountsStateQuery class CountsStateQuery
KEYS = ['locale', 'resource', 'mode', 'at', 'from', 'before', 'grain', KEYS = ['locale', 'resource', 'mode', 'at', 'from', 'before', 'grain',
'kind', 'tag', 'page', 'limit', 'sort'].freeze 'kind', 'tag', 'page', 'limit', 'sort', 'axis', 'user_id', 'resource_id'].freeze
MAX_PERIODS = 366 MAX_PERIODS = 366
attr_reader :at
def initialize(params, language_code:) def initialize(params, language_code:)
raise ArgumentError unless (params.keys - KEYS).empty? raise ArgumentError unless (params.keys - KEYS).empty?
raise ArgumentError unless params.values.all? { _1.is_a?(String) } raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@@ -12,14 +14,21 @@ class CountsStateQuery
@connection = ApplicationRecord.connection @connection = ApplicationRecord.connection
@language = language_code @language = language_code
@resource = params.fetch('resource', 'posts') @resource = params.fetch('resource', 'posts')
@mode = params.fetch('mode', 'state') @mode = params.fetch('mode', 'summary')
@inclusive = ['summary', 'details'].include?(@mode)
@axis = params.fetch('axis', @resource == 'posts' ? 'tags' : 'month')
@kind = params.fetch('kind', 'internal') @kind = params.fetch('kind', 'internal')
@grain = params.fetch('grain', 'month') @grain = params.fetch('grain', 'month')
raise ArgumentError unless ['posts', 'tags'].include?(@resource) raise ArgumentError unless ['posts', 'tags'].include?(@resource)
raise ArgumentError unless ['state', 'trend', 'snapshot'].include?(@mode) raise ArgumentError unless ['summary', 'details', 'state', 'trend', 'snapshot'].include?(@mode)
raise ArgumentError unless ['internal', 'external', 'all'].include?(@kind) raise ArgumentError unless ['internal', 'external', 'all'].include?(@kind)
raise ArgumentError unless ['day', 'month', 'year'].include?(@grain) raise ArgumentError unless ['day', 'month', 'year'].include?(@grain)
raise ArgumentError unless ['tags', 'month', 'user'].include?(@axis)
if @mode == 'summary' && @axis == 'tags' && @resource != 'posts'
raise ArgumentError
end
@page = integer('page', 1) @page = integer('page', 1)
@limit = integer('limit', 20) @limit = integer('limit', 20)
@tag = integer('tag') if params['tag'].present? @tag = integer('tag') if params['tag'].present?
@@ -28,15 +37,19 @@ class CountsStateQuery
raise ArgumentError if @mode == 'trend' && !@tag raise ArgumentError if @mode == 'trend' && !@tag
@now = Time.current.utc @now = Time.current.utc
@at = timestamp('at') || @now.change(usec: 0) @at = timestamp('at') || @now
raise ArgumentError if @at > @now raise ArgumentError if @at > @now
end end
def call def call(metadata: true)
@metadata = metadata
# Keep totals, rows, and detail pages on one repeatable-read database snapshot. # Keep totals, rows, and detail pages on one repeatable-read database snapshot.
ApplicationRecord.transaction do ApplicationRecord.transaction do
unless @history_loaded
@post_start = PostVersion.minimum(:created_at) @post_start = PostVersion.minimum(:created_at)
@tag_start = TagVersion.minimum(:created_at) @tag_start = TagVersion.minimum(:created_at)
@history_loaded = true
end
result = { page: @page, limit: @limit, at: @at.iso8601(6), result = { page: @page, limit: @limit, at: @at.iso8601(6),
coverage: { basis: 'recorded_snapshots', exact: false, coverage: { basis: 'recorded_snapshots', exact: false,
post_history_from: @post_start&.iso8601(6), post_history_from: @post_start&.iso8601(6),
@@ -44,14 +57,45 @@ class CountsStateQuery
case @mode case @mode
when 'trend' when 'trend'
result.merge(trend) result.merge(trend)
when 'snapshot' when 'summary'
result.merge(totals: totals).merge(details) result.merge(metadata ? { totals: totals } : {}).merge(summary)
when 'details', 'snapshot'
result.merge(metadata ? { totals: totals } : {}).merge(details)
else else
result.merge(totals: totals).merge(listing) result.merge(metadata ? { totals: totals } : {}).merge(listing)
end end
end end
end end
def each_page
return enum_for(:each_page) unless block_given?
@page = 1
first = call
yield first
total = ['summary', 'state', 'trend'].include?(@mode) ? first[:group_count] : first[:count]
while @page * @limit < total
@page += 1
yield call(metadata: false)
end
end
def validate_query!
filtered_scope(@resource)
case @mode
when 'summary'
if @axis == 'tags'
order_sql({ 'name' => 'name', 'count' => 'post_count', 'tag_id' => 'tag_id',
'kind' => 'kind', 'deprecated' => 'deprecated_at' }, 'count:desc', [])
else
record_query('summary')
end
when 'details', 'snapshot'
record_query('details')
end
self
end
private private
def quote(value) = @connection.quote(value) def quote(value) = @connection.quote(value)
@@ -78,10 +122,10 @@ class CountsStateQuery
def latest_condition(table, row, boundary) def latest_condition(table, row, boundary)
key = table == 'post_versions' ? 'post_id' : 'tag_id' key = table == 'post_versions' ? 'post_id' : 'tag_id'
<<~SQL.squish <<~SQL.squish
#{ row }.created_at < #{ boundary } AND NOT EXISTS ( #{ row }.created_at #{ @inclusive ? '<=' : '<' } #{ boundary } AND NOT EXISTS (
SELECT 1 FROM #{ table } newer SELECT 1 FROM #{ table } newer
WHERE newer.#{ key } = #{ row }.#{ key } WHERE newer.#{ key } = #{ row }.#{ key }
AND newer.created_at < #{ boundary } AND newer.created_at #{ @inclusive ? '<=' : '<' } #{ boundary }
AND newer.version_no > #{ row }.version_no) AND newer.version_no > #{ row }.version_no)
SQL SQL
end end
@@ -110,7 +154,7 @@ class CountsStateQuery
def ctes def ctes
boundary = quote(@at) boundary = quote(@at)
<<~SQL <<~SQL
WITH post_at AS (#{ state_sql('post_versions', boundary, live: true) }), WITH post_at AS (#{ filtered_scope('posts').to_sql }),
tag_at AS (#{ state_sql('tag_versions', boundary) }), tag_at AS (#{ state_sql('tag_versions', boundary) }),
nico_at AS (#{ state_sql('nico_tag_versions', boundary) }), nico_at AS (#{ state_sql('nico_tag_versions', boundary) }),
pairs AS ( pairs AS (
@@ -133,7 +177,7 @@ class CountsStateQuery
row = @connection.select_one(<<~SQL) row = @connection.select_one(<<~SQL)
#{ ctes } #{ ctes }
SELECT (SELECT COUNT(*) FROM post_at) AS posts, SELECT (SELECT COUNT(*) FROM post_at) AS posts,
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard') AS tags, (SELECT COUNT(*) FROM (#{ filtered_scope('tags').to_sql }) counted_tags) AS tags,
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard' (SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard'
AND deprecated_at IS NOT NULL) AS deprecated_tags, AND deprecated_at IS NOT NULL) AS deprecated_tags,
(SELECT COUNT(*) FROM pairs) AS assignments, (SELECT COUNT(*) FROM pairs) AS assignments,
@@ -148,7 +192,9 @@ class CountsStateQuery
'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil) 'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil)
end end
def available?(first, boundary) = first && first < boundary def available?(first, boundary)
first && (@inclusive ? first <= boundary : first < boundary)
end
def listing def listing
path = quote("$.#{ JSON.generate(@language) }.name") path = quote("$.#{ JSON.generate(@language) }.name")
@@ -176,7 +222,9 @@ class CountsStateQuery
where = filters.any? ? "WHERE #{ filters.join(' AND ') }" : '' 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' source = 'FROM catalog c LEFT JOIN tag_counts n ON n.kind = c.kind AND n.tag_id = c.tag_id'
prefix = "#{ ctes } #{ catalog }" prefix = "#{ ctes } #{ catalog }"
total = @connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i total = if @metadata
@connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i
end
order = order_sql({ 'name' => 'c.name', 'count' => 'COALESCE(n.post_count, 0)', order = order_sql({ 'name' => 'c.name', 'count' => 'COALESCE(n.post_count, 0)',
'tag_id' => 'c.tag_id', 'kind' => 'c.kind', 'tag_id' => 'c.tag_id', 'kind' => 'c.kind',
'deprecated' => 'c.deprecated_at' }, 'count:desc', 'deprecated' => 'c.deprecated_at' }, 'count:desc',
@@ -195,13 +243,14 @@ class CountsStateQuery
end } end }
end end
def details # All record filters apply after selecting the latest recorded version at the point.
table = @resource == 'posts' ? 'post_versions' : 'tag_versions' def restored_scope(resource)
model = @resource == 'posts' ? PostVersion : TagVersion model = resource == 'posts' ? PostVersion : TagVersion
scope = model.joins("INNER JOIN (#{ state_sql(table, quote(@at), live: true) }) " + table = model.table_name
"counts_at ON counts_at.id = #{ table }.id") scope = model.where(Arel.sql(latest_condition(table, table, quote(@at))))
.where.not(event_type: 'discard')
if @tag if @tag
scope = if @resource == 'posts' scope = if resource == 'posts'
scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }") scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }")
elsif @kind == 'internal' elsif @kind == 'internal'
scope.where(tag_id: @tag) scope.where(tag_id: @tag)
@@ -209,12 +258,36 @@ class CountsStateQuery
scope.none scope.none
end end
end end
scope
end
def filtered_scope(resource)
params = @params.slice('from', 'before', 'user_id', 'resource_id')
.merge('mode' => 'details', 'latest' => '0', 'sort' => '')
CountsQuery.new(restored_scope(resource), resource, params,
latest_expression: '1').relation
end
def record_query(mode)
params = @params.slice('locale', 'sort', 'page', 'limit') params = @params.slice('locale', 'sort', 'page', 'limit')
.merge('mode' => 'details', 'latest' => '0') .merge('mode' => mode, 'latest' => '0', 'axis' => @axis == 'tags' ? 'month' : @axis,
query = CountsQuery.new(scope, @resource, params, latest_expression: '1') 'page' => @page.to_s, 'limit' => @limit.to_s)
{ count: query.count, CountsQuery.new(filtered_scope(@resource), @resource, params, latest_expression: '1')
end
def summary
return listing if @axis == 'tags'
query = record_query('summary')
{ count: @metadata ? query.count : nil }.merge(query.summary(include_count: @metadata))
end
def details
query = record_query('details')
{ count: @metadata ? query.count : nil,
versions: CountsVersionRepr.many(query.details, resource: @resource, versions: CountsVersionRepr.many(query.details, resource: @resource,
language_code: @language) } language_code: @language, at: @at,
inclusive: @inclusive) }
end end
def next_boundary(time) def next_boundary(time)
+2
ファイルの表示
@@ -1,4 +1,6 @@
Rails.application.routes.draw do Rails.application.routes.draw do
get 'counts/export', to: 'counts_exports#index'
post 'counts/export', to: 'counts_exports#index'
get 'counts/state', to: 'counts_states#index' 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'
+62 -27
ファイルの表示
@@ -1,28 +1,31 @@
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 { isCountsState, normaliseCountsParams } from '@/lib/counts' import { isCountsState, normaliseCountsParams } from '@/lib/counts'
import { countsSummaryFields } from '@/lib/countsState'
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'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import type { ReactNode } from 'react'
import type { CountsResource } from '@/lib/counts' import type { CountsResource } from '@/lib/counts'
const CountsControls = ( const CountsControls = (
{ resource, applied, open, setOpen, execute, displayJSON, reorder }: { { resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: {
resource: CountsResource resource: CountsResource
applied: URLSearchParams applied: URLSearchParams
open: boolean open: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
execute: (params: URLSearchParams) => void execute: (params: URLSearchParams) => void
displayJSON: (enabled: boolean) => void displayJSON: (enabled: boolean) => void
reorder: (value: string) => void }, reorder: (value: string) => void
actions?: ReactNode },
) => { ) => {
const { label, message } = useUITranslation ('counts') const { label, message } = useUITranslation ('counts')
const locale = useLocale () const locale = useLocale ()
@@ -43,13 +46,18 @@ 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') ?? 'state' const mode = draft.get ('mode') ?? 'summary'
const state = isCountsState (draft) const state = isCountsState (draft)
const axes = (state && resource === 'posts') ? ['tags', 'month', 'user'] : ['month', 'user']
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)
if (key === 'mode') next.delete ('sort') if (['mode', 'axis', 'basis'].includes (key)) next.delete ('sort')
if (value || key === 'columns' || key === 'children') next.set (key, value) 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) else next.delete (key)
return next return next
}) })
@@ -60,7 +68,7 @@ const CountsControls = (
try try
{ {
const next = new URLSearchParams (draft) 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 () ?? '' const value = next.get (key)?.trim () ?? ''
if (value && !/^[1-9][0-9]*$/.test (value)) if (value && !/^[1-9][0-9]*$/.test (value))
@@ -99,14 +107,16 @@ 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)}>
{[['state', 'summary'], ['trend', 'state.trend'], ['snapshot', 'state.details'], {['summary', 'details'].map (value => (
['summary', 'state.history'], ['details', 'state.historyDetails']].map ( <option key={value} value={value} lang={message (value).language}>
([value, key]) => <option key={value} value={value} lang={message (key).language}> {message (value).text}</option>))}
{message (key).text}</option>)}
</select> </select>
{actions}
</div> </div>
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>} {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"> <div className="grid gap-4 md:grid-cols-2">
<fieldset className="min-w-0" data-counts-output> <fieldset className="min-w-0" data-counts-output>
<legend className="mb-2 font-semibold">{label ('output')}</legend> <legend className="mb-2 font-semibold">{label ('output')}</legend>
@@ -118,33 +128,59 @@ const CountsControls = (
}}/> }}/>
{label ('nested.asJSON')} {label ('nested.asJSON')}
</label> </label>
{(mode === 'state' || mode === 'trend') ? ( <FormField label={label ('measure')}>
<p className="text-sm">{label ( {() => <select value={state ? 'state' : 'history'}
mode === 'trend' ? 'state.trend' : 'state.list')}</p>) : (mode === 'summary' ? ( className={inputClass (false, 'w-auto py-1')}
<label className="flex flex-wrap items-center gap-2"> 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')} {label ('axis')}
<select className={inputClass (false, 'w-auto py-1')} <select className={inputClass (false, 'w-auto py-1')}
value={draft.get ('axis') ?? 'month'} value={draft.get ('axis')
?? ((state && resource === 'posts') ? 'tags' : 'month')}
aria-label={message ('axis').text} lang={message ('axis').language} aria-label={message ('axis').text} lang={message ('axis').language}
onChange={event => update ('axis', event.target.value)}> onChange={event => update ('axis', event.target.value)}>
{['month', 'user'].map (axis => ( {axes.map (axis => <option key={axis} value={axis}
<option key={axis} value={axis} lang={message (`axes.${axis}`).language}> lang={message (`axes.${axis}`).language}>
{message (`axes.${axis}`).text}</option>))} {message (`axes.${axis}`).text}</option>)}
</select> </select>
</label>) : ( </label>}
<CountsFieldTree resource={resource} params={draft} update={(key, value) => { <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) 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}/> : <> <FormField label={label ('state.at')}>
<label className="flex items-center gap-1"> {() => <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'} <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')}/>
{label ('latestOnly')} {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"> <div className="grid gap-2 md:grid-cols-2">
{['from', 'before'].map (key => ( {['from', 'before'].map (key => (
<FormField key={key} label={label (`fields.${key}`)}> <FormField key={key} label={label (`fields.${key}`)}>
@@ -167,7 +203,6 @@ const CountsControls = (
className={inputClass (false, 'py-1')}/>} className={inputClass (false, 'py-1')}/>}
</FormField>))} </FormField>))}
</div> </div>
</>}
</fieldset> </fieldset>
</div> </div>
</div> </div>
+23 -13
ファイルの表示
@@ -2,7 +2,7 @@ import { DndContext, MouseSensor, TouchSensor, closestCenter,
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core' useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields, import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields,
selectedCountChildren, selectedCountColumns } from '@/lib/counts' selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
@@ -36,7 +36,8 @@ const FieldBranch = (
{label (field.label)} {label (field.label)}
</label> </label>
return ( 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')}> 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" <div ref={drag.setNodeRef} className="flex min-w-0 flex-1 items-start gap-1"
style={drag.transform == null ? undefined : { style={drag.transform == null ? undefined : {
@@ -60,7 +61,7 @@ const FieldBranch = (
{field.children ? ( {field.children ? (
<details className="my-1 min-w-0 flex-1"> <details className="my-1 min-w-0 flex-1">
<summary className="cursor-pointer">{control}</summary> <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} <FieldGroup fields={field.children} path={path} params={params}
enabled={enabled && checked} selected={selected} select={select} enabled={enabled && checked} selected={selected} select={select}
update={update}/> update={update}/>
@@ -99,6 +100,7 @@ const FieldGroup = (
onDragEnd={({ active, over }) => { onDragEnd={({ active, over }) => {
if (over != null && active.id !== over.id) move (String (active.id), String (over.id)) 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 => { {ordered.map (field => {
const position = movable.findIndex (item => item.key === field.key) const position = movable.findIndex (item => item.key === field.key)
return <FieldBranch key={field.key} field={field} return <FieldBranch key={field.key} field={field}
@@ -107,28 +109,36 @@ const FieldGroup = (
select={select} update={update} move={move} select={select} update={update} move={move}
previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/> previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/>
})} })}
</div>
</DndContext>) </DndContext>)
} }
const CountsFieldTree = ( const CountsFieldTree = (
{ resource, params, update }: { { resource, params, update, fields: suppliedFields,
selectionKey = 'columns', parent = '' }: {
resource: CountsResource resource: CountsResource
fields?: CountField[]
selectionKey?: string
parent?: string
params: URLSearchParams params: URLSearchParams
update: (key: string, value: string) => void }, update: (key: string, value: string) => void },
) => { ) => {
const { label } = useUITranslation ('counts') const { label } = useUITranslation ('counts')
const fields = fieldsForCounts (resource) const fields = suppliedFields ?? fieldsForCounts (resource)
const roots = selectedCountColumns (resource, params) const roots = suppliedFields == null ? selectedCountColumns (resource, params)
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
const selected = selectedCountChildren (params) 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) => { 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) const next = new Set<string> (roots)
if (checked) next.add (path) if (checked) next.add (root.key)
else next.delete (path) else next.delete (root.key)
update ('columns', fields.filter (field => next.has (field.key)) update (selectionKey, fields.filter (field => next.has (field.key))
.map (field => field.key).join (',')) .map (field => field.key).join (','))
return return
} }
@@ -139,9 +149,9 @@ const CountsFieldTree = (
} }
return ( return (
<> <>
<FieldGroup fields={fields} path="" params={params} enabled selected={selected} <FieldGroup fields={fields} path={parent} params={params} enabled selected={selected}
select={select} update={update}/> 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 { label, message } = useUITranslation ('counts')
const locale = useLocale () const locale = useLocale ()
const dialogue = useDialogue () const dialogue = useDialogue ()
const summary = (params.get ('mode') ?? 'summary') === 'summary'
const axis = params.get ('axis') ?? 'month'
const columns = selectedCountColumns (resource, params) const columns = selectedCountColumns (resource, params)
const fields = fieldsForCounts (resource) 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) => const sort = (key: string, additive: boolean) =>
update ('sort', writeCountSort (changeCountSort (sorts, key, additive))) 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 cell = (row: CountsVersion, column: CountColumn) => {
const field = fields.find (field => field.key === column)! const field = fields.find (field => field.key === column)!
const value = countsCellText (row, column, locale) const value = countsCellText (row, column, locale)
@@ -109,20 +86,15 @@ const CountsResults = (
} }
return ( return (
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}> <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
{!summary && columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : ( {columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
<CountsSelectableTable family={summary ? 'summary' : resource} <CountsSelectableTable family={resource}
columns={summary ? ['group', 'count'] : columns} columns={columns}
identity={`${resource}:${countsRequestParams (params)}:${columns.join (',')}:${ identity={`${resource}:${countsRequestParams (params)}:${columns.join (',')}:${
(data.versions ?? []).map (row => row.id)}`} (data.versions ?? []).map (row => row.id)}`}
className="text-left text-sm"> className="text-left text-sm">
<thead className="sticky top-0 z-20 bg-background text-foreground <thead className="sticky top-0 z-20 bg-background text-foreground
dark:bg-background dark:text-foreground"><tr> dark:bg-background dark:text-foreground"><tr>
{summary ? <> {columns.map (column => column === 'thumbnail'
<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'
? <th key={column} data-counts-heading={message (countColumns[column]).text} ? <th key={column} data-counts-heading={message (countColumns[column]).text}
className="relative px-2 py-1"> className="relative px-2 py-1">
<span className="sr-only">{label (countColumns[column])}</span> <span className="sr-only">{label (countColumns[column])}</span>
@@ -134,18 +106,7 @@ const CountsResults = (
field => field.key === column)?.children != null}/>)} field => field.key === column)?.children != null}/>)}
</tr></thead> </tr></thead>
<tbody> <tbody>
{summary ? (data.groups ?? []).map (group => ( {(data.versions ?? []).map (row => (
<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 => (
<tr key={row.id} className="border-b dark:border-slate-700"> <tr key={row.id} className="border-b dark:border-slate-700">
{columns.map (column => ( {columns.map (column => (
<td key={column} <td key={column}
@@ -158,7 +119,7 @@ const CountsResults = (
</tr>))} </tr>))}
</tbody> </tbody>
</CountsSelectableTable>)} </CountsSelectableTable>)}
{(summary ? data.groups?.length ?? 0 : data.versions?.length ?? 0) === 0 {(data.versions?.length ?? 0) === 0
&& <p className="p-2">{label ('empty')}</p>} && <p className="p-2">{label ('empty')}</p>}
</div>) </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
+51 -13
ファイルの表示
@@ -1,4 +1,4 @@
import { apiGet } from '@/lib/api' import { apiGet, apiPost } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales' import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types' 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 = ( export const normaliseCountsParams = (
resource: CountsResource, resource: CountsResource,
source: URLSearchParams, source: URLSearchParams,
): URLSearchParams => { ): URLSearchParams => {
const params = new URLSearchParams (source) const params = new URLSearchParams (source)
params.delete ('event_type') params.delete ('event_type')
if (!params.has ('mode')) const legacy = params.get ('mode')
params.set ('mode', source.has ('axis') ? 'summary' : 'state') if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
if (!['month', 'user'].includes (params.get ('axis') ?? '')) {
params.set ('axis', 'month') 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')) if (params.has ('columns'))
{ {
const selected = new Set (params.get ('columns')!.split (',')) const selected = new Set (params.get ('columns')!.split (','))
@@ -237,17 +263,14 @@ export const normaliseCountsParams = (
export const isCountsState = (params: URLSearchParams): boolean => export const isCountsState = (params: URLSearchParams): boolean =>
['state', 'trend', 'snapshot'].includes (params.get ('mode') ?? 'state') params.get ('basis') !== 'history'
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => { export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
const result = new URLSearchParams (params) const result = new URLSearchParams (params)
const hidden = isCountsState (params) const hidden = isCountsState (params) ? ['latest'] : ['kind']
? ['axis', 'latest', 'user_id', 'resource_id'] for (const key of ['columns', 'summary_columns', 'children', 'child_sort',
: ['at', 'grain', 'kind'] 'nested_json', 'order', 'basis', 'grain', ...hidden])
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
} }
@@ -257,11 +280,26 @@ export const fetchCounts = (
resource: CountsResource, resource: CountsResource,
params: URLSearchParams, params: URLSearchParams,
locale: LocaleCode, locale: LocaleCode,
): Promise<CountsResult> => apiGet (isCountsState (params) ? '/counts/state' : `/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 } : { }) } }) ...(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' })
const stableValue = (value: unknown): unknown => { const stableValue = (value: unknown): unknown => {
if (Array.isArray (value)) return value.map (stableValue) if (Array.isArray (value)) return value.map (stableValue)
+32
ファイルの表示
@@ -212,3 +212,35 @@ export const countsImageURL = (value: string | null | undefined): string | null
return 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 { 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' 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' }, export const countsSummaryFields = (
{ key: 'count', label: 'counts:state.postCount' }] resource: CountsResource,
: [{ key: 'kind', label: 'counts:columns.kind' }, params: URLSearchParams,
): CountField[] => (isCountsState (params) && params.get ('axis') === 'tags')
? [{ key: 'kind', label: 'counts:columns.kind' },
{ key: 'tag_id', label: 'counts:columns.resourceId' }, { key: 'tag_id', label: 'counts:columns.resourceId' },
{ key: 'name', label: 'history:tagName' }, { key: 'name', label: 'history:tagName' },
{ key: 'deprecated', label: 'counts:columns.deprecated' }, { key: 'deprecated', label: 'counts:columns.deprecated' },
{ key: 'count', label: 'counts:state.postCount' }] { 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 = ( export const selectedCountsSummaryFields = (
row: CountsStateRow, 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, key: string,
locale: LocaleCode, locale: LocaleCode,
): string => { ): { text: string; language?: string } => {
switch (key) switch (key)
{ {
case 'period': return row.period ?? '' case 'group': return row.key == null
case 'count': return row.count == null ? '—' : String (row.count) ? translateUIMessage ('history:bot', 'counts', locale)
case 'tag_id': return String (row.tagId) : { text: row.name ?? String (row.key) }
case 'kind': return translateUIMessage (`kinds.${row.kind}`, 'counts', locale).text case 'count': return { text: row.count == null ? '—' : String (row.count) }
case 'name': return row.name ?? translateUIMessage ('notRegistered', 'counts', locale).text 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 case 'deprecated': return row.deprecated
? translateUIMessage ('common:states.deprecated', 'counts', locale).text : '' ? translateUIMessage ('common:states.deprecated', 'counts', locale) : { text: '' }
default: return '' default: return { text: '' }
} }
} }
export const countsStateDrill = ( export const countsSummaryDrill = (
resource: CountsResource,
params: URLSearchParams, params: URLSearchParams,
at: string, row: CountsSummaryRow,
resource = 'posts',
row?: CountsStateRow,
): string => { ): string => {
const next = new URLSearchParams (params) const next = new URLSearchParams (params)
for (const key of ['sort', 'page', 'tag', 'from', 'before']) next.delete (key) for (const key of ['sort', 'page']) next.delete (key)
next.set ('mode', 'snapshot') next.set ('mode', 'details')
next.set ('at', at) if (row.at) next.set ('at', row.at)
if (row != null) if (row.tagId != null)
{ {
next.set ('kind', row.kind) next.set ('kind', row.kind!)
next.set ('tag', String (row.tagId)) 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}` return `/counts/${resource}?${next}`
} }
+11 -3
ファイルの表示
@@ -14,7 +14,8 @@
"axis": "集計軸", "axis": "集計軸",
"axes": { "axes": {
"month": "月別(UTC)", "month": "月別(UTC)",
"user": "$t(history:actor)" "user": "$t(history:actor)",
"tags": "タグ別"
}, },
"metric": "履歴レコード件数", "metric": "履歴レコード件数",
"total": "履歴レコード件数:{{count}}", "total": "履歴レコード件数:{{count}}",
@@ -96,8 +97,8 @@
"details": "時点明細", "details": "時点明細",
"history": "履歴集計", "history": "履歴集計",
"historyDetails": "履歴明細", "historyDetails": "履歴明細",
"at": "指定時点(端末の時刻帯・未満)", "at": "集計時点",
"boundary": "指定時点(UTC・未満)", "boundary": "集計時点(UTC)",
"period": "時点(UTC)", "period": "時点(UTC)",
"grain": "粒度(UTC)", "grain": "粒度(UTC)",
"grains": { "grains": {
@@ -111,5 +112,12 @@
"tags": "タグ総数(内部タグ)", "tags": "タグ総数(内部タグ)",
"assignments": "タグ付け総件数" "assignments": "タグ付け総件数"
} }
},
"measure": "指標",
"export": "エクスポート",
"exportFormat": "エクスポート形式",
"exportFormats": {
"json": "JSON",
"csv": "CSV"
} }
} }
+73 -58
ファイルの表示
@@ -4,7 +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 CountsSummaryResults from '@/components/counts/CountsSummaryResults'
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,12 +13,12 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' 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 { countsRequestParams, exportCounts, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts' isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsCSVCell } from '@/lib/countsDisplay' import { countsExportLabels } from '@/lib/countsDisplay'
import { countsStateCell, countsStateDrill, countsStateFields } from '@/lib/countsState' import { selectedCountsSummaryFields } from '@/lib/countsState'
import { countsKeys } from '@/lib/queryKeys' import { countsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils' import { cn, inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
@@ -37,18 +37,22 @@ 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') ?? 'state' const mode = params.get ('mode') ?? 'summary'
const state = isCountsState (params) const state = isCountsState (params)
const stateTable = mode === 'state' || mode === 'trend' const cacheParams = new URLSearchParams (request)
const axis = params.get ('axis') ?? 'month' 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 ({ const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
queryKey: countsKeys.index (resource, request.toString (), locale), queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
queryFn: () => fetchCounts (resource, request, locale), retry: false, queryFn: () => fetchCounts (resource, params, locale), retry: false,
placeholderData: (previous, query) => { placeholderData: (previous, query) => {
const previousMode = new URLSearchParams (String (query?.queryKey[3] ?? '')) const previousParams = new URLSearchParams (String (query?.queryKey[3] ?? ''))
.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 && previousParams.get ('mode') === mode
&& previousParams.get ('axis') === params.get ('axis')
&& previousParams.get ('basis') === cacheParams.get ('basis')) ? previous : undefined
} }) } })
useEffect (() => { useEffect (() => {
@@ -57,16 +61,12 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
}, [canonical, source, setParams]) }, [canonical, source, setParams])
useEffect (() => { useEffect (() => {
if (!state || data?.at == null || isPlaceholderData) return if (data?.at == null || isPlaceholderData) return
const next = new URLSearchParams (canonical) const next = new URLSearchParams (canonical)
if (!next.get ('at')) next.set ('at', data.at) 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 }) 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 update = (key: string, value: string) => {
const next = new URLSearchParams (params) const next = new URLSearchParams (params)
@@ -75,29 +75,32 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
if (key === 'sort') next.delete ('page') if (key === 'sort') next.delete ('page')
setParams (next) setParams (next)
} }
const exportCSV = () => { const download = async () => {
const columns = selectedCountColumns (resource, params) setExporting (true)
const stateFields = countsStateFields (mode === 'trend') setExportError (false)
const rows = stateTable try
? [stateFields.map (field => message (field.label).text), {
...(data?.stateRows ?? []).map (row => stateFields.map ( const fields = mode === 'summary' ? selectedCountsSummaryFields (resource, params)
field => countsStateCell (row, field.key, locale)))] : selectedCountColumns (resource, params).map (column =>
: (mode === 'summary' fieldsForCounts (resource).find (field => field.key === column)!)
? [[message (`axes.${axis}`).text, message ('metric').text], const file = await exportCounts (resource, params, locale, format,
...(data?.groups ?? []).map (group => [ fields.map (field => field.key), fields.map (field => message (field.label).text),
group.key == null ? message ('history:bot').text : group.name ?? String (group.key), countsExportLabels (resource, locale))
String (group.count)])] const url = URL.createObjectURL (file)
: [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') const link = document.createElement ('a')
link.href = url link.href = url
link.download = `counts-${resource}.csv` link.download = `counts-${resource}.${format}`
link.click () link.click ()
URL.revokeObjectURL (url) setTimeout (() => URL.revokeObjectURL (url), 1000)
}
catch
{
setExportError (true)
}
finally
{
setExporting (false)
}
} }
return ( return (
@@ -114,19 +117,23 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
lang={message ('target').language}> lang={message ('target').language}>
<h2 className="mb-2 font-semibold">{label ('target')}</h2> <h2 className="mb-2 font-semibold">{label ('target')}</h2>
<ul className="space-y-1"> <ul className="space-y-1">
{(['posts', 'tags'] as const).map (target => ( {(['posts', 'tags'] as const).map (target => {
<li key={target}><PrefetchLink to={`/counts/${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} aria-current={target === resource ? 'page' : undefined}
className={cn ( className={cn ('block py-1', target === resource && 'font-bold underline')}>
'block py-1', target === resource && 'font-bold underline')}>
{label (target === 'posts' ? 'history:post' : 'navigation:tags')} {label (target === 'posts' ? 'history:post' : 'navigation:tags')}
</PrefetchLink></li>))} </PrefetchLink></li>
})}
</ul> </ul>
</nav> </nav>
</SidebarComponent> </SidebarComponent>
<MainArea className="flex min-h-0 min-w-0 flex-col p-2"> <MainArea className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle> <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}`} <CountsControls key={`${resource}:${controlParams}`}
resource={resource} applied={params} resource={resource} applied={params}
open={open} setOpen={setOpen} open={open} setOpen={setOpen}
@@ -135,32 +142,40 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
execute={next => { execute={next => {
if (next.toString () === canonical) void refetch () if (next.toString () === canonical) void refetch ()
else setParams (next) else setParams (next)
}}/> }} actions={<>
<Button type="button" variant="outline" size="sm" className="absolute right-0 top-0" <select value={format} aria-label={message ('exportFormat').text}
title={message ('csvScope').text} lang={message ('csvScope').language} lang={message ('exportFormat').language}
disabled={data == null || isError || isPlaceholderData} onClick={exportCSV}> className={inputClass (false, 'w-auto py-1')}
{label ('csv')}</Button> 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> </div>
{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 && <>
{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> <span>{label ('state.boundary')}:{data.at.replace ('T', ' ')}</span>
{data.totals && (['posts', 'tags', 'assignments'] as const).map (key => ( {data.totals && (['posts', 'tags', 'assignments'] as const).map (key => (
<span key={key}>{label (`state.totals.${key}`)}: <span key={key}>{label (`state.totals.${key}`)}:
{key !== 'assignments' && data.totals![key] != null {data.totals![key] ?? '—'}
? <PrefetchLink to={countsStateDrill (params, data.at!, key)}>
{data.totals![key]}</PrefetchLink>
: data.totals![key] ?? '—'}
</span>))} </span>))}
</div>} </div>}
{!state && <p className="my-1 text-xs">{label ('total', { count: data.count })}</p>} {!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}/> : ( pending={isPlaceholderData} update={update}/> : (
<CountsResults resource={resource} data={data} params={params} <CountsResults resource={resource} data={data} params={params}
pending={isPlaceholderData} update={update}/>)} pending={isPlaceholderData} update={update}/>)}
<Pagination page={data.page} totalPages={Math.ceil ( <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> </MainArea>
</div> </div>