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 =>
-
}
-
+
{definition != null && data != null && <>
- {data.excludedCount != null && (
-
- {label ('outputTotal', { value: data.outputCount ?? '—' })}
-
- {label ('excludedRecords', { count: data.excludedCount })}
-
)}
+
+ {label ('outputTotal', { value: data.outputCount ?? '—' })}
+ {data.excludedCountScope === 'current_query'
+ && data.excludedCount != null && data.excludedCount > 0 && (
+
+ {label ('excludedRecords', { count: data.excludedCount })})}
+
{resultMode === 'summary' ?
: (
@@ -372,7 +368,7 @@ const CountsResourceContent = (
>}
{waiting &&
+ className="pointer-events-none absolute right-2 top-2">