From 1154babe453352561cbed95a14f02571a88e6ac4 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 26 Oct 2026 21:53:09 +0900 Subject: [PATCH] #434 --- .../controllers/counts_tables_controller.rb | 2 +- backend/app/services/counts_axes.rb | 10 +- backend/app/services/counts_defaults.rb | 46 ++++ .../app/services/counts_export_projection.rb | 6 +- backend/app/services/counts_pivot_layout.rb | 28 +++ backend/app/services/counts_pivot_totals.rb | 59 ++++++ backend/app/services/counts_query.rb | 4 +- backend/app/services/counts_table_export.rb | 107 +++++++++- backend/app/services/counts_table_query.rb | 83 ++++++-- .../src/components/counts/CountsControls.tsx | 27 ++- .../src/components/counts/CountsRowNumber.tsx | 8 +- .../counts/CountsSelectableTable.tsx | 1 + .../components/counts/CountsSortHeader.tsx | 9 +- .../counts/CountsSummaryResults.tsx | 199 ++++++++++++------ .../src/components/counts/countsTable.css | 11 + frontend/src/lib/counts.ts | 42 +++- frontend/src/lib/countsClipboard.ts | 5 +- frontend/src/lib/countsDefinitions.ts | 8 +- frontend/src/lib/countsDisplay.ts | 13 +- frontend/src/lib/countsTableLayout.ts | 2 +- frontend/src/locales/ja/counts.json | 10 +- frontend/src/pages/CountsResourcePage.tsx | 2 +- 22 files changed, 537 insertions(+), 145 deletions(-) create mode 100644 backend/app/services/counts_defaults.rb create mode 100644 backend/app/services/counts_pivot_layout.rb create mode 100644 backend/app/services/counts_pivot_totals.rb diff --git a/backend/app/controllers/counts_tables_controller.rb b/backend/app/controllers/counts_tables_controller.rb index 503975e..a93aaa7 100644 --- a/backend/app/controllers/counts_tables_controller.rb +++ b/backend/app/controllers/counts_tables_controller.rb @@ -4,7 +4,7 @@ class CountsTablesController < ApplicationController definition = CountsResourceDefinition.new(params[:resource]) render json: { fields: definition.metadata, axes: { latest: definition.axes(false), history: definition.axes(true) }, - grains: CountsAxes::GRAINS } + grains: CountsAxes::GRAINS, defaults: CountsDefaults.metadata(params[:resource]) } rescue ArgumentError head :bad_request end diff --git a/backend/app/services/counts_axes.rb b/backend/app/services/counts_axes.rb index 0697966..91b9802 100644 --- a/backend/app/services/counts_axes.rb +++ b/backend/app/services/counts_axes.rb @@ -40,7 +40,7 @@ class CountsAxes end def expressions - all.map do |axis| + @expressions ||= all.map do |axis| field = axis.fetch('field') case field when 'tags' @@ -56,9 +56,9 @@ class CountsAxes end end - def joins + def joins(fields = all.map { _1['field'] }) result = '' - if all.any? { _1['field'] == 'tags' } + if fields.include?('tags') result += <<~SQL.squish JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS ( tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY, @@ -66,7 +66,7 @@ class CountsAxes SQL end - if all.any? { _1['field'] == 'changed_field' } + if fields.include?('changed_field') fields = @definition.changes.map do |field| "SELECT #{ ApplicationRecord.connection.quote(field) } AS field_key" end.join(' UNION ALL ') @@ -89,6 +89,8 @@ class CountsAxes raise ArgumentError unless @definition.axes(@population.history).include?(axis['field']) if @definition.fields[axis['field']] == :datetime raise ArgumentError unless GRAINS.include?(axis.fetch('grain', 'month')) + + axis['grain'] ||= 'month' elsif axis.key?('grain') raise ArgumentError end diff --git a/backend/app/services/counts_defaults.rb b/backend/app/services/counts_defaults.rb new file mode 100644 index 0000000..48a9347 --- /dev/null +++ b/backend/app/services/counts_defaults.rb @@ -0,0 +1,46 @@ +# Only confirmed combinations belong here; all others retain the existing behaviour. +class CountsDefaults + POST_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'title', 'url', + 'thumbnail_base', 'tags', 'parents', 'original_created_from', + 'original_created_before', 'event_type', 'user', 'created_at'].freeze + + def self.entries(resource) + result = [{ definition: 'history', mode: 'summary', params: { + 'rows' => [{ field: 'created_at', grain: 'month' }].to_json, + 'cols' => [{ field: 'event_type' }].to_json, + 'sort' => 'a0:desc', 'conditions' => '[]' } }] + if resource == 'posts' + result += [{ definition: 'latest', mode: 'summary', params: { + 'rows' => '[]', 'cols' => '[]', 'conditions' => '[]', 'tags' => '' } }, + { definition: 'latest', mode: 'details', params: { + 'columns' => POST_COLUMNS.join(','), 'order' => POST_COLUMNS.join(','), + 'children' => 'tags.name,parents.id', 'nested_json' => '0', + 'conditions' => '[]', 'tags' => '' } }] + end + result + end + + def self.for(params) + settings = entries(params.fetch('resource', 'posts')).find do |entry| + entry[:definition] == params.fetch('definition', 'latest') && + entry[:mode] == params.fetch('mode', 'summary') + end&.fetch(:params) || {} + if settings.key?('sort') && params.key?('rows') + actual = JSON.parse(params['rows']) + expected = JSON.parse(settings.fetch('rows')) + compatible = actual.is_a?(Array) && actual.first.is_a?(Hash) && + actual.first['field'] == expected.first['field'] && + actual.first.fetch('grain', 'month') == expected.first['grain'] + unless compatible + settings = settings.except('sort') + end + end + settings + end + + def self.metadata(resource) + entries(resource).map do |entry| + entry.merge(params: entry[:params].map { |key, value| { key:, value: } }) + end + end +end diff --git a/backend/app/services/counts_export_projection.rb b/backend/app/services/counts_export_projection.rb index 3285d3b..3474007 100644 --- a/backend/app/services/counts_export_projection.rb +++ b/backend/app/services/counts_export_projection.rb @@ -6,7 +6,6 @@ class CountsExportProjection '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', @@ -43,6 +42,9 @@ class CountsExportProjection attr_reader :columns, :headers def initialize(params, resource:, summary:, tags:) + if params.key?('definition') + params = CountsDefaults.for(params.merge('resource' => resource)).merge(params) + end @params = params @labels = JSON.parse(params.fetch('labels', '{}')) raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100 @@ -103,8 +105,6 @@ class CountsExportProjection 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) diff --git a/backend/app/services/counts_pivot_layout.rb b/backend/app/services/counts_pivot_layout.rb new file mode 100644 index 0000000..8fe1d57 --- /dev/null +++ b/backend/app/services/counts_pivot_layout.rb @@ -0,0 +1,28 @@ +# Coordinates carry their aggregation depth; SQL NULL remains an ordinary axis value. +class CountsPivotLayout + def self.depths(keys, sorts, measures: true) + return [] if keys.length < 2 || sorts.first&.first == 'count' || + sorts.first&.first&.start_with?('measure_') + + relevant = sorts.map(&:first).select { keys.include?(_1) || (measures && (_1 == 'count' || + _1.start_with?('measure_'))) } + order = (relevant + keys).uniq + (1...keys.length).select { order.first(_1).sort == keys.first(_1).sort } + end + + def self.headers(values, depths, next_values: nil, grand: false) + result = [] + values.each_with_index do |tuple, index| + result << { values: tuple, kind: 'data' } + following = values[index + 1] || next_values + depths.reverse_each do |depth| + prefix = tuple.first(depth) + next if following && following.first(depth) == prefix + + result << { values: prefix, kind: 'subtotal' } + end + end + result << { values: [], kind: 'grand_total' } if grand + result + end +end diff --git a/backend/app/services/counts_pivot_totals.rb b/backend/app/services/counts_pivot_totals.rb new file mode 100644 index 0000000..d7aa15c --- /dev/null +++ b/backend/app/services/counts_pivot_totals.rb @@ -0,0 +1,59 @@ +# Re-aggregate the population, never add up already grouped cells (multi-value axes overlap). +class CountsPivotTotals + def initialize(population, axes, row_headers, column_headers) + @population = population + @axes = axes + @rows = row_headers.group_by { _1[:values].length } + @columns = column_headers.group_by { _1[:values].length } + end + + def call + queries = @rows.flat_map do |row_depth, headers| + @columns.filter_map do |column_depth, columns| + next if row_depth == @axes.rows.length && column_depth == @axes.columns.length + + indexes = (0...row_depth).to_a + + (0...column_depth).map { @axes.rows.length + _1 } + expressions = indexes.map { @axes.expressions[_1] } + scope = @population.scope.except(:select, :order) + joins = @axes.joins(indexes.map { @axes.all[_1].fetch('field') }) + scope = scope.joins(joins) unless joins.empty? + if row_depth.positive? + tuples = headers.map { _1[:values] }.uniq + predicate = tuples.map { Array.new(row_depth) { |i| + "(#{ expressions[i] }) <=> ?" + }.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ') + scope = scope.where(predicate, *tuples.flatten) + end + if column_depth.positive? + tuples = columns.map { _1[:values] }.uniq + predicate = tuples.map { Array.new(column_depth) { |i| + "(#{ expressions[row_depth + i] }) <=> ?" + }.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ') + scope = scope.where(predicate, *tuples.flatten) + end + grouped = expressions.each_with_index.map { |expression, index| + "#{ expression } AS v#{ index }" + } + # Fixed-width result coordinates, independent of NULL and of translated labels. + padding = (expressions.length...@axes.all.length).map { "NULL AS v#{ _1 }" } + fields = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] + + grouped + padding + ['COUNT(DISTINCT v.id) AS count'] + scope = scope.group(*expressions.map { Arel.sql(_1) }) if expressions.any? + scope.optimizer_hints('MAX_EXECUTION_TIME(10000)') + .select(Arel.sql(fields.join(', '))).to_sql + end + end + return [] if queries.empty? + + sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ') + @population.definition.model.connection.select_all( + "SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sql }) totals").map do |record| + row_depth = record['row_depth'].to_i + column_depth = record['column_depth'].to_i + { row: (0...row_depth).map { record["v#{ _1 }"] }, + column: (0...column_depth).map { record["v#{ row_depth + _1 }"] }, + count: record['count'].to_i } + end + end +end diff --git a/backend/app/services/counts_query.rb b/backend/app/services/counts_query.rb index fb47736..3da4017 100644 --- a/backend/app/services/counts_query.rb +++ b/backend/app/services/counts_query.rb @@ -5,7 +5,7 @@ class CountsQuery COLUMNS = { 'posts' => COMMON_COLUMNS + [ 'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms', - 'original_created_at', 'original_created_from', 'original_created_before'], + 'original_created_from', 'original_created_before'], 'tags' => COMMON_COLUMNS + [ 'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze KEYS = [ @@ -124,8 +124,6 @@ class CountsQuery 'title', 'url', 'thumbnail_base', 'video_ms', 'original_created_from', 'original_created_before'] common.merge(fields.to_h { [_1, ["#{ @table }.#{ _1 }"]] }).merge( - 'original_created_at' => [ - 'post_versions.original_created_from', 'post_versions.original_created_before'], 'tags' => json_order('post_versions.tags_json'), 'parents' => list_order('post_versions.parent_post_ids')) else diff --git a/backend/app/services/counts_table_export.rb b/backend/app/services/counts_table_export.rb index 6f89b7e..aab4568 100644 --- a/backend/app/services/counts_table_export.rb +++ b/backend/app/services/counts_table_export.rb @@ -10,6 +10,7 @@ class CountsTableExport raise ArgumentError if (params.keys - CountsTableQuery::KEYS - EXTRA).any? raise ArgumentError unless params.values.all? { _1.is_a?(String) } + params = CountsDefaults.for(params).merge(params) unless params.key?('children') params = params.merge('children' => CountsExportProjection.default_children( params.fetch('resource', 'posts')).join(',')) @@ -63,7 +64,8 @@ class CountsTableExport total = @summary ? nil : page[:count] progress&.call(processed, total) unless started - metadata = page.except(:versions, :cells, :axis_labels, :column_values).merge( + metadata = page.except(:versions, :cells, :axis_labels, :column_values, + :row_headers, :column_headers, :pivot_totals).merge( resource:, filters: @params.slice(*CountsTableQuery::KEYS), fields: @query.population.definition.metadata, columns: @columns, column_labels: @headers, @@ -71,12 +73,35 @@ class CountsTableExport sort: @params.fetch('sort', ''), child_sort: @params['child_sort']) output << (format == 'json' ? "{\"metadata\":#{ metadata.to_json },\"data\":[" : "\uFEFF") if format == 'csv' - headers = @summary ? summary_headers : @headers - output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n") + headings = @summary ? pivot_headers(page) : [@headers] + headings.each do |headers| + output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n") + end end started = true end - (page[:cells] || page[:versions]).each do |row| + if @summary + pivot_rows(page).each do |header| + records = page.fetch(:column_headers).map { pivot_cell(header, _1, page) } + if format == 'csv' + labels = pivot_row_labels(header, page) + values = labels + records.map { _1[:count] } + output << CSV.generate_line(values.map { safe(_1) }, row_sep: "\r\n") + processed += records.length + progress&.call(processed, nil) + else + records.each do |record| + output << ',' unless first + output << record.to_json + first = false + processed += 1 + progress&.call(processed, nil) + end + end + end + next + end + page[:versions].each do |row| record = project(row) if format == 'json' output << ',' unless first @@ -106,12 +131,76 @@ class CountsTableExport private - def summary_headers - return @headers if @headers.any? + def pivot_rows(page) + page.fetch(:row_headers).reject do |header| + header[:kind] == 'grand_total' && @query.page * @query.limit < page[:group_count] + end + end - labels = @labels - @query.axes.all.map { labels.fetch(_1['field'], _1['field']) } + - [labels.fetch('count', 'count')] + def total_label(header) + @labels.fetch(header[:kind] == 'grand_total' ? 'grandTotal' : 'subtotal', '') + end + + def pivot_row_labels(header, page) + values = header[:values].each_with_index.map { |value, index| + summary_value(value, index, page) + } + values << total_label(header) unless header[:kind] == 'data' + leading = [@query.axes.rows.length, @query.axes.columns.any? ? 1 : 0].max + Array.new(leading) { values[_1] || '' } + end + + def pivot_headers(page) + row_count = @query.axes.rows.length + cols = @query.axes.columns + leading = [row_count, cols.any? ? 1 : 0].max + columns = page.fetch(:column_headers) + Array.new([cols.length, 1].max) do |level| + prefix = Array.new(leading) { level.zero? ? @headers[_1].to_s : '' } + prefix = [''] if row_count.zero? && cols.any? + prefix + columns.each_with_index.map do |header, index| + values = header[:values] + if cols.empty? + header[:kind] == 'data' ? @labels.fetch('count', '') : total_label(header) + elsif level == values.length && header[:kind] != 'data' + total_label(header) + elsif level < values.length + previous = index.positive? ? columns[index - 1][:values] : nil + if level < cols.length - 1 && previous && previous.first(level + 1) == values.first(level + 1) + '' + else + summary_value(values[level], row_count + level, page) + end + else + '' + end + end + end + end + + def pivot_cell(row, column, page) + @pivot_lookup ||= {} + # Discard the previous page's lookup; memory remains bounded by one page and its rollups. + unless @pivot_page.equal?(page) + @pivot_page = page + @pivot_lookup = page.fetch(:cells).to_h do |cell| + values = cell[:values] + [[values.first(@query.axes.rows.length), values.drop(@query.axes.rows.length)], cell[:count]] + end + page.fetch(:pivot_totals).each { @pivot_lookup[[_1[:row], _1[:column]]] = _1[:count] } + end + kinds = [row[:kind], column[:kind]] + kind = if kinds.include?('grand_total') + 'grand_total' + elsif kinds.include?('subtotal') + 'subtotal' + else + 'data' + end + { row: row[:values], column: column[:values], kind:, + row_kind: row[:kind], column_kind: column[:kind], + row_depth: row[:values].length, column_depth: column[:values].length, + count: @pivot_lookup.fetch([row[:values], column[:values]], 0) } end def summary_value(value, index, page) diff --git a/backend/app/services/counts_table_query.rb b/backend/app/services/counts_table_query.rb index bca43c8..37b31b2 100644 --- a/backend/app/services/counts_table_query.rb +++ b/backend/app/services/counts_table_query.rb @@ -12,6 +12,7 @@ class CountsTableQuery raise ArgumentError if (params.keys - KEYS).any? raise ArgumentError unless params.values.all? { _1.is_a?(String) } + params = CountsDefaults.for(params).slice(*KEYS).merge(params) @params = params @population = CountsPopulation.new(params) @definition = population.definition @@ -26,7 +27,7 @@ class CountsTableQuery @sort = params.fetch('sort', '').split(',').map { _1.split(':', -1) } allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] : @definition.fields.keys + ['is_latest'] + - (@definition.resource == 'posts' ? ['tags', 'parents', 'original_created_at'] : []) + + (@definition.resource == 'posts' ? ['tags', 'parents'] : []) + (@definition.resource == 'tags' ? ['parents', 'deprecated'] : []) raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length @sort.each do |key, direction, *extra| @@ -72,25 +73,26 @@ class CountsTableQuery # Page whole row tuples so a pivot row is never split across display pages. row_selection = row_keys.empty? ? '1 AS singleton' : row_keys.join(', ') row_group = row_keys.empty? ? 'GROUP BY singleton' : "GROUP BY #{ row_keys.join(', ') }" + measure_joins = [] + extras = [] row_sort = @sort.each_with_index.filter_map do |(key, direction), index| if key == 'count' || key.start_with?('measure_') - condition = if key == 'count' - 'TRUE' - else - measure_values(key).each_with_index.map do |value, offset| - "a#{ axes.rows.length + offset } <=> #{ @connection.quote(value) }" - end.join(' AND ').presence || 'TRUE' - end - ["MAX(CASE WHEN #{ condition } THEN count END) AS sort#{ index }", - "sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }"] + values = key == 'count' ? [] : measure_values(key) + relation = measure_relation(values) + matches = row_keys.map { "grouped.#{ _1 } <=> sort#{ index }.#{ _1 }" } + condition = matches.empty? ? 'TRUE' : matches.join(' AND ') + measure_joins << "LEFT JOIN (#{ relation }) sort#{ index } ON #{ condition }" + extras << "sort#{ index }.count AS sort#{ index }" + "sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }" elsif row_keys.include?(key) - [nil, "#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"] + "#{ key } IS NULL ASC, #{ key } #{ direction.upcase }" end end - extras = row_sort.filter_map(&:first) - order_rows = row_sort.map(&:last) + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" } + order_rows = row_sort + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" } order_rows = ['singleton ASC'] if order_rows.empty? - group_sql = "SELECT #{ ([row_selection] + extras).join(', ') } FROM cells #{ row_group }" + grouped = "SELECT #{ row_selection } FROM cells #{ row_group }" + selection = (['grouped.*'] + extras).join(', ') + group_sql = "SELECT #{ selection } FROM (#{ grouped }) grouped #{ measure_joins.join(' ') }" total = @connection.select_value(<<~SQL).to_i #{ prefix } SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (#{ group_sql }) row_groups @@ -113,7 +115,30 @@ class CountsTableQuery SELECT /*+ MAX_EXECUTION_TIME(10000) */ cells.* FROM cells JOIN page_rows ON #{ join } ORDER BY #{ order.join(', ') } SQL - { group_count: total, column_values:, cells: records.map do |row| + next_row = @connection.select_one(<<~SQL) + #{ prefix }, row_groups AS (#{ group_sql }), + numbered AS (SELECT row_groups.*, + ROW_NUMBER() OVER (ORDER BY #{ order_rows.join(', ') }) AS position FROM row_groups) + SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM numbered + WHERE position = #{ page * limit + 1 } + SQL + row_values = records.map { |record| row_keys.map { record[_1] } }.uniq + row_depths = CountsPivotLayout.depths(row_keys, @sort) + column_keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" } + column_depths = CountsPivotLayout.depths(column_keys, @sort, measures: false) + row_headers = CountsPivotLayout.headers(row_values, row_depths, + next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?) + if axes.rows.empty? && axes.columns.any? + row_headers = [{ values: [], kind: 'grand_total' }] + end + column_headers = CountsPivotLayout.headers(column_values, column_depths, + grand: axes.columns.any?) + if axes.columns.empty? && axes.rows.any? + column_headers = [{ values: [], kind: 'grand_total' }] + end + totals = CountsPivotTotals.new(population, axes, row_headers, column_headers).call + { group_count: total, column_values:, row_headers:, column_headers:, pivot_totals: totals, + cells: records.map do |row| { values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i } end, axis_labels: axis_labels(records, column_values) } end @@ -167,7 +192,11 @@ class CountsTableQuery keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" } return [[]] if keys.empty? - order = keys.map { "#{ _1 } IS NULL, #{ _1 }" }.join(', ') + configured = @sort.select { |key, _direction| keys.include?(key) } + directions = (configured + keys.map { [_1, 'asc'] }).uniq { _1.first } + order = directions.map { |key, direction| + "#{ key } IS NULL ASC, #{ key } #{ direction.upcase }" + }.join(', ') values = @connection.select_rows(<<~SQL) #{ prefix } SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells @@ -178,9 +207,26 @@ class CountsTableQuery values end + def measure_relation(values) + row_expressions = axes.expressions.first(axes.rows.length) + source = population.scope.except(:select, :order) + fields = axes.rows + axes.columns.first(values.length) + joins = axes.joins(fields.map { _1.fetch('field') }) + source = source.joins(joins) unless joins.empty? + values.each_with_index do |value, index| + expression = axes.expressions[axes.rows.length + index] + source = source.where("(#{ expression }) <=> ?", value) + end + selected = row_expressions.each_with_index.map { |expression, index| + "#{ expression } AS a#{ index }" + } + ['COUNT(DISTINCT v.id) AS count'] + source = source.group(*row_expressions.map { Arel.sql(_1) }) if row_expressions.any? + source.select(Arel.sql(selected.join(', '))).to_sql + end + def measure_values(key) value = JSON.parse([key.delete_prefix('measure_')].pack('H*')) - unless (value.is_a?(Array) && value.length == axes.columns.length && + unless (value.is_a?(Array) && value.length <= axes.columns.length && value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) }) raise ArgumentError end @@ -190,8 +236,7 @@ class CountsTableQuery def detail_expression(key) return "(#{ population.latest_sql })" if key == 'is_latest' aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ? - 'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at', - 'original_created_at' => 'original_created_from' } + 'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at' } field = aliases.fetch(key, key) expression = @definition.expression(field) return expression unless @definition.fields[field] == :json diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx index 79da94c..6981f59 100644 --- a/frontend/src/components/counts/CountsControls.tsx +++ b/frontend/src/components/counts/CountsControls.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions' import CountsAxes from '@/components/counts/CountsAxes' @@ -9,10 +9,10 @@ import FormField from '@/components/common/FormField' import HelpPopover from '@/components/common/HelpPopover' import TagInput from '@/components/common/TagInput' import { Button } from '@/components/ui/button' -import { countsUseLatest, fieldsForCounts, normaliseCountsParams, +import { countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort, selectedCountChildren, selectedCountColumns } from '@/lib/counts' import { countsNestedIsScalar } from '@/lib/countsDisplay' -import { useCountsDefinition } from '@/lib/countsDefinitions' +import { readCountsJSON, useCountsDefinition } from '@/lib/countsDefinitions' import { useClientMotion } from '@/lib/useClientMotion' import { inputClass } from '@/lib/utils' import { useUITranslation } from '@/lib/useUITranslation' @@ -42,6 +42,7 @@ const CountsControls = ( const nestedJSON = applied.get ('nested_json') const rowNumbers = applied.get ('row_numbers') const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail') + const previousThumbnail = useRef (thumbnail) const mode = draft.get ('mode') ?? 'summary' const columns = selectedCountColumns (resource, draft, definition) const details = mode === 'details' @@ -50,8 +51,10 @@ const CountsControls = ( && !countsNestedIsScalar (field.children, field.key, draft) && field.children.some (child => selectedCountChildren (draft) .has (`${field.key}.${child.key}`))) - // Immediate presentation changes merge into the pending query, never replace it. + // Preserve absent columns until a real presentation change or an explicit edit. useEffect (() => { + if (previousThumbnail.current === thumbnail) return + previousThumbnail.current = thumbnail setDraft (previous => { const next = new URLSearchParams (previous) const columns = selectedCountColumns (resource, previous, definition) @@ -75,16 +78,26 @@ const CountsControls = ( const history = draft.get ('definition') === 'history' const update = (key: string, value: string) => setDraft (previous => { const next = new URLSearchParams (previous) - if (['mode', 'rows', 'cols'].includes (key)) next.delete ('sort') + if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort') next.set (key, value) - return next + if (key !== 'mode') return next + const resolved = normaliseCountsParams (resource, next, definition) + const axes = [...readCountsJSON (resolved, 'rows', []), + ...readCountsJSON (resolved, 'cols', [])] + const allowed = value === 'summary' + ? ['count', ...axes.map ((_, index) => `a${index}`)] + : fieldsForCounts (resource, definition).map (field => field.key) + if (readCountSort (resolved.get ('sort')).some (sort => !allowed.includes (sort.key) + && !(value === 'summary' && /^measure_[0-9a-f]+$/.test (sort.key)))) + next.delete ('sort') + return normaliseCountsParams (resource, next, definition) }) return
{ event.preventDefault () const next = new URLSearchParams (draft) next.delete ('page') next.delete ('execution_at') - execute (normaliseCountsParams (resource, next)) + execute (normaliseCountsParams (resource, next, definition)) }}> {!history &&
diff --git a/frontend/src/components/counts/CountsRowNumber.tsx b/frontend/src/components/counts/CountsRowNumber.tsx index 11f77a0..822eb1c 100644 --- a/frontend/src/components/counts/CountsRowNumber.tsx +++ b/frontend/src/components/counts/CountsRowNumber.tsx @@ -2,14 +2,14 @@ import CountsResizeHandle from '@/components/counts/CountsResizeHandle' import { useUITranslation } from '@/lib/useUITranslation' -const CountsRowNumber = ({ value }: { value?: number }) => { +const CountsRowNumber = ({ value, rowSpan }: { value?: number; rowSpan?: number }) => { const { label, message } = useUITranslation ('counts') if (value != null) return {value} - return - {label ('rowNumbers')} - + return + {label ('rowNumberHeading')} + } diff --git a/frontend/src/components/counts/CountsSelectableTable.tsx b/frontend/src/components/counts/CountsSelectableTable.tsx index 4ddb4a9..d448e94 100644 --- a/frontend/src/components/counts/CountsSelectableTable.tsx +++ b/frontend/src/components/counts/CountsSelectableTable.tsx @@ -110,6 +110,7 @@ const CountsSelectableTable = ( const table = copyTable () return countsClipboard ({ columns: table.columns.slice (bounds.left, bounds.right + 1), + headerRows: table.headerRows?.map (row => row.slice (bounds.left, bounds.right + 1)), rows: table.rows.slice (bounds.top, bounds.bottom + 1) .map (row => row.slice (bounds.left, bounds.right + 1)) }, headers) } diff --git a/frontend/src/components/counts/CountsSortHeader.tsx b/frontend/src/components/counts/CountsSortHeader.tsx index f19a7bf..07bef10 100644 --- a/frontend/src/components/counts/CountsSortHeader.tsx +++ b/frontend/src/components/counts/CountsSortHeader.tsx @@ -8,12 +8,13 @@ import type { CountSort } from '@/lib/counts' const CountsSortHeader = ( - { field, label, sorts, change, structured = false }: { + { field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true }: { field: string label: { text: string; language: string } sorts: CountSort[] change: (key: string, additive: boolean) => void - structured?: boolean }, + structured?: boolean + rowSpan?: number; colSpan?: number; resizable?: boolean }, ) => { const { message } = useUITranslation ('counts') const press = useRef<{ x: number; y: number @@ -36,7 +37,7 @@ const CountsSortHeader = ( direction: message (`sort.${state}`).text }) return ( - 1 && priority >= 0) ? 'other' : state}> - + {resizable && } ) } diff --git a/frontend/src/components/counts/CountsSummaryResults.tsx b/frontend/src/components/counts/CountsSummaryResults.tsx index dd10787..ca7204e 100644 --- a/frontend/src/components/counts/CountsSummaryResults.tsx +++ b/frontend/src/components/counts/CountsSummaryResults.tsx @@ -5,10 +5,11 @@ import CountsSortHeader from '@/components/counts/CountsSortHeader' import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts' import { countsAxisLabel, countsFieldLabel, readCountsJSON, useCountsDefinition } from '@/lib/countsDefinitions' +import { cn } from '@/lib/utils' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' -import type { CountsResource, CountsResult } from '@/lib/counts' +import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts' import type { CountsAxis } from '@/lib/countsDefinitions' @@ -19,25 +20,26 @@ const CountsSummaryResults = ( ) => { const definition = useCountsDefinition () const locale = useLocale () - const { label, message } = useUITranslation ('counts') - const rowNumbers = params.get ('row_numbers') === '1' + const { message } = useUITranslation ('counts') + const numbered = params.get ('row_numbers') === '1' const rows = data.rows ?? [] const cols = data.cols ?? [] - const axes = [...rows, ...cols] const cells = data.cells ?? [] - const rowKeys = [...new Set (cells.map (cell => JSON.stringify ( - cell.values.slice (0, rows.length))))] - const columnKeys = data.columnValues?.map (values => JSON.stringify (values)) - ?? [...new Set (cells.map (cell => JSON.stringify (cell.values.slice (rows.length))))] - if (cols.length === 0 && columnKeys.length === 0) columnKeys.push ('[]') + const rowHeaders: CountsPivotHeader[] = data.rowHeaders ?? [...new Set ( + cells.map (cell => JSON.stringify (cell.values.slice (0, rows.length))))] + .map (key => ({ values: JSON.parse (key), kind: 'data' })) + const columnHeaders: CountsPivotHeader[] = data.columnHeaders + ?? (data.columnValues ?? [[]]).map (values => ({ values, kind: 'data' })) const sorts = readCountSort (params.get ('sort')) + const change = (key: string, additive: boolean) => update ( + 'sort', writeCountSort (changeCountSort (sorts, key, additive))) const display = (value: string | number | null, axis: CountsAxis, index: number): string => { if (value == null) return axis.field === 'user' ? message ('bot').text : '' const logical = data.axisLabels?.[`a${index}`]?.[String (value)] if (axis.field === 'user') return message ('actorWithId', { - name: logical || message ('notRegistered').text, id: value }).text + name: logical || message ('notRegistered').text, id: value }).text if (logical != null) return logical - if (['tags', 'user', 'language_code', 'source_language_code'].includes (axis.field)) + if (['tags', 'language_code', 'source_language_code'].includes (axis.field)) return message ('notRegistered').text if (axis.field === 'changed_field') return message (countsFieldLabel (String (value))).text if (axis.field === 'event_type') return message (`events.${value}`).text @@ -45,71 +47,140 @@ const CountsSummaryResults = ( return message (`common:categories.${value}`).text return String (value) } - const drill = (values: (string | number | null)[]) => { + const aggregateLabel = (header: CountsPivotHeader) => message ( + header.kind === 'grand_total' ? 'grandTotal' : 'subtotal').text + const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0) + const levels = Math.max (1, cols.length) + const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) => + header.values.map ((value, index) => display (value, axes[index], offset + index)) + const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}` + const measureKey = (header: CountsPivotHeader) => header.values.length === 0 ? 'count' + : `measure_${[...new TextEncoder ().encode (JSON.stringify (header.values))] + .map (byte => byte.toString (16).padStart (2, '0')).join ('')}` + const keys = [...(numbered ? ['row_number'] : []), + ...Array.from ({ length: leading }, (_, index) => `a${index}`), + ...columnHeaders.map (columnKey)] + const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count])) + const totals = new Map ((data.pivotTotals ?? []).map (cell => [ + JSON.stringify ([cell.row, cell.column]), cell.count])) + const count = (row: CountsPivotHeader, column: CountsPivotHeader) => + (row.values.length === rows.length && column.values.length === cols.length) + ? full.get (JSON.stringify ([...row.values, ...column.values])) ?? 0 + : totals.get (JSON.stringify ([row.values, column.values])) ?? 0 + const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => { const next = new URLSearchParams (params) const existing = readCountsJSON (params, 'drill', []) next.set ('drill', JSON.stringify ([...existing, - ...axes.map ((axis, index) => ({ ...axis, value: values[index] }))])) + ...row.values.map ((value, index) => ({ ...rows[index], value })), + ...column.values.map ((value, index) => ({ ...cols[index], value }))])) next.set ('mode', 'details') next.delete ('sort') next.delete ('page') return `/counts/${resource}?${next}` } - const keys = [...(rowNumbers ? ['row_number'] : []), ...rows.map ((_, index) => `a${index}`), - ...columnKeys.map ((_, index) => `cell${index}`)] + let ordinal = (data.page - 1) * data.limit + const rowLabels = rowHeaders.map (header => { + const labels = names (header, rows, 0) + const number = header.kind === 'data' ? String (++ordinal) : '' + if (header.kind !== 'data') labels.push (aggregateLabel (header)) + return [...(numbered ? [number] : []), + ...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')] + }) + // This unmerged matrix is also the single source for TSV and HTML clipboard headings. + const headingRows = Array.from ({ length: levels }, (_, level) => [ + ...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []), + ...Array.from ({ length: leading }, (_, index) => + level === 0 && rows[index] != null + ? countsAxisLabel (rows[index], definition, locale).text : ''), + ...columnHeaders.map ((header, index) => { + if (cols.length === 0) return header.kind === 'data' + ? message ('count').text : aggregateLabel (header) + if (level > header.values.length) return '' + if (level === header.values.length) + return header.kind === 'data' ? '' : aggregateLabel (header) + const prefix = header.values.slice (0, level + 1) + const previous = columnHeaders[index - 1] + if (level < levels - 1 && previous != null + && JSON.stringify (previous.values.slice (0, level + 1)) === JSON.stringify (prefix)) + return '' + return display (header.values[level], cols[level], rows.length + level) + })]) + const matrix = rowHeaders.map ((header, index) => [...rowLabels[index], + ...columnHeaders.map (column => String (count (header, column)))]) + const headerCells = (level: number) => { + const output = [] + for (let index = 0; index < columnHeaders.length; ++index) + { + const header = columnHeaders[index] + if (cols.length > 0 && level > header.values.length) continue + const aggregate = header.kind !== 'data' && level === header.values.length + const leaf = level === levels - 1 || aggregate + const text = aggregate ? aggregateLabel (header) + : (cols.length === 0 ? message ('count').text + : display (header.values[level], cols[level], rows.length + level)) + let span = 1 + if (!leaf) + { + const prefix = JSON.stringify (header.values.slice (0, level + 1)) + while (index + span < columnHeaders.length) + { + const sibling = columnHeaders[index + span] + if (sibling.values.length <= level + || JSON.stringify (sibling.values.slice (0, level + 1)) !== prefix) break + ++span + } + } + const sortKey = leaf ? measureKey (header) : `a${rows.length + level}` + const field = leaf ? columnKey (header) : sortKey + output.push ( sort.key === sortKey ? { ...sort, key: field } : sort)} + change={(_, additive) => change (sortKey, additive)}/>) + index += span - 1 + } + return output + } return
- - {rowNumbers && } - {rows.map ((axis, index) => update ('sort', writeCountSort ( - changeCountSort (sorts, key, additive)))}/>)} - {columnKeys.map ((key, index) => { - const values = JSON.parse (key) as (string | number | null)[] - const name = values.map ((value, position) => - `${countsAxisLabel (cols[position], definition, locale).text}: ${ - display (value, cols[position], rows.length + position)}`).join (' / ') - const sortKey = cols.length === 0 ? 'count' : `measure_${[ - ...new TextEncoder ().encode (key)].map (byte => - byte.toString (16).padStart (2, '0')).join ('')}` - return sort.key === sortKey - ? { ...sort, key: `cell${index}` } : sort)} - change={(_, additive) => update ('sort', writeCountSort ( - changeCountSort (sorts, sortKey, additive)))}/> - })} - - {rowKeys.map ((rowKey, rowIndex) => { - const rowValues = JSON.parse (rowKey) as (string | number | null)[] - return - {rowNumbers && } - {rowValues.map ((value, index) => - {display (value, rows[index], index)})} - {columnKeys.map (columnKey => { - const columnValues = JSON.parse (columnKey) as (string | number | null)[] - const values = [...rowValues, ...columnValues] - const cell = cells.find (cell => JSON.stringify ( - cell.values) === JSON.stringify (values)) - return - {cell?.count ?? 0} - - })} - - })} + copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })), + headerRows: headingRows, rows: matrix })} + identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`} + className="text-left text-sm"> + + {headingRows.map ((_, level) => + {level === 0 && <> + {numbered && } + {Array.from ({ length: leading }, (_, index) => rows[index] == null + ? + : )} + } + {headerCells (level)} + )} + + {rowHeaders.map ((row, index) => + {rowLabels[index].map ((value, offset) => {value})} + {columnHeaders.map (column => + {count (row, column)} + )} + )} - {cells.length === 0 &&

{label ('empty')}

}
} - export default CountsSummaryResults diff --git a/frontend/src/components/counts/countsTable.css b/frontend/src/components/counts/countsTable.css index b9106d7..e62f10b 100644 --- a/frontend/src/components/counts/countsTable.css +++ b/frontend/src/components/counts/countsTable.css @@ -18,6 +18,17 @@ @apply bg-slate-900 text-slate-100; } +.counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) { + background-color: #e2e8f0; + color: #0f172a; + font-weight: 600; +} + +.dark .counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) { + background-color: #334155; + color: #f8fafc; +} + .counts-grid > thead { @apply bg-slate-200 text-slate-950; } diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts index 00b94cf..c8eb43d 100644 --- a/frontend/src/lib/counts.ts +++ b/frontend/src/lib/counts.ts @@ -1,7 +1,7 @@ -import { countsFieldLabel } from '@/lib/countsDefinitions' +import { countsFieldLabel, readCountsJSON } from '@/lib/countsDefinitions' import { apiDownload, apiGet } from '@/lib/api' -import type { CountsDefinition } from '@/lib/countsDefinitions' +import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions' import type { LocaleCode } from '@/lib/locales' import type { TagNameSnapshot } from '@/types' @@ -55,7 +55,13 @@ export type CountsStateTotals = { assignments: number | null deprecatedTags: number | null externalAssignments: number | null } +export type CountsPivotHeader = { values: (string | number | null)[] + kind: 'data' | 'subtotal' | 'grand_total' } export type CountsResult = { + rowHeaders?: CountsPivotHeader[] + columnHeaders?: CountsPivotHeader[] + pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[] + count: number }[] executionAt?: string columnValues?: (string | number | null)[][] cells?: { values: (string | number | null)[]; count: number }[] @@ -83,11 +89,10 @@ export type CountRecord = Record export const countColumns: Record = { event_type: 'counts:eventType', thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId', - version_no: 'history:version', is_latest: 'counts:columns.latest', + version_no: 'counts:columns.version', is_latest: 'counts:columns.latest', title: 'counts:columns.postTitle', url: 'counts:columns.postURL', thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags', parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration', - original_created_at: 'shared:labels.originalCreatedAt', original_created_from: 'counts:columns.originalFrom', original_created_before: 'counts:columns.originalBefore', name: 'history:tagName', names: 'counts:columns.names', @@ -144,7 +149,7 @@ export const columnsForCounts = ( 'resource_id', 'version_no', 'is_latest', ...(resource === 'posts' ? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms', - 'original_created_at', 'original_created_from', 'original_created_before'] + 'original_created_from', 'original_created_before'] : (resource === 'tags' ? ['name', 'names', 'category', 'aliases', 'parents', 'deprecated'] : (definition?.fields.filter (field => ![ @@ -206,6 +211,7 @@ export const selectedCountColumns = ( params: URLSearchParams, definition?: CountsDefinition, ): CountColumn[] => { + const resolved = normaliseCountsParams (resource, params, definition) const common = ['resource_id', 'version_no', 'event_type', 'user', 'created_at'] const defaults: Record = { posts: ['title', 'url', 'tags', 'parents', 'video_ms'], @@ -214,9 +220,9 @@ export const selectedCountColumns = ( tag_descriptions: ['language_code', 'body', 'source_language_code'], tag_groups: ['name', 'description', 'members'], materials: ['url', 'source_kind', 'tag_id'] } - const selected = params.has ('columns') ? params.get ('columns')!.split (',') + const selected = resolved.has ('columns') ? resolved.get ('columns')!.split (',') : [...common, ...defaults[resource]] - return orderedCountFields (fieldsForCounts (resource, definition), params) + return orderedCountFields (fieldsForCounts (resource, definition), resolved) .map (field => field.key as CountColumn).filter (key => selected.includes (key)) } @@ -281,6 +287,7 @@ export const defaultCountsMode = (definition: string | null): 'summary' | 'detai export const normaliseCountsParams = ( resource: CountsResource, source: URLSearchParams, + definition?: CountsDefinition, ): URLSearchParams => { const params = new URLSearchParams (source) if (!params.has ('definition')) @@ -306,6 +313,27 @@ export const normaliseCountsParams = ( params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key)) .join (',')) } + if (params.has ('sort')) + params.set ('sort', params.get ('sort')!.split (',') + .filter (entry => entry.split (':')[0] !== 'original_created_at').join (',')) + if (params.has ('order')) + params.set ('order', params.get ('order')!.split (',') + .filter (key => key !== 'original_created_at').join (',')) + const defaults = definition?.defaults?.find (entry => + entry.definition === params.get ('definition') && entry.mode === params.get ('mode')) + for (const { key, value } of defaults?.params ?? []) + { + if (params.has (key)) continue + if (key === 'sort') + { + const expected = defaults?.params.find (entry => entry.key === 'rows')?.value + const axes = readCountsJSON (params, 'rows', []) + const first = expected == null ? null : (JSON.parse (expected) as CountsAxis[])[0] + if (!Array.isArray (axes) || axes[0]?.field !== first?.field + || (axes[0]?.grain ?? 'month') !== (first?.grain ?? 'month')) continue + } + params.set (key, value) + } return params } diff --git a/frontend/src/lib/countsClipboard.ts b/frontend/src/lib/countsClipboard.ts index 070a8c9..ed68683 100644 --- a/frontend/src/lib/countsClipboard.ts +++ b/frontend/src/lib/countsClipboard.ts @@ -2,7 +2,7 @@ export type CountsClipboard = { text: string; html: string } export type CountsClipboardColumn = { label: string; children?: CountsClipboardColumn[] } export type CountsClipboardCell = string | CountsClipboardTable export type CountsClipboardTable = { - columns: CountsClipboardColumn[]; rows: CountsClipboardCell[][] } + columns: CountsClipboardColumn[]; rows: CountsClipboardCell[][]; headerRows?: string[][] } const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character => @@ -59,7 +59,8 @@ export const countsClipboard = ( source: string[][] | CountsClipboardTable, headers = false, ): CountsClipboard => { - const headings = !Array.isArray (source) && headers ? headerRows (source.columns) : [] + const headings = (!Array.isArray (source) && headers) + ? source.headerRows ?? headerRows (source.columns) : [] const rows = Array.isArray (source) ? source : [...headings, ...dataRows (source)] const headingCount = Array.isArray (source) ? (headers ? 1 : 0) : headings.length // Excel accepts quoted TSV fields only when delimiters or quotes require them. diff --git a/frontend/src/lib/countsDefinitions.ts b/frontend/src/lib/countsDefinitions.ts index 025cd1b..084bbfb 100644 --- a/frontend/src/lib/countsDefinitions.ts +++ b/frontend/src/lib/countsDefinitions.ts @@ -17,6 +17,7 @@ export type CountsFieldMetadata = { key: string; type: CountsFieldType; nullable: boolean; operators: string[] } export type CountsDefinition = { fields: CountsFieldMetadata[] + defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[] axes: { latest: string[]; history: string[] } grains: string[] } @@ -42,7 +43,7 @@ export const countsFilterFields = ( export const countsFieldLabel = (key: string): string => { const labels: Record = { - resource_id: 'columns.resourceId', version_no: 'history:version', + resource_id: 'columns.resourceId', version_no: 'columns.version', user: 'history:actor', created_at: 'columns.changedAt', name: 'history:name', title: 'columns.postTitle', url: 'columns.postURL', thumbnail_base: 'columns.thumbnailURL', tags: 'columns.tags', tags_json: 'columns.tags', names: 'columns.names', @@ -62,11 +63,10 @@ export const countsAxisLabel = ( locale: LocaleCode, ): { text: string; language: string } => { const field = translateUIMessage (countsFieldLabel (axis.field), 'counts', locale) - if (axis.grain == null || definition.fields.find (item => item.key === axis.field) - ?.type !== 'datetime') return field + if (definition.fields.find (item => item.key === axis.field)?.type !== 'datetime') return field return translateUIMessage ('axisWithGrain', 'counts', locale, { field: field.text, - grain: translateUIMessage (`grains.${axis.grain}`, 'counts', locale).text }) + grain: translateUIMessage (`grains.${axis.grain ?? 'month'}`, 'counts', locale).text }) } diff --git a/frontend/src/lib/countsDisplay.ts b/frontend/src/lib/countsDisplay.ts index 2857e79..77b4f52 100644 --- a/frontend/src/lib/countsDisplay.ts +++ b/frontend/src/lib/countsDisplay.ts @@ -2,7 +2,7 @@ import { fieldsForCounts, orderedCountFields, selectedCountChildren, sortedCountChildren } from '@/lib/counts' import { countsEventTypes } from '@/lib/countsDefinitions' import { translateUIMessage } from '@/lib/i18n' -import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils' +import { localisedDateString, msToTime } from '@/lib/utils' import type { CountColumn, CountField, CountRecord, CountsResource, CountsVersion } from '@/lib/counts' @@ -34,13 +34,6 @@ export const countsCellText = ( case 'category': return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs) - case 'original_created_at': - if (timezone !== 'Asia/Tokyo') - return translateUIMessage ('sections.range', 'counts', locale, { - begin: row.originalCreatedFrom == null ? '' : date (row.originalCreatedFrom), - end: row.originalCreatedBefore == null ? '' : date (row.originalCreatedBefore) }).text - return originalCreatedAtString ( - row.originalCreatedFrom ?? null, row.originalCreatedBefore ?? null, locale) case 'original_created_from': case 'original_created_before': { @@ -314,6 +307,8 @@ export const countsExportLabels = ( labels.name = translateUIMessage ('history:name', 'counts', locale).text for (const event of countsEventTypes) labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text + labels.subtotal = translateUIMessage ('subtotal', 'counts', locale).text + labels.grandTotal = translateUIMessage ('grandTotal', 'counts', locale).text labels.bot = translateUIMessage ('bot', 'counts', locale).text labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, { name: '{{name}}', id: '{{id}}' }).text @@ -376,7 +371,7 @@ export const countsDetailClipboardTable = ( ? undefined : clipboardColumns ( field.children, field.key, params, locale) })) if (rowStart != null) - columns.unshift ({ label: translateUIMessage ('rowNumbers', 'counts', locale).text, + columns.unshift ({ label: translateUIMessage ('rowNumberHeading', 'counts', locale).text, children: undefined }) return { columns, rows: rows.map ((row, index) => { const cells: CountsClipboardCell[] = fields.map (field => field.children == null diff --git a/frontend/src/lib/countsTableLayout.ts b/frontend/src/lib/countsTableLayout.ts index f21cb59..b4471e9 100644 --- a/frontend/src/lib/countsTableLayout.ts +++ b/frontend/src/lib/countsTableLayout.ts @@ -6,7 +6,7 @@ export const MAX_COUNT_WIDTH = 2000 export const defaultCountWidths: Record = { row_number: 72, thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104, title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180, - video_ms: 120, original_created_at: 260, original_created_from: 240, + video_ms: 120, original_created_from: 240, original_created_before: 240, name: 240, names: 480, category: 180, aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160, period: 200, tag_id: 88, id: 88, versionNo: 88, kind: 140, sections: 260, diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json index 789b1a2..99401ff 100644 --- a/frontend/src/locales/ja/counts.json +++ b/frontend/src/locales/ja/counts.json @@ -42,10 +42,11 @@ "empty": "該当する履歴はありません。", "columns": { "changedAt": "操作日時", + "version": "版", "resourceId": "Id.", - "postTitle": "投稿タイトル", - "postURL": "投稿 URL", - "tags": "記載タグ", + "postTitle": "タイトル", + "postURL": "URL", + "tags": "タグ", "deprecated": "廃止状態", "latest": "最新フラグ", "thumbnailURL": "サムネール元 URL", @@ -232,6 +233,9 @@ "profile": "出力設定", "path": "出力パス" }, + "rowNumberHeading": "No.", + "subtotal": "小計", + "grandTotal": "綜合計", "rowNumbers": "行番号", "periodSeparator": "〜", "bot": "$t(history:bot)", diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index ffb0e60..3acc3c7 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -37,7 +37,7 @@ import type { CountsResource } from '@/lib/counts' const CountsResourceContent = ({ resource }: { resource: CountsResource }) => { const definition = useCountsDefinition () const [source, setParams] = useSearchParams () - const params = normaliseCountsParams (resource, source) + const params = normaliseCountsParams (resource, source, definition) const canonical = params.toString () const controlParams = new URLSearchParams (params) controlParams.delete ('nested_json')