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;
}