From 2706789fa44e4bc83d0406a33e82840a16e8fb94 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 27 Oct 2026 12:39:32 +0900 Subject: [PATCH] #434 --- AGENTS.md | 22 ++++++++++++++++++ backend/AGENTS.md | 11 +++++++++ backend/app/services/counts_table_query.rb | 12 +++++----- frontend/AGENTS.md | 11 +++++++++ frontend/src/App.tsx | 20 +++++++++++------ frontend/src/lib/counts.ts | 3 ++- frontend/src/lib/countsDefinitions.ts | 6 +++++ frontend/src/lib/queryKeys.ts | 9 ++++++++ frontend/src/locales/ja/counts.json | 4 ++-- frontend/src/pages/CountsResourcePage.tsx | 26 +++++++++------------- 10 files changed, 94 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 224405b..1a3c545 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,28 @@ 秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。 秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。 +## Mandatory naming contract + +- メソッド・関数など、実際に処理を実行する callable の名前以外の自前識別子に + 動詞を使用しない。状態は名詞・形容詞・形容詞的な語で表す。今後の全開発に適用する。 +- 変数、引数、プロパティ、型定義内の項目、API・URL パラメータ、JSON キー、 + DB カラムにも適用する。`is_` / `is`、`has_` / `has`、`can_` / `can`、 + `should_` / `should` などの動詞・助動詞由来の接頭辞を使用しない。 + 状態を格納する識別子に `show`、`enable` 等の動詞も使用しない。 +- 形容詞で状態を表せる場合はその語を使う。例:`is_latest` / `isLatest` は + `latest`、`is_active` は `active`、`is_deleted` は `deleted`、 + `is_visible` は `visible`、表示状態は `visible`、有効状態は `enabled`。 +- 名詞で真偽値を表す場合は原則 `_flg`、camelCase では `Flg` を付ける。 + 例:`is_admin` は `admin_flg`、`is_owner` は `owner_flg`、 + `isAdmin` は `adminFlg`。クエリパラメータでは意味が明確なら接尾辞を省略できる。 +- callable の `setState`、`onChange` 等や、外部ライブラリ・フレームワーク所定の + API 名は変更しない。外部値を自前の状態へ保持する場合は境界で適切な別名を付ける。 + 言語固有の表記規約を維持し、予約語や曖昧さは規則を満たす明確な代替名で避ける。 +- 改名時は Frontend / Backend、URL、API、出力の名称を整合させる。 + 旧 URL・API の互換性は入出力境界で扱い、旧命名を新しい内部コードへ伝播させない。 + DB の改名は既存データを保持する追加マイグレーションで扱い、適用済みのものを + 書き換えない。メソッド名・外部 API・文字列値等を機械的な一括置換で破壊しない。 + ## Project overview BTRC Hub / タグ広場 is a split Rails API and React frontend repository. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 4878eb2..3ba7e2a 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -15,6 +15,17 @@ - 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。 export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。 +## Mandatory naming contract + +- ルート `AGENTS.md` の必須命名規約を適用する。メソッド等の callable 以外の + 自前識別子に動詞を使わず、形容詞の状態名、名詞の真偽値には `_flg` を使用する。 + 例:`is_latest` は `latest`、`is_admin` は `admin_flg`。 +- 引数、属性、SQL alias、API・URL パラメータ、JSON キー、DB カラムも対象とする。 + Rails 等が定義する外部 API 名や、処理を実行するメソッド名は変更しない。 +- 旧 API・URL の互換性は解析・シリアライズ境界で扱い、内部定義は新名に統一する。 + DB カラムの改名はデータを保持する追加マイグレーションで設計し、適用済みの + マイグレーションを書き換えない。マイグレーションの実行には個別指示を要する。 + ## Scope These rules apply to work under `backend/`. diff --git a/backend/app/services/counts_table_query.rb b/backend/app/services/counts_table_query.rb index 21f7c56..03f306d 100644 --- a/backend/app/services/counts_table_query.rb +++ b/backend/app/services/counts_table_query.rb @@ -43,12 +43,9 @@ class CountsTableQuery ApplicationRecord.transaction do result = metadata.merge(mode == 'summary' ? summary : details) visible = mode == 'summary' ? result[:visible_output_count] : result[:count] - # A scalar result has one row regardless of private attributes or predicates. - public_count = result[:excluded_count].zero? || (mode == 'summary' && axes.rows.empty?) result.merge(visible_output_count: visible, - output_count: public_count ? visible : nil, - output_count_scope: public_count ? - 'before_authorisation' : 'unavailable_private_history', + output_count: visible, + output_count_scope: 'authorised_results', output_count_unit: 'table_body_rows', page_grand_total_repeated: mode == 'summary' && axes.rows.any? && result[:group_count] > limit) @@ -57,7 +54,12 @@ class CountsTableQuery def metadata @population_counts ||= HistoryAccess.population_counts(@definition.model) + # Global exclusions are not exclusions for this filtered query. Do not expose + # private predicate matches through a condition-dependent count. + exclusions_known = @population_counts[:excluded_count].zero? @population_counts.merge( + excluded_count: exclusions_known ? 0 : nil, + excluded_count_scope: exclusions_known ? 'current_query' : 'unavailable_private_history', population_count_scope: 'all_recorded_versions', page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence, definition: population.history ? 'history' : 'latest', diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 721c01a..b8b75ec 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -15,6 +15,17 @@ - 秘匿は React / CSS では実装しない。認可済み API のみを使う。 認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。 +## Mandatory naming contract + +- ルート `AGENTS.md` の必須命名規約を適用する。非 callable の自前識別子に + 動詞を使わず、形容詞の状態名、名詞の真偽値には `Flg` を使用する。 + 例:`isLatest` は `latest`、`isAdmin` は `adminFlg`。 +- React / TanStack Query 等の外部 API 名は維持する。取得した値を自前の状態に + 保持するときは、必要に応じて分割代入等の境界で別名を付ける。 + callable の state setter、event handler、callback は動詞名を使用できる。 +- 型、表示項目キー、URL、API、コピー・エクスポートのフィールド参照を整合させる。 + 旧キーの互換性は共通の入出力境界で処理し、新しい内部状態には旧名を使用しない。 + ## Scope These rules apply to work under `frontend/`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a46f65f..2fc3e97 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,7 +22,7 @@ import { initialiseUserSession } from '@/lib/users' import { prepareHelpPage } from '@/lib/help' import { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath, unlocalisedPath } from '@/lib/locales' -import { localesKeys, sessionKeys } from '@/lib/queryKeys' +import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys' import { switchPostFilterLocale } from '@/lib/posts' import { switchWikiLocale } from '@/lib/wiki' import { useLocale } from '@/lib/useLocale' @@ -63,7 +63,10 @@ const lazyPage = ( }) const Deferred = lazy (preload) // A preloaded page must render synchronously on the first hydration pass. - const Page = (props: Props) => createElement (loaded ?? Deferred, props) + const Page = (props: Props) => { + const [Component] = useState (() => loaded ?? Deferred) + return createElement (Component, props) + } return Object.assign (Page, { preload }) } @@ -77,7 +80,6 @@ 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')) @@ -196,8 +198,8 @@ 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', element: , + handle: { preload: CountsResourcePage.preload } }, { path: 'counts/posts', element: , handle: { preload: CountsResourcePage.preload } }, { path: 'counts/tags', element: , @@ -276,10 +278,14 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { const next = typeof action === 'function' ? action (authority.current) : action if (next?.id !== authority.current?.id || next?.role !== authority.current?.role) { - client.cancelQueries () - client.removeQueries ({ predicate: query => query.queryKey[0] !== 'session' }) + const filters = { + predicate: (query: { queryKey: readonly unknown[] }) => + isAuthorityDependentQuery (query.queryKey) } + void client.cancelQueries (filters) + client.removeQueries (filters) } authority.current = next + client.setQueryData (sessionKeys.current, next) setUserState (next) }, [client]) const [status, setStatus] = useState (200) diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts index cfe7b15..e867a88 100644 --- a/frontend/src/lib/counts.ts +++ b/frontend/src/lib/counts.ts @@ -61,7 +61,8 @@ export type CountsPivotHeader = { values: (string | number | null)[] kind: 'data' | 'subtotal' | 'grand_total' } export type CountsResult = { populationCount?: number - excludedCount?: number + excludedCount?: number | null + excludedCountScope?: string outputCount?: number | null visibleOutputCount?: number outputCountScope?: 'before_authorisation' | 'unavailable_private_history' diff --git a/frontend/src/lib/countsDefinitions.ts b/frontend/src/lib/countsDefinitions.ts index c49652a..a0438a0 100644 --- a/frontend/src/lib/countsDefinitions.ts +++ b/frontend/src/lib/countsDefinitions.ts @@ -14,6 +14,12 @@ export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum' export const countsResources: CountsResource[] = [ 'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials'] + +// Navigation visibility only; supported resources and direct URLs remain available. +// The external-tag entry currently uses Nico tag history. +export const visibleCountsResources = [ + 'posts', 'tags', 'nico_tags'] as const satisfies readonly CountsResource[] + export type CountsFieldMetadata = { key: string; type: CountsFieldType; nullable: boolean; operators: string[] labels?: string[]; multiple?: boolean } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index fbc8fcd..e7828fc 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -157,6 +157,15 @@ export const sessionKeys = { settings: (id: number) => ['session', id, 'settings'] as const, themeSlots: (id: number) => ['session', id, 'themeSlots'] as const } +export const isAuthorityDependentQuery = (key: readonly unknown[]): boolean => { + // Only the public locale catalogue and the explicitly synchronised session survive. + const publicLocaleFlg = key.length === 2 + && key[0] === localesKeys.index[0] && key[1] === localesKeys.index[1] + const currentSessionFlg = key.length === 2 + && key[0] === sessionKeys.current[0] && key[1] === sessionKeys.current[1] + return !publicLocaleFlg && !currentSessionFlg +} + export const theatresKeys = { show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const, programmes: (id: string, locale: LocaleCode) => diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json index 43816e7..1f32d39 100644 --- a/frontend/src/locales/ja/counts.json +++ b/frontend/src/locales/ja/counts.json @@ -147,7 +147,7 @@ "resources": { "posts": "広場", "tags": "タグ", - "nico_tags": "ニコニコタグ", + "nico_tags": "外部タグ", "tag_descriptions": "タグ説明", "tag_groups": "タググループ", "materials": "素材" @@ -172,7 +172,7 @@ "changedField": "変更項目", "eventType": "異動区分", "count": "件数", - "outputTotal": "綜件数:{{value}} 件", + "outputTotal": "件数:{{value}} 件", "excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)", "recordTotal": "閲覧可能件数:{{count}}", "grain": "粒度", diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index 20523ba..2a197d7 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -19,7 +19,7 @@ import { countsRequestParams, countsDefinitionParams, countsExportPayload, field isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts' import { countsExportLabels } from '@/lib/countsDisplay' import { CountsDefinitionContext, countsAxisFields, countsAxisLabel, - countsResources, countsFieldLabel, + visibleCountsResources, countsFieldLabel, fetchCountsDefinition } from '@/lib/countsDefinitions' import { countsKeys } from '@/lib/queryKeys' @@ -211,9 +211,6 @@ const CountsResourceContent = ( const waiting = running || (!definitionError && (definition == null || isPending || isFetching || isPlaceholderData || (!params.has ('execution_at') && !isError))) - const loadingParams = pendingRun.current?.params ?? params - const compatible = data != null && ['definition', 'mode', 'rows', 'cols'].every ( - key => loadingParams.get (key) === resultParams.get (key)) const update = (key: string, value: string, extra?: Record) => { @@ -287,7 +284,7 @@ const CountsResourceContent = ( lang={message ('target').language}>

{label ('target')}

    - {countsResources.map (target =>
  • + {visibleCountsResources.map (target =>