diff --git a/admin/src/lib/dateUtils.test.ts b/admin/src/lib/dateUtils.test.ts index 38bbbe83..856bf1a1 100644 --- a/admin/src/lib/dateUtils.test.ts +++ b/admin/src/lib/dateUtils.test.ts @@ -1,19 +1,50 @@ /** - * formatDate のテスト。 - * Tests for formatDate. + * formatDate / formatNumber / getActiveLocale のテスト。 + * Tests for formatDate, formatNumber, and getActiveLocale. */ -import { describe, it, expect } from "vitest"; -import { formatDate } from "./dateUtils"; +import { describe, it, expect, afterEach } from "vitest"; +import i18n from "@/i18n"; +import { formatDate, formatNumber, getActiveLocale } from "./dateUtils"; + +afterEach(async () => { + // 他テストファイルが期待する ja への復帰を保証する。 + // Restore the global ja default so subsequent test files keep their assumptions. + await i18n.changeLanguage("ja"); +}); + +describe("getActiveLocale", () => { + it("ja の時 ja-JP を返す / returns ja-JP when language is ja", async () => { + await i18n.changeLanguage("ja"); + expect(getActiveLocale()).toBe("ja-JP"); + }); + + it("en の時 en-US を返す / returns en-US when language is en", async () => { + await i18n.changeLanguage("en"); + expect(getActiveLocale()).toBe("en-US"); + }); + + it("未知言語は en-US にフォールバックする / falls back to en-US for unknown languages", async () => { + await i18n.changeLanguage("fr"); + expect(getActiveLocale()).toBe("en-US"); + }); +}); describe("formatDate", () => { - it("ISO 8601 を YYYY/MM/DD(ja-JP)に整形する / formats ISO date in ja-JP locale", () => { + it("ja で YYYY/MM/DD に整形する / formats as YYYY/MM/DD in ja", async () => { + await i18n.changeLanguage("ja"); expect(formatDate("2026-04-25T01:23:45Z")).toBe("2026/04/25"); }); - it("月日が 1 桁でも 0 埋めされる / zero-pads single-digit month/day", () => { + it("ja で 1 桁の月日を 0 埋めする / zero-pads single-digit month/day in ja", async () => { + await i18n.changeLanguage("ja"); expect(formatDate("2026-01-02T00:00:00Z")).toBe("2026/01/02"); }); + it("en で MM/DD/YYYY に整形する / formats as MM/DD/YYYY in en", async () => { + await i18n.changeLanguage("en"); + expect(formatDate("2026-04-25T01:23:45Z")).toBe("04/25/2026"); + }); + it("不正な日付文字列はそのまま返す / returns input as-is for invalid date", () => { expect(formatDate("not-a-date")).toBe("not-a-date"); }); @@ -22,3 +53,19 @@ describe("formatDate", () => { expect(formatDate("")).toBe(""); }); }); + +describe("formatNumber", () => { + it("ja でカンマ区切りに整形する / formats with comma separators in ja", async () => { + await i18n.changeLanguage("ja"); + expect(formatNumber(1234567)).toBe("1,234,567"); + }); + + it("en でカンマ区切りに整形する / formats with comma separators in en", async () => { + await i18n.changeLanguage("en"); + expect(formatNumber(1234567)).toBe("1,234,567"); + }); + + it('0 は "0" を返す / returns "0" for zero', () => { + expect(formatNumber(0)).toBe("0"); + }); +}); diff --git a/admin/src/lib/dateUtils.ts b/admin/src/lib/dateUtils.ts index 34ea747e..320ee53d 100644 --- a/admin/src/lib/dateUtils.ts +++ b/admin/src/lib/dateUtils.ts @@ -1,19 +1,46 @@ +import i18n from "@/i18n"; + /** - * ISO 8601 形式の日付文字列を日本語ロケールの日付形式に変換する。 - * Converts an ISO 8601 date string to Japanese locale date format. + * 現在の i18n 言語に対応する BCP 47 ロケールタグを返す。 + * 日本語以外は `en-US` にフォールバックする(将来言語追加時の保守性のため)。 + * + * Returns a BCP 47 locale tag matching the current i18n language. + * Falls back to `en-US` for any non-`ja` language so adding new locales later + * does not silently render Japanese. + */ +export function getActiveLocale(): "ja-JP" | "en-US" { + const lang = i18n.language?.split("-")[0]; + if (lang === "ja") return "ja-JP"; + return "en-US"; +} + +/** + * ISO 8601 形式の日付文字列を、現在の i18n ロケールに合わせた日付形式に変換する。 + * Converts an ISO 8601 date string to a date format matching the active i18n locale. * * @param iso - ISO 8601 形式の日付文字列 / ISO 8601 date string - * @returns 日本語形式(YYYY/MM/DD)の日付文字列。不正な入力の場合はそのまま返す。 - * Date string in Japanese format (YYYY/MM/DD). Returns input as-is for invalid input. + * @returns ロケール依存の日付文字列。不正な入力の場合はそのまま返す。 + * Locale-formatted date string. Returns input as-is for invalid input. */ export function formatDate(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) { return iso; } - return date.toLocaleDateString("ja-JP", { + return date.toLocaleDateString(getActiveLocale(), { year: "numeric", month: "2-digit", day: "2-digit", }); } + +/** + * 数値を現在の i18n ロケールの慣習で整形する(桁区切りなど)。 + * Formats a number using the active i18n locale (thousand separators, etc.). + * + * @param value - 数値 / numeric value + * @returns ロケール依存の数値文字列 / locale-formatted number string + */ +export function formatNumber(value: number): string { + return value.toLocaleString(getActiveLocale()); +} diff --git a/admin/src/pages/users/UserCard.tsx b/admin/src/pages/users/UserCard.tsx index 9f4eed02..2e21db09 100644 --- a/admin/src/pages/users/UserCard.tsx +++ b/admin/src/pages/users/UserCard.tsx @@ -11,7 +11,7 @@ import { SelectValue, } from "@zedi/ui"; import type { UserAdmin, UserRole } from "@/api/admin"; -import { formatDate } from "@/lib/dateUtils"; +import { formatDate, formatNumber } from "@/lib/dateUtils"; interface UserCardProps { user: UserAdmin; @@ -78,7 +78,7 @@ export function UserCard({ - {t("users.card.pageCount", { count: user.pageCount.toLocaleString("ja-JP") })} + {t("users.card.pageCount", { count: formatNumber(user.pageCount) })} {formatDate(user.createdAt)} {!saving && user.status === "deleted" ? ( diff --git a/admin/src/pages/users/UsersContent.test.tsx b/admin/src/pages/users/UsersContent.test.tsx index c447036a..b37d51ed 100644 --- a/admin/src/pages/users/UsersContent.test.tsx +++ b/admin/src/pages/users/UsersContent.test.tsx @@ -127,6 +127,12 @@ vi.mock("./useConfirmDialogs", () => ({ vi.mock("@/lib/dateUtils", () => ({ formatDate: (d: string) => d, + // Mirror the real ja-JP behaviour (the test setup forces ja) so the + // existing "1,234" assertion below stays meaningful. + // 実装は ja のときカンマ区切りになる。テスト setup が ja を強制しているため、 + // 既存アサーション "1,234" がそのまま意味を持つよう同等の整形を返す。 + formatNumber: (n: number) => n.toLocaleString("ja-JP"), + getActiveLocale: () => "ja-JP" as const, })); const mockUser: UserAdmin = { diff --git a/admin/src/pages/users/UsersContent.tsx b/admin/src/pages/users/UsersContent.tsx index e12d80c3..76c26f3b 100644 --- a/admin/src/pages/users/UsersContent.tsx +++ b/admin/src/pages/users/UsersContent.tsx @@ -17,7 +17,7 @@ import { TableRow, } from "@zedi/ui"; import type { UserAdmin, UserRole, UserStatus } from "@/api/admin"; -import { formatDate } from "@/lib/dateUtils"; +import { formatDate, formatNumber, getActiveLocale } from "@/lib/dateUtils"; import { ConfirmActionDialog } from "@/components/ConfirmActionDialog"; import { UserCard } from "./UserCard"; import { SuspendDialog } from "./SuspendDialog"; @@ -196,7 +196,7 @@ export function UsersContent({ - {u.pageCount.toLocaleString("ja-JP")} + {formatNumber(u.pageCount)} {formatDate(u.createdAt)} @@ -386,7 +386,7 @@ export function UsersContent({
  • {t("users.impact.lastAiUsage", { date: new Date(confirm.deleteTarget.impact.lastAiUsageAt).toLocaleDateString( - "ja-JP", + getActiveLocale(), ), })}
  • diff --git a/server/hocuspocus/src/index.ts b/server/hocuspocus/src/index.ts index cdf425ba..00ba7ef0 100644 --- a/server/hocuspocus/src/index.ts +++ b/server/hocuspocus/src/index.ts @@ -500,22 +500,25 @@ hocuspocusServer }); // Graceful shutdown -process.on("SIGTERM", async () => { - console.log("[Shutdown] SIGTERM received, closing server..."); - await hocuspocusServer.destroy(); - if (pgPool) { - await pgPool.end(); +async function gracefulShutdown(signal: "SIGTERM" | "SIGINT"): Promise { + console.log(`[Shutdown] ${signal} received, closing server...`); + try { + await hocuspocusServer.destroy(); + if (pgPool) { + await pgPool.end(); + } + console.log("[Shutdown] Server closed"); + process.exit(0); + } catch (error) { + console.error("[Shutdown] Failed to close server cleanly:", error); + process.exit(1); } - console.log("[Shutdown] Server closed"); - process.exit(0); +} + +process.on("SIGTERM", () => { + void gracefulShutdown("SIGTERM"); }); -process.on("SIGINT", async () => { - console.log("[Shutdown] SIGINT received, closing server..."); - await hocuspocusServer.destroy(); - if (pgPool) { - await pgPool.end(); - } - console.log("[Shutdown] Server closed"); - process.exit(0); +process.on("SIGINT", () => { + void gracefulShutdown("SIGINT"); }); diff --git a/src/components/editor/TiptapEditor/headingLevelClampExtension.ts b/src/components/editor/TiptapEditor/headingLevelClampExtension.ts index abf3baf0..627afb32 100644 --- a/src/components/editor/TiptapEditor/headingLevelClampExtension.ts +++ b/src/components/editor/TiptapEditor/headingLevelClampExtension.ts @@ -11,19 +11,18 @@ const headingLevelClampKey = new PluginKey("headingLevelClamp"); * also run once in queueMicrotask after the plugin view is mounted */ function buildHeadingClampTr(state: EditorState): Transaction | null { - const tr = state.tr; - let modified = false; + let tr: Transaction | null = null; state.doc.descendants((node, pos) => { if (node.type.name !== "heading") return; const level = typeof node.attrs.level === "number" ? node.attrs.level : 1; if (level < 2) { + tr ??= state.tr; tr.setNodeMarkup(pos, undefined, { ...node.attrs, level: 2 }); - modified = true; } // headings only contain inline content; no need to scan descendants further return false; }); - return modified ? tr : null; + return tr; } /** @@ -49,7 +48,10 @@ export const HeadingLevelClamp = Extension.create({ }); return {}; }, - appendTransaction(_transactions, _oldState, newState) { + appendTransaction(transactions, _oldState, newState) { + if (!transactions.some((tr) => tr.docChanged)) { + return null; + } return buildHeadingClampTr(newState); }, }), diff --git a/src/pages/NoteMembers/NoteMembersManageSection.tsx b/src/pages/NoteMembers/NoteMembersManageSection.tsx index f75543ef..3a13b04b 100644 --- a/src/pages/NoteMembers/NoteMembersManageSection.tsx +++ b/src/pages/NoteMembers/NoteMembersManageSection.tsx @@ -114,15 +114,19 @@ export interface NoteMembersManageSectionProps { */ now?: () => number; /** - * read-only モードで描画するか。editor / viewer 向けにメンバー一覧だけ - * 閲覧させたいときに `true` を渡す。 + * read-only モードで描画するか。editor / viewer 向けに `NoteMembersManageSection` + * のメンバー一覧だけ閲覧させたいときに `true` を渡す。 * - 招待フォーム(Email / Role / Add)は非表示 * - 各メンバー行の Role セレクトは disabled - * - 再送信 / 取り消し ボタンは非表示 - * - ステータスバッジ(pending / expired / accepted)は引き続き表示する + * - 再送信 / 取り消し ボタンと、accepted 行の削除ボタンは非表示 + * - `deriveBadgeVariant()` のステータスバッジ(declined 含む)は引き続き表示する * - * Render in read-only mode (editor / viewer browsing the list). Hides the - * invite form and per-row action buttons; status badges remain. + * Render `NoteMembersManageSection` in read-only mode (editor / viewer + * browsing the member list only). + * - Hides the invite form (email / role / add) + * - Disables each member row's Role select + * - Hides resend / cancel buttons and the remove button on accepted rows + * - Keeps `deriveBadgeVariant()` status badges visible, including declined */ readOnly?: boolean; }