From 304090f71750ccc7e34fdce6cef44e3a33a58230 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 25 Oct 2026 14:14:55 +0900 Subject: [PATCH] #434 --- .../controllers/concerns/counts_listing.rb | 19 ++ .../controllers/counts_posts_controller.rb | 3 + .../app/controllers/counts_tags_controller.rb | 3 + .../controllers/post_versions_controller.rb | 34 +-- .../controllers/tag_versions_controller.rb | 26 +- backend/app/services/counts_query.rb | 110 ++++++++ backend/config/routes.rb | 3 + backend/spec/requests/counts_spec.rb | 96 +++++++ frontend/server.ssr.test.mjs | 51 +++- frontend/src/App.tsx | 8 + frontend/src/lib/counts.test.ts | 18 ++ frontend/src/lib/counts.ts | 44 +++ frontend/src/lib/queryKeys.ts | 4 + frontend/src/pages/CountsPage.tsx | 17 ++ .../src/pages/CountsResourcePage.test.tsx | 83 ++++++ frontend/src/pages/CountsResourcePage.tsx | 253 ++++++++++++++++++ 16 files changed, 745 insertions(+), 27 deletions(-) create mode 100644 backend/app/controllers/concerns/counts_listing.rb create mode 100644 backend/app/controllers/counts_posts_controller.rb create mode 100644 backend/app/controllers/counts_tags_controller.rb create mode 100644 backend/app/services/counts_query.rb create mode 100644 backend/spec/requests/counts_spec.rb create mode 100644 frontend/src/lib/counts.test.ts create mode 100644 frontend/src/lib/counts.ts create mode 100644 frontend/src/pages/CountsPage.tsx create mode 100644 frontend/src/pages/CountsResourcePage.test.tsx create mode 100644 frontend/src/pages/CountsResourcePage.tsx diff --git a/backend/app/controllers/concerns/counts_listing.rb b/backend/app/controllers/concerns/counts_listing.rb new file mode 100644 index 0000000..80c4660 --- /dev/null +++ b/backend/app/controllers/concerns/counts_listing.rb @@ -0,0 +1,19 @@ +module CountsListing + extend ActiveSupport::Concern + + def index + locale = resolve_locale! + resource = is_a?(CountsPostsController) ? 'posts' : 'tags' + query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale') + .merge('locale' => locale.code)) + result = { count: query.count, page: query.page, limit: query.limit } + if query.mode == 'summary' + result.merge!(query.summary) + else + result[:versions] = serialise_versions(query.details(version_scope), locale.language_code) + end + render json: result + rescue ArgumentError, TypeError + head :bad_request + end +end diff --git a/backend/app/controllers/counts_posts_controller.rb b/backend/app/controllers/counts_posts_controller.rb new file mode 100644 index 0000000..74ed700 --- /dev/null +++ b/backend/app/controllers/counts_posts_controller.rb @@ -0,0 +1,3 @@ +class CountsPostsController < PostVersionsController + include CountsListing +end diff --git a/backend/app/controllers/counts_tags_controller.rb b/backend/app/controllers/counts_tags_controller.rb new file mode 100644 index 0000000..2bda72a --- /dev/null +++ b/backend/app/controllers/counts_tags_controller.rb @@ -0,0 +1,3 @@ +class CountsTagsController < TagVersionsController + include CountsListing +end diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index 2617cde..e37bdb5 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -14,21 +14,7 @@ class PostVersionsController < ApplicationController offset = (page - 1) * limit - q = PostVersion.joins(<<~SQL.squish) - 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.*', - 'prev.title AS prev_title', - 'prev.url AS prev_url', - 'prev.thumbnail_base AS prev_thumbnail_base', - 'prev.tags_json AS prev_tags_json', - 'prev.video_ms AS prev_video_ms', - 'prev.original_created_from AS prev_original_created_from', - 'prev.original_created_before AS prev_original_created_before') + q = version_scope q = q.where('post_versions.post_id = ?', post_id) if post_id if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id))) q = q.where('JSON_CONTAINS(post_versions.tags_json,' + @@ -51,6 +37,24 @@ class PostVersionsController < ApplicationController private + def version_scope + PostVersion.joins(<<~SQL.squish) + 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.*', + 'prev.title AS prev_title', + 'prev.url AS prev_url', + 'prev.thumbnail_base AS prev_thumbnail_base', + 'prev.tags_json AS prev_tags_json', + 'prev.video_ms AS prev_video_ms', + 'prev.original_created_from AS prev_original_created_from', + 'prev.original_created_before AS prev_original_created_before') + end + def serialise_versions rows, language_code rows = rows.to_a user_ids = rows.map(&:created_by_user_id).compact.uniq diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index e1169e9..78677a2 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -10,17 +10,7 @@ class TagVersionsController < ApplicationController offset = (page - 1) * limit - q = TagVersion.joins(<<~SQL.squish) - LEFT JOIN - tag_versions prev - ON - prev.tag_id = tag_versions.tag_id - AND prev.version_no = tag_versions.version_no - 1 - SQL - .select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category', - 'prev.deprecated_at AS prev_deprecated_at', - 'prev.aliases AS prev_aliases', 'prev.names AS prev_names', - 'prev.parent_tag_ids AS prev_parent_tag_ids') + q = version_scope q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id count = q.except(:select, :order, :limit, :offset).count @@ -34,6 +24,20 @@ class TagVersionsController < ApplicationController private + def version_scope + TagVersion.joins(<<~SQL.squish) + LEFT JOIN + tag_versions prev + ON + prev.tag_id = tag_versions.tag_id + AND prev.version_no = tag_versions.version_no - 1 + SQL + .select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category', + 'prev.deprecated_at AS prev_deprecated_at', + 'prev.aliases AS prev_aliases', 'prev.names AS prev_names', + 'prev.parent_tag_ids AS prev_parent_tag_ids') + end + def serialise_versions rows, language_code rows = rows.to_a tag_ids = rows.map(&:tag_id).uniq diff --git a/backend/app/services/counts_query.rb b/backend/app/services/counts_query.rb new file mode 100644 index 0000000..1e62d57 --- /dev/null +++ b/backend/app/services/counts_query.rb @@ -0,0 +1,110 @@ +# Read-only history queries. All identifiers and SQL expressions are server-owned. +class CountsQuery + EVENTS = ['create', 'update', 'discard', 'restore'].freeze + AXES = ['event_type', 'month', 'user'].freeze + COMMON_COLUMNS = ['created_at', 'resource_id', 'version_no', 'event_type', 'user'].freeze + COLUMNS = { + 'posts' => COMMON_COLUMNS + ['title', 'url', 'tags', 'added_tags', 'removed_tags'], + 'tags' => COMMON_COLUMNS + ['name', 'category', 'aliases', 'parents', 'deprecated'] }.freeze + KEYS = [ + 'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', + 'user_id', 'event_type', 'resource_id', 'tag', 'columns'].freeze + + attr_reader :page, :limit, :mode, :axis, :columns + + def initialize(scope, resource, params) + @scope = scope.except(:select, :order) + @resource = resource + @table = scope.klass.table_name + @params = params + raise ArgumentError unless (params.keys - KEYS).empty? + raise ArgumentError unless params.values.all? { _1.is_a?(String) } + + @mode = params.fetch('mode', 'summary') + @axis = params.fetch('axis', 'event_type') + raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis) + + @page = integer('page', 1) + @limit = integer('limit', 20) + raise ArgumentError if limit > 100 || page > 1_000_000 + + @columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',') + if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any? + raise ArgumentError + end + apply_filters + end + + def count = @scope.count + + def details(scope) + scope.where(id: @scope.select("#{ @table }.id")) + .order(Arel.sql("#{ @table }.created_at DESC, #{ @table }.id DESC")) + .limit(limit).offset((page - 1) * limit) + end + + def summary + expression = case axis + when 'event_type' then "#{ @table }.event_type" + when 'user' then "#{ @table }.created_by_user_id" + when 'month' then "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" + end + grouped = @scope.group(Arel.sql(expression)) + # A derived table counts groups without materialising every group in Ruby. + sql = grouped.select(Arel.sql("#{ expression } AS group_key")).to_sql + groups = @scope.klass.connection.select_value("SELECT COUNT(*) FROM (#{ sql }) counts_groups") + rows = grouped.order(Arel.sql("#{ expression } ASC")) + .limit(limit).offset((page - 1) * limit).count + names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {} + { group_count: groups.to_i, + groups: rows.map { |key, value| { key:, name: names[key], count: value } } } + end + + private + + def integer(key, default = nil) + value = @params[key].presence + return default if value.nil? && !default.nil? + raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/) + + value.to_i + end + + def timestamp(key) + value = @params[key].presence + return nil unless value + raise ArgumentError unless value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z\z/) + + Date.iso8601(value.first(10)) + Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value) + end + + def apply_filters + first = timestamp('from') + last = timestamp('before') + raise ArgumentError if first && last && first >= last + + @scope = @scope.where("#{ @table }.created_at >= ?", first) if first + @scope = @scope.where("#{ @table }.created_at < ?", last) if last + if @params['resource_id'].present? + id_column = @resource == 'posts' ? 'post_id' : 'tag_id' + @scope = @scope.where(@table => { id_column => integer('resource_id') }) + end + if @params['user_id'].present? + user = @params['user_id'] == 'none' ? nil : integer('user_id') + @scope = @scope.where(@table => { created_by_user_id: user }) + end + if @params['event_type'].present? + raise ArgumentError unless EVENTS.include?(@params['event_type']) + + @scope = @scope.where(@table => { event_type: @params['event_type'] }) + end + return unless @params['tag'].present? + raise ArgumentError unless @resource == 'posts' + + id = integer('tag') + @scope = @scope.where( + "JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED))) OR " + + "JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED)))", id, id) + end +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 01e0895..030a874 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,4 +1,7 @@ Rails.application.routes.draw do + get 'counts/posts', to: 'counts_posts#index' + get 'counts/tags', to: 'counts_tags#index' + resources :locales, only: [:index] resources :sitemap_entries, only: [:index, :show], param: :resource diff --git a/backend/spec/requests/counts_spec.rb b/backend/spec/requests/counts_spec.rb new file mode 100644 index 0000000..bbd3a28 --- /dev/null +++ b/backend/spec/requests/counts_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe 'Counts API', type: :request do + let!(:actor) { create(:user, :member) } + let!(:post) { create(:post) } + let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) } + + before do + snapshot = { tag_id: tag.id, version_no: 1, name: 'counts tag', + category: 'general', sections: [] } + [1, 2, 3].each do |number| + time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0) + PostVersion.create!(post:, version_no: number, + event_type: number == 1 ? 'create' : 'update', + tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '', + tags_json: number == 1 ? [snapshot] : [], + created_by_user: number == 3 ? nil : actor, created_at: time) + TagVersion.create!(tag:, version_no: number, + event_type: number == 1 ? 'create' : 'update', + name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '', + names: { ja: { name: 'counts tag', script_code: 'Jpan', + auto_generated: false, aliases: [] } }, + created_by_user: number == 3 ? nil : actor, created_at: time) + end + end + + ['posts', 'tags'].each do |resource| + context resource do + let(:path) { "/counts/#{ resource }" } + let(:id) { resource == 'posts' ? post.id : tag.id } + let(:filters) { { resource_id: id, user_id: actor.id, + from: '2024-01-01T00:00Z', before: '2024-02-01T00:00Z' } } + + it 'uses the same AND filters for SQL totals, groups and details' do + get path, params: filters.merge(axis: 'event_type') + expect(response).to have_http_status(:ok) + summary = response.parsed_body + expect(summary['count']).to eq(2) + expect(summary['groups'].sum { _1['count'] }).to eq(2) + expect(summary['groups'].map { _1['key'] }).to eq(['create', 'update']) + + get path, params: filters.merge(mode: 'details') + expect(response.parsed_body['count']).to eq(2) + expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1]) + end + + it 'paginates details and groups independently from totals' do + get path, params: { resource_id: id, mode: 'details', limit: 1, page: 2 } + expect(response.parsed_body['count']).to eq(3) + expect(response.parsed_body['versions'].sole['version_no']).to eq(2) + get path, params: { resource_id: id, axis: 'month', limit: 1, page: 2 } + expect(response.parsed_body['group_count']).to eq(2) + expect(response.parsed_body['groups'].sole).to include('key' => '2024-02', 'count' => 1) + end + + it 'groups null actors and supports their drill-down filter' do + get path, params: { resource_id: id, axis: 'user' } + expect(response.parsed_body['groups']).to include( + { 'key' => nil, 'name' => nil, 'count' => 1 }) + get path, params: { resource_id: id, mode: 'details', user_id: 'none' } + expect(response.parsed_body['count']).to eq(1) + end + + it 'uses an inclusive start and exclusive end' do + get path, params: { resource_id: id, + from: '2024-01-01T00:00Z', before: '2024-01-02T00:00Z' } + expect(response.parsed_body['count']).to eq(1) + end + + it 'rejects unsupported or malformed query values' do + [ + { axis: 'SQL' }, { limit: 101 }, { page: '1x' }, { user_id: '-1' }, + { event_type: 'invalid' }, { columns: 'password' }, { order: 'id' }, + { from: '2024-02-30T00:00Z' }, { from: '2024-01-01' }, + { user_id: ['1'] }, { mode: 'invalid' }].each do |invalid| + get path, params: invalid + expect(response).to have_http_status(:bad_request) + end + end + + it 'retains the existing banned-user boundary' do + actor.update!(banned_at: Time.current) + sign_in_as(actor) + get path + expect(response).to have_http_status(:forbidden) + end + end + end + + it 'matches removed tags from the previous snapshot, not current post_tags' do + get '/counts/posts', params: { tag: tag.id, mode: 'details' } + expect(response.parsed_body['count']).to eq(2) + expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1]) + expect(response.parsed_body['versions'].first['tags'].sole['type']).to eq('removed') + end +end diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs index 668d111..07f1814 100644 --- a/frontend/server.ssr.test.mjs +++ b/frontend/server.ssr.test.mjs @@ -62,6 +62,14 @@ const responseFor = url => { return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] } if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' } if (path === '/users/theme_slots') return [] + if (path === '/counts/posts' || path === '/counts/tags') { + const resource = path.endsWith ('posts') ? 'posts' : 'tags' + return { count: 1, page: 1, limit: 20, group_count: 1, + groups: [{ key: 'create', name: null, count: 1 }], + versions: resource === 'posts' + ? [{ post_id: 1, version_no: 1, title: { current: `SSR count ${locale}` } }] + : [{ tag_id: 1, version_no: 1, name: { current: `SSR count ${locale}` } }] } + } if (path === '/posts') return { posts: [post (locale)], count: 1 } if (path === '/gekanator/posts') return { posts: [post (locale)] } if (path === '/gekanator/questions') return { questions: [] } @@ -300,6 +308,21 @@ after (async () => { if (errors.length) throw new AggregateError (errors, 'SSR teardown failed') }) +test ('counts SSR renders history data and retains URL query state', async () => { + for (const locale of ['ja', 'ain-Kana']) { + for (const resource of ['posts', 'tags']) { + const column = resource === 'posts' ? 'title' : 'name' + const query = `mode=details&columns=${column}&user_id=4` + const result = await renderPage (`/${locale}/counts/${resource}?${query}`, origin, apiURL) + assert.equal (result.status, 200) + assert.match (result.html, new RegExp (`SSR count ${locale}`)) + assert.match (result.html, new RegExp (`/${locale}/${resource}/1`)) + assert.ok (requests.some (request => request.url.includes (`/counts/${resource}`) + && request.url.includes ('user_id=4') && request.url.includes (`locale=${locale}`))) + } + } +}) + test ('every canonical screen route returns page HTML without JavaScript', async t => { const cookie = await authenticatedCookie () for (const route of routes) { @@ -582,7 +605,8 @@ test ('hydrate every route with stored browser preferences', { }` }, sessionId) const evaluate = async expression => (await send ('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result.value - for (const route of routes.filter (entry => !entry.handle?.redirectTo)) { + for (const route of routes.filter (entry => !entry.handle?.redirectTo + && (process.env.SSR_COUNTS_ONLY !== '1' || entry.path.startsWith ('counts')))) { for (const locale of ['ja', 'ain-Kana']) { await t.test (`hydrate ${locale} ${route.path}`, async () => { const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1') @@ -608,6 +632,31 @@ test ('hydrate every route with stored browser preferences', { }) } } + for (const width of [375, 1240]) { + await send ('Emulation.setDeviceMetricsOverride', { + width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId) + for (const resource of ['posts', 'tags']) { + await t.test (`counts layout ${width} ${resource}`, async () => { + await send ('Page.navigate', { + url: `${httpOrigin}/ja/counts/${resource}` }, sessionId) + let ready = false + for (let attempt = 0; attempt < 100; ++attempt) { + await new Promise (resolve => setTimeout (resolve, 75)) + try { + ready = await evaluate (`document.querySelector('tbody tr')!=null`) + } catch {} + if (ready) break + } + assert.ok (ready, 'history query results must render') + for (const dark of [false, true]) { + await evaluate (`document.documentElement.classList.toggle('dark',${dark})`) + assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'), + 'counts controls must fit the viewport in both themes') + } + }) + } + } + if (process.env.SSR_COUNTS_ONLY === '1') return probeActive = true for (const width of [375, 1240]) { await send ('Emulation.setDeviceMetricsOverride', { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d083062..6e53548 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,6 +77,8 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage')) const MaterialSyncSuppressionsPage = lazyPage ( () => import ('@/pages/materials/MaterialSyncSuppressionsPage')) +const CountsPage = lazyPage (() => import ('@/pages/CountsPage')) +const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage')) const MorePage = lazyPage (() => import ('@/pages/MorePage')) const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage')) const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage')) @@ -194,6 +196,12 @@ export const pageRoutes = ( { id: 'gekanator', path: 'gekanator', element: , handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja' ? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } }, + { path: 'counts', element: , + handle: { preload: CountsPage.preload } }, + { path: 'counts/posts', element: , + handle: { preload: CountsResourcePage.preload } }, + { path: 'counts/tags', element: , + handle: { preload: CountsResourcePage.preload } }, { path: 'more', element: , handle: { preload: MorePage.preload, sitemap: ['static'] } }, { path: '*', element: , handle: { notFound: true } }] diff --git a/frontend/src/lib/counts.test.ts b/frontend/src/lib/counts.test.ts new file mode 100644 index 0000000..c1d14a3 --- /dev/null +++ b/frontend/src/lib/counts.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { columnsForCounts, countsCSV } from '@/lib/counts' + + +describe ('counts exports', () => { + it ('escapes quotes, newlines and spreadsheet formulas in UTF-8 CSV', () => { + expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']])) + .toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"') + }) + + it ('keeps resource-specific columns out of the other resource', () => { + expect (columnsForCounts ('posts')).toContain ('added_tags') + expect (columnsForCounts ('posts')).not.toContain ('aliases') + expect (columnsForCounts ('tags')).toContain ('aliases') + expect (columnsForCounts ('tags')).not.toContain ('url') + }) +}) diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts new file mode 100644 index 0000000..da9e9f6 --- /dev/null +++ b/frontend/src/lib/counts.ts @@ -0,0 +1,44 @@ +import { apiGet } from '@/lib/api' + +import type { LocaleCode } from '@/lib/locales' +import type { PostVersion, TagVersion } from '@/types' + +export type CountsResource = 'posts' | 'tags' +export type CountsResult = { + count: number + page: number + limit: number + groupCount?: number + groups?: { key: string | number | null; name: string | null; count: number }[] + versions?: (PostVersion | TagVersion)[] } + +export const countColumns = { + created_at: '変更日時', resource_id: 'ID', version_no: '版番号', + event_type: '操作種別', user: '操作者', title: '投稿タイトル', url: '投稿 URL', + tags: '記載タグ', added_tags: '追加されたタグ', removed_tags: '削除されたタグ', + name: 'タグ名', category: 'カテゴリ', aliases: '別名', parents: '上位タグ', + deprecated: '廃止状態' } + +export type CountColumn = keyof typeof countColumns + + +export const columnsForCounts = (resource: CountsResource): CountColumn[] => [ + 'created_at', 'resource_id', 'version_no', 'event_type', 'user', + ...(resource === 'posts' + ? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const + : ['name', 'category', 'aliases', 'parents', 'deprecated'] as const)] + + +export const fetchCounts = ( + resource: CountsResource, + params: URLSearchParams, + locale: LocaleCode, +): Promise => apiGet (`/counts/${resource}`, { + locale, params: Object.fromEntries (params) }) + + +export const countsCSV = (rows: string[][]): string => + '\uFEFF' + rows.map (row => row.map (value => { + const safe = /^[=+@\-\t\r]/.test (value) ? `'${value}` : value + return `"${safe.replaceAll ('"', '""')}"` + }).join (',')).join ('\r\n') diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 29544b9..9370694 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -161,3 +161,7 @@ export const theatresKeys = { show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const, programmes: (id: string, locale: LocaleCode) => ['theatres', locale, id, 'programmes'] as const } + +export const countsKeys = { + index: (resource: string, params: string, locale: LocaleCode) => + ['counts', resource, locale, params] as const } diff --git a/frontend/src/pages/CountsPage.tsx b/frontend/src/pages/CountsPage.tsx new file mode 100644 index 0000000..8dfe9e5 --- /dev/null +++ b/frontend/src/pages/CountsPage.tsx @@ -0,0 +1,17 @@ +import PrefetchLink from '@/components/PrefetchLink' +import PageHead from '@/components/common/PageHead' +import PageTitle from '@/components/common/PageTitle' +import MainArea from '@/components/layout/MainArea' + + +const CountsPage = () => ( + + + 集計・明細照会 +
    +
  • 投稿
  • +
  • タグ
  • +
+
) + +export default CountsPage diff --git a/frontend/src/pages/CountsResourcePage.test.tsx b/frontend/src/pages/CountsResourcePage.test.tsx new file mode 100644 index 0000000..b30d926 --- /dev/null +++ b/frontend/src/pages/CountsResourcePage.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import CountsPage from '@/pages/CountsPage' +import CountsResourcePage from '@/pages/CountsResourcePage' +import { renderWithProviders } from '@/test/render' + +import type { CountsResult } from '@/lib/counts' + + +const { fetchCounts } = vi.hoisted (() => ({ fetchCounts: vi.fn () })) + +vi.mock ('@/lib/counts', async importOriginal => ({ + ...await importOriginal (), fetchCounts })) + + +beforeEach (() => { + fetchCounts.mockReset () + fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 }) +}) + + +describe ('counts pages', () => { + it ('links to the two locale-aware resource screens', () => { + renderWithProviders (, { route: '/ja/counts' }) + expect (screen.getByRole ('link', { name: '投稿' })) + .toHaveAttribute ('href', '/ja/counts/posts') + expect (screen.getByRole ('link', { name: 'タグ' })) + .toHaveAttribute ('href', '/ja/counts/tags') + }) + + it ('restores filters and selected columns and preserves them in page links', async () => { + fetchCounts.mockResolvedValue ({ + count: 21, page: 1, limit: 20, + versions: [{ postId: 7, versionNo: 2, + url: { current: 'https://example.com' }, + title: { current: 'Snapshot' } }] as CountsResult['versions'] }) + renderWithProviders (, { + route: '/ja/counts/posts?mode=details&columns=title&user_id=3' }) + expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument () + expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked () + expect (screen.getByRole ('checkbox', { name: '投稿 URL' })).not.toBeChecked () + expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( + 'href', '/ja/counts/posts?mode=details&columns=title&user_id=3&page=2') + expect (fetchCounts.mock.calls[0][1].get ('user_id')).toBe ('3') + fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' })) + await waitFor (() => { + expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url') + }) + }) + + it ('executes combined filters and resets the page', async () => { + renderWithProviders (, { + route: '/ja/counts/posts?page=3&mode=details' }) + fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), { + target: { value: '7' } }) + fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), { + target: { value: '9' } }) + fireEvent.submit (screen.getByRole ('button', { name: '実行' }).closest ('form')!) + await waitFor (() => { + const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams + expect (params.get ('resource_id')).toBe ('7') + expect (params.get ('user_id')).toBe ('9') + expect (params.has ('page')).toBe (false) + }) + const calls = fetchCounts.mock.calls.length + fireEvent.submit (screen.getByRole ('button', { name: '実行' }).closest ('form')!) + await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (calls)) + }) + + it ('keeps a partial-month date range when drilling down into that month', async () => { + fetchCounts.mockResolvedValue ({ count: 4, groupCount: 1, page: 1, limit: 20, + groups: [{ key: '2024-01', name: null, count: 4 }] }) + renderWithProviders (, { + route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z&event_type=update' }) + const link = await screen.findByRole ('link', { name: '2024-01' }) + const url = new URL (link.getAttribute ('href')!, 'http://localhost') + expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z') + expect (url.searchParams.get ('before')).toBe ('2024-02-01T00:00Z') + expect (url.searchParams.get ('event_type')).toBe ('update') + expect (url.searchParams.get ('mode')).toBe ('details') + }) +}) diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx new file mode 100644 index 0000000..1c93591 --- /dev/null +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -0,0 +1,253 @@ +import { useQuery } from '@tanstack/react-query' +import { useSearchParams } from 'react-router-dom' + +import PrefetchLink from '@/components/PrefetchLink' +import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField' +import FormField from '@/components/common/FormField' +import PageHead from '@/components/common/PageHead' +import PageTitle from '@/components/common/PageTitle' +import Pagination from '@/components/common/Pagination' +import MainArea from '@/components/layout/MainArea' +import { Button } from '@/components/ui/button' +import { columnsForCounts, countColumns, countsCSV, fetchCounts } from '@/lib/counts' +import { countsKeys } from '@/lib/queryKeys' +import { inputClass, localisedDateString } from '@/lib/utils' +import { useLocale } from '@/lib/useLocale' +import { useUITranslation } from '@/lib/useUITranslation' + +import type { CountColumn, CountsResource } from '@/lib/counts' +import type { PostVersion, TagVersion } from '@/types' + + +const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { + const [params, setParams] = useSearchParams () + const locale = useLocale () + const { label, message } = useUITranslation ('history') + const mode = params.get ('mode') ?? 'summary' + const axis = params.get ('axis') ?? 'event_type' + const availableColumns = columnsForCounts (resource) + const selectedColumns = params.has ('columns') + ? params.get ('columns')!.split (',') as CountColumn[] + : availableColumns + const columns = selectedColumns.filter (column => availableColumns.includes (column)) + const title = `集計・明細照会:${resource === 'posts' ? '投稿' : 'タグ'}` + const { data, isPending, isError, refetch } = useQuery ({ + queryKey: countsKeys.index (resource, params.toString (), locale), + queryFn: () => fetchCounts (resource, params, locale), retry: false }) + + const update = (key: string, value: string) => { + const next = new URLSearchParams (params) + if (value) + next.set (key, value) + else + next.delete (key) + next.delete ('page') + setParams (next) + } + + const eventName = (value: string) => value + const cell = (row: PostVersion | TagVersion, column: CountColumn): string => { + switch (column) + { + case 'created_at': return localisedDateString (row.createdAt, locale) + case 'resource_id': return String ('postId' in row ? row.postId : row.tagId) + case 'version_no': return String (row.versionNo) + case 'event_type': return eventName (row.eventType) + case 'user': return row.createdByUser?.name ?? '—' + } + if ('postId' in row) + { + if (column === 'title') return row.title.current ?? '' + if (column === 'url') return row.url.current + const tags = row.tags.filter (tag => column === 'added_tags' + ? tag.type === 'added' + : (column === 'removed_tags' ? tag.type === 'removed' : tag.type !== 'removed')) + return tags.map (tag => tag.name).join (' ') + } + switch (column) + { + case 'name': return row.name.current ?? '' + case 'category': return message (`common:categories.${row.category.current}`).text + case 'aliases': + return row.aliases.filter (item => item.type !== 'removed') + .map (item => item.name).join (' ') + case 'parents': + return row.parentTags.filter (item => item.type !== 'removed') + .map (item => item.tag.name).join (' ') + case 'deprecated': return row.deprecatedAt.current ?? '—' + default: return '' + } + } + const groups = data?.groups ?? [] + const versions = data?.versions ?? [] + const groupName = (group: typeof groups[number]) => axis === 'event_type' + ? eventName (String (group.key)) + : (axis === 'user' ? group.name ?? String (group.key ?? '—') : String (group.key)) + const drillDown = (key: string | number | null): string => { + const next = new URLSearchParams (params) + next.set ('mode', 'details') + next.delete ('page') + 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 && from > first ? from : first) + next.set ('before', before != null && before < last ? before : last) + } + else + next.set (axis === 'user' ? 'user_id' : 'event_type', String (key ?? 'none')) + return `?${next}` + } + const exportCSV = () => { + const rows = mode === 'summary' + ? [['集計軸', '件数'], ...groups.map (group => [groupName (group), String (group.count)])] + : [columns.map (column => countColumns[column]), + ...versions.map (row => columns.map (column => cell (row, column)))] + const url = URL.createObjectURL (new Blob ([countsCSV (rows)], { + type: 'text/csv;charset=utf-8' })) + const link = document.createElement ('a') + link.href = url + link.download = `counts-${resource}.csv` + link.click () + URL.revokeObjectURL (url) + } + + return ( + + + {title} + 集計・明細照会 +
{ + event.preventDefault () + const form = new FormData (event.currentTarget) + const next = new URLSearchParams (params) + for (const key of [ + 'resource_id', 'user_id', 'event_type', 'tag', 'limit', 'from', 'before']) + { + const raw = String (form.get (key) ?? '') + const value = (raw && ['from', 'before'].includes (key)) + ? toMinutePrecisionIsoUtc (raw) : raw + if (value) next.set (key, value) + else next.delete (key) + } + next.delete ('page') + if (next.toString () === params.toString ()) + void refetch () + else + setParams (next) + }}> +
+ + {() => } + + + {() => } + + + {() => } + + {[['resource_id', resource === 'posts' ? '投稿 ID' : 'タグ ID'], + ['user_id', '操作者 ID'], + ...(resource === 'posts' ? [['tag', '記載・変更タグ ID']] : [])] + .map (([key, name]) => ( + + {() => } + ))} + + {() => } + + + {() => } + +
+ {mode === 'summary' ? ( + + {() => } + ) : ( +
+ 出力項目 + {availableColumns.map (column => ( + ))} +
)} +
+ + +
+
+

変更日時:開始以上・終了未満。入力は端末の時刻帯、月別は UTC。

+

件数は履歴レコード数です。

+ {isPending &&

{label ('shared:messages.loadingEllipsis')}

} + {isError &&

{label ('common:fetchError')}

} + {data != null && <> +

総件数:{data.count}

+
+ + + {mode === 'summary' + ? <> + : <> + {columns.map (column => )}} + + + {mode === 'summary' ? groups.map (group => ( + + + + )) : versions.map (row => { + const id = 'postId' in row ? row.postId : row.tagId + return + + {columns.map (column => )} + + })} + +
集計軸件数{resource === 'posts' ? '投稿' : 'タグ'} + {countColumns[column]}
+ {groupName (group)}{group.count}
#{id} + {column === 'url' + ? + {cell (row, column)} + : cell (row, column)}
+
+ + } +
) +} + + +export default CountsResourcePage