Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions admin/src/lib/dateUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Expand All @@ -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");
});
});
37 changes: 32 additions & 5 deletions admin/src/lib/dateUtils.ts
Original file line number Diff line number Diff line change
@@ -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());
}
4 changes: 2 additions & 2 deletions admin/src/pages/users/UserCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,7 +78,7 @@ export function UserCard({
</SelectContent>
</Select>
<span className="text-xs text-slate-500">
{t("users.card.pageCount", { count: user.pageCount.toLocaleString("ja-JP") })}
{t("users.card.pageCount", { count: formatNumber(user.pageCount) })}
</span>
<span className="text-xs text-slate-500">{formatDate(user.createdAt)}</span>
{!saving && user.status === "deleted" ? (
Expand Down
6 changes: 6 additions & 0 deletions admin/src/pages/users/UsersContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
6 changes: 3 additions & 3 deletions admin/src/pages/users/UsersContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
TableRow,
} from "@zedi/ui";
import type { UserAdmin, UserRole, UserStatus } from "@/api/admin";
import { formatDate } from "@/lib/dateUtils";
import { formatDate, formatNumber, getActiveLocale } from "@/lib/dateUtils";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

getActiveLocaleformatDate 内部で使用されており、コンポーネント側で直接呼び出す必要がなくなるため、インポートから削除できます。

Suggested change
import { formatDate, formatNumber, getActiveLocale } from "@/lib/dateUtils";
import { formatDate, formatNumber } from "@/lib/dateUtils";

import { ConfirmActionDialog } from "@/components/ConfirmActionDialog";
import { UserCard } from "./UserCard";
import { SuspendDialog } from "./SuspendDialog";
Expand Down Expand Up @@ -70,7 +70,7 @@
* @param props - Users, pagination, search, and action handlers
* @returns User management UI
*/
export function UsersContent({

Check warning on line 73 in admin/src/pages/users/UsersContent.tsx

View workflow job for this annotation

GitHub Actions / Lint

Function 'UsersContent' has a complexity of 22. Maximum allowed is 20

Check warning on line 73 in admin/src/pages/users/UsersContent.tsx

View workflow job for this annotation

GitHub Actions / Lint

Function 'UsersContent' has too many lines (317). Maximum allowed is 150
users,
total,
page,
Expand Down Expand Up @@ -196,7 +196,7 @@
</Select>
</TableCell>
<TableCell className="text-muted-foreground px-3 py-2 tabular-nums">
{u.pageCount.toLocaleString("ja-JP")}
{formatNumber(u.pageCount)}
</TableCell>
<TableCell className="text-muted-foreground px-3 py-2">
{formatDate(u.createdAt)}
Expand Down Expand Up @@ -386,7 +386,7 @@
<li>
{t("users.impact.lastAiUsage", {
date: new Date(confirm.deleteTarget.impact.lastAiUsageAt).toLocaleDateString(
"ja-JP",
getActiveLocale(),
),
Comment on lines 388 to 390
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

既に formatDate ユーティリティが定義されているため、ここでもそれを利用することでコードの重複を避け、日付形式(0埋めなど)の統一性を保つことができます。また、formatDate は内部で getActiveLocale() を呼び出しており、不正な日付入力に対するハンドリングも含まれているため、より安全です。

                    date: formatDate(confirm.deleteTarget.impact.lastAiUsageAt),

})}
</li>
Expand Down
33 changes: 18 additions & 15 deletions server/hocuspocus/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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");
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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);
},
}),
Expand Down
16 changes: 10 additions & 6 deletions src/pages/NoteMembers/NoteMembersManageSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading