完了日時: {current.completedAt ?? '未完了'}
作成: {current.createdAt}
更新: {current.updatedAt}
@@ -291,7 +297,7 @@ export function TodoDialog({
diff --git a/docs/functional-design.md b/docs/functional-design.md
index df434ee..c67af1c 100644
--- a/docs/functional-design.md
+++ b/docs/functional-design.md
@@ -84,11 +84,15 @@ interface User {
avatarUrl: string | null; // アイコン画像URL
role: UserRole; // 'system_admin' | 'project_admin' | 'member' | 'guest'
status: UserStatus; // 'active' | 'inactive'
+ theme: Theme; // 'dark' | 'light' (既定 dark)
+ locale: Locale; // 'en' | 'ja' (既定 en)
createdAt: string; // ISO8601
updatedAt: string; // ISO8601
}
type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest';
type UserStatus = 'active' | 'inactive';
+type Theme = 'dark' | 'light';
+type Locale = 'en' | 'ja';
```
**制約**: emailは一意。passwordHashは平文保存しない。status='inactive'はログイン不可。
diff --git a/lib/auth/getCurrentUser.ts b/lib/auth/getCurrentUser.ts
index b211a61..9d98cc7 100644
--- a/lib/auth/getCurrentUser.ts
+++ b/lib/auth/getCurrentUser.ts
@@ -16,6 +16,8 @@ export function toPublicUser(user: User): PublicUser {
avatarUrl: user.avatarUrl,
role: user.role,
status: user.status,
+ theme: user.theme,
+ locale: user.locale,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
diff --git a/lib/db/migrations/004_user_prefs.sql b/lib/db/migrations/004_user_prefs.sql
new file mode 100644
index 0000000..38e341a
--- /dev/null
+++ b/lib/db/migrations/004_user_prefs.sql
@@ -0,0 +1,5 @@
+-- 004_user_prefs.sql
+-- ユーザーごとのテーマ(dark/light)と言語(en/ja)設定を追加。
+
+ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark';
+ALTER TABLE users ADD COLUMN locale TEXT NOT NULL DEFAULT 'en';
diff --git a/lib/i18n/I18nProvider.tsx b/lib/i18n/I18nProvider.tsx
new file mode 100644
index 0000000..8d83b5a
--- /dev/null
+++ b/lib/i18n/I18nProvider.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+import { useRouter } from 'next/navigation';
+import { dictionary, type MessageKey } from '@/lib/i18n/dictionary';
+import { PREF_MAX_AGE } from '@/lib/i18n/constants';
+import type { Locale, Theme } from '@/lib/types';
+
+interface I18nContextValue {
+ locale: Locale;
+ theme: Theme;
+ t: (key: MessageKey) => string;
+ setLocale: (locale: Locale) => void;
+ setTheme: (theme: Theme) => Promise
;
+}
+
+const I18nContext = createContext(null);
+
+/**
+ * 言語とテーマのクライアント状態を提供するコンテキスト。
+ * - locale: 辞書の切り替え(t)と の更新
+ * - setLocale/setTheme: Cookie を即時設定し /api/users/me で永続化、router.refresh でSSR再描画
+ */
+export function I18nProvider({
+ initialLocale,
+ initialTheme,
+ children,
+}: {
+ initialLocale: Locale;
+ initialTheme: Theme;
+ children: ReactNode;
+}) {
+ const router = useRouter();
+ const [locale, setLocaleState] = useState(initialLocale);
+ const [theme, setThemeState] = useState(initialTheme);
+
+ const t = useCallback(
+ (key: MessageKey) => dictionary[locale][key] ?? dictionary.en[key] ?? key,
+ [locale]
+ );
+
+ const persist = useCallback(async (body: Record) => {
+ try {
+ await fetch('/api/users/me', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ } catch (error) {
+ // 永続化失敗時もローカルCookieで動作継続するが、ログは残す
+ console.warn('Failed to persist user prefs:', error);
+ }
+ }, []);
+
+ const setLocale = useCallback(
+ (next: Locale) => {
+ setLocaleState(next);
+ document.cookie = `locale=${next}; path=/; max-age=${PREF_MAX_AGE}; samesite=lax`;
+ void persist({ locale: next });
+ router.refresh();
+ },
+ [persist, router]
+ );
+
+ const setTheme = useCallback(
+ async (next: Theme) => {
+ setThemeState(next);
+ const root = document.documentElement;
+ if (next === 'dark') root.classList.add('dark');
+ else root.classList.remove('dark');
+ root.style.colorScheme = next;
+ document.cookie = `theme=${next}; path=/; max-age=${PREF_MAX_AGE}; samesite=lax`;
+ await persist({ theme: next });
+ },
+ [persist]
+ );
+
+ const value = useMemo(
+ () => ({ locale, theme, t, setLocale, setTheme }),
+ [locale, theme, t, setLocale, setTheme]
+ );
+
+ return {children};
+}
+
+export function useI18n(): I18nContextValue {
+ const ctx = useContext(I18nContext);
+ if (!ctx) {
+ throw new Error('useI18n must be used within I18nProvider');
+ }
+ return ctx;
+}
diff --git a/lib/i18n/constants.ts b/lib/i18n/constants.ts
new file mode 100644
index 0000000..5133c26
--- /dev/null
+++ b/lib/i18n/constants.ts
@@ -0,0 +1,10 @@
+import type { Locale, Theme } from '@/lib/types';
+
+/** テーマ/言語のプリフ Cookie の有効期限(1年)。 */
+export const PREF_MAX_AGE = 60 * 60 * 24 * 365;
+
+/** 許容されるテーマ。 */
+export const VALID_THEMES: Theme[] = ['dark', 'light'];
+
+/** 許容される言語。 */
+export const VALID_LOCALES: Locale[] = ['en', 'ja'];
diff --git a/lib/i18n/dictionary.ts b/lib/i18n/dictionary.ts
new file mode 100644
index 0000000..059cbcb
--- /dev/null
+++ b/lib/i18n/dictionary.ts
@@ -0,0 +1,172 @@
+import type { Locale } from '@/lib/types';
+
+/**
+ * 多言語辞書。en/ja の両方に同じキーを定義する。
+ * 共通クロム(ヘッダー/ナビ)、認証、プロフィール/設定、ページタイトル、
+ * 共通アクションラベルをカバーする。機能画面の本文は漸次追加する。
+ */
+const en = {
+ 'app.name': 'Groupware',
+ 'nav.dashboard': 'Dashboard',
+ 'nav.overview': 'Overview',
+ 'nav.board': 'Board',
+ 'nav.notes': 'Notes',
+ 'nav.chat': 'Chat',
+ 'nav.todos': 'ToDo',
+ 'nav.files': 'Files',
+ 'nav.calendar': 'Calendar',
+ 'nav.milestones': 'Milestones',
+ 'nav.meetings': 'Meetings',
+ 'nav.search': 'Search',
+ 'nav.members': 'Members',
+ 'nav.activity': 'Activity',
+ 'nav.settings': 'Settings',
+ 'header.logout': 'Logout',
+ 'common.save': 'Save',
+ 'common.cancel': 'Cancel',
+ 'common.create': 'Create',
+ 'common.delete': 'Delete',
+ 'common.submit': 'Submit',
+ 'common.loading': 'Loading...',
+ 'common.back': 'Back',
+ 'common.edit': 'Edit',
+ 'theme.dark': 'Dark',
+ 'theme.light': 'Light',
+ 'language.english': 'English',
+ 'language.japanese': '日本語',
+ 'auth.login': 'Login',
+ 'auth.register': 'Register',
+ 'auth.displayName': 'Display name',
+ 'auth.email': 'Email',
+ 'auth.password': 'Password',
+ 'auth.registerHere': 'New here? Register',
+ 'auth.registerButton': 'Register',
+ 'auth.loginButton': 'Login',
+ 'auth.backToLogin': 'Back to login',
+ 'auth.processing': 'Processing...',
+ 'auth.failed': 'Operation failed',
+ 'auth.projectName': 'Project name',
+ 'auth.newProject': 'New project',
+ 'project.description': 'Description (optional)',
+ 'project.creating': 'Creating...',
+ 'project.createFailed': 'Failed to create project',
+ 'profile.title': 'Profile',
+ 'profile.displayName': 'Display name',
+ 'profile.email': 'Email',
+ 'profile.avatarUrl': 'Avatar image URL',
+ 'profile.saved': 'Profile updated',
+ 'profile.backToDashboard': 'Back to dashboard',
+ 'profile.role': 'Role',
+ 'profile.theme': 'Theme',
+ 'profile.language': 'Language',
+ 'page.dashboard': 'Dashboard',
+ 'page.board': 'Board',
+ 'page.notes': 'Notes',
+ 'page.chat': 'Chat',
+ 'page.todos': 'ToDo / Kanban',
+ 'page.files': 'Files',
+ 'page.calendar': 'Calendar',
+ 'page.milestones': 'Milestones',
+ 'page.meetings': 'Meetings',
+ 'page.search': 'Search',
+ 'page.members': 'Members',
+ 'page.activity': 'Activity',
+ 'page.settings': 'Settings',
+ 'page.profile': 'Profile',
+ 'page.login': 'Login',
+ 'dash.projects': 'Joined projects',
+ 'dash.unreadNotifications': 'Unread notifications',
+ 'dash.incompleteTodos': 'Incomplete ToDo',
+ 'dash.overdueTasks': 'Overdue tasks',
+ 'dash.upcomingMeetings': 'Upcoming meetings',
+ 'dash.recentActivity': 'Recent activity',
+ 'dash.empty': 'None',
+ 'dash.noUnread': 'No unread',
+ 'dash.openNotifications': 'Open notifications',
+ 'dash.due': 'Due',
+};
+
+const ja: typeof en = {
+ 'app.name': 'シンプルグループウェア',
+ 'nav.dashboard': 'ダッシュボード',
+ 'nav.overview': '概要',
+ 'nav.board': '掲示板',
+ 'nav.notes': 'メモ',
+ 'nav.chat': 'チャット',
+ 'nav.todos': 'ToDo',
+ 'nav.files': 'ファイル',
+ 'nav.calendar': 'カレンダー',
+ 'nav.milestones': 'マイルストーン',
+ 'nav.meetings': 'ミーティング',
+ 'nav.search': '検索',
+ 'nav.members': 'メンバー',
+ 'nav.activity': 'アクティビティ',
+ 'nav.settings': '設定',
+ 'header.logout': 'ログアウト',
+ 'common.save': '保存',
+ 'common.cancel': 'キャンセル',
+ 'common.create': '作成',
+ 'common.delete': '削除',
+ 'common.submit': '送信',
+ 'common.loading': '読み込み中...',
+ 'common.back': '戻る',
+ 'common.edit': '編集',
+ 'theme.dark': 'ダーク',
+ 'theme.light': 'ライト',
+ 'language.english': 'English',
+ 'language.japanese': '日本語',
+ 'auth.login': 'ログイン',
+ 'auth.register': '新規登録',
+ 'auth.displayName': '表示名',
+ 'auth.email': 'メールアドレス',
+ 'auth.password': 'パスワード',
+ 'auth.registerHere': '新規登録はこちら',
+ 'auth.registerButton': '登録する',
+ 'auth.loginButton': 'ログイン',
+ 'auth.backToLogin': 'ログイン画面に戻る',
+ 'auth.processing': '処理中...',
+ 'auth.failed': '処理に失敗しました',
+ 'auth.projectName': 'プロジェクト名',
+ 'auth.newProject': '新規プロジェクト',
+ 'project.description': '説明(任意)',
+ 'project.creating': '作成中...',
+ 'project.createFailed': 'プロジェクトの作成に失敗しました',
+ 'profile.title': 'プロフィール',
+ 'profile.displayName': '表示名',
+ 'profile.email': 'メールアドレス',
+ 'profile.avatarUrl': 'アイコン画像URL',
+ 'profile.saved': 'プロフィールを更新しました',
+ 'profile.backToDashboard': 'ダッシュボードへ',
+ 'profile.role': 'ロール',
+ 'profile.theme': 'テーマ',
+ 'profile.language': '言語',
+ 'page.dashboard': 'ダッシュボード',
+ 'page.board': '掲示板',
+ 'page.notes': 'メモ',
+ 'page.chat': 'チャット',
+ 'page.todos': 'ToDo / Kanban',
+ 'page.files': 'ファイル',
+ 'page.calendar': 'カレンダー',
+ 'page.milestones': 'マイルストーン',
+ 'page.meetings': 'ミーティング',
+ 'page.search': '検索',
+ 'page.members': 'メンバー',
+ 'page.activity': 'アクティビティ',
+ 'page.settings': '設定',
+ 'page.profile': 'プロフィール',
+ 'page.login': 'ログイン',
+ 'dash.projects': '参加プロジェクト',
+ 'dash.unreadNotifications': '未読通知',
+ 'dash.incompleteTodos': '未完了ToDo',
+ 'dash.overdueTasks': '期限切れタスク',
+ 'dash.upcomingMeetings': '近日中のミーティング',
+ 'dash.recentActivity': '最近のアクティビティ',
+ 'dash.empty': 'ありません',
+ 'dash.noUnread': '未読はありません',
+ 'dash.openNotifications': '通知一覧を開く',
+ 'dash.due': '期限',
+};
+
+export const dictionary: Record = { en, ja };
+
+export type MessageKey = keyof typeof en;
diff --git a/lib/i18n/server.ts b/lib/i18n/server.ts
new file mode 100644
index 0000000..e4d718e
--- /dev/null
+++ b/lib/i18n/server.ts
@@ -0,0 +1,20 @@
+import { cookies } from 'next/headers';
+import { dictionary, type MessageKey } from './dictionary';
+import type { Locale, Theme } from '@/lib/types';
+
+/** サーバー側で locale Cookie を読み解決する(既定 en)。 */
+export async function getLocale(): Promise {
+ const c = await cookies();
+ return c.get('locale')?.value === 'ja' ? 'ja' : 'en';
+}
+
+/** サーバー側で theme Cookie を読み解決する(既定 dark)。 */
+export async function getTheme(): Promise {
+ const c = await cookies();
+ return c.get('theme')?.value === 'light' ? 'light' : 'dark';
+}
+
+/** サーバー側で翻訳文字列を取得する。 */
+export function translate(key: MessageKey, locale: Locale): string {
+ return dictionary[locale][key] ?? dictionary.en[key] ?? key;
+}
diff --git a/lib/types/index.ts b/lib/types/index.ts
index cd07525..fdf5d0d 100644
--- a/lib/types/index.ts
+++ b/lib/types/index.ts
@@ -8,6 +8,8 @@
export type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest';
export type UserStatus = 'active' | 'inactive';
+export type Theme = 'dark' | 'light';
+export type Locale = 'en' | 'ja';
export type ProjectStatus = 'active' | 'on_hold' | 'completed' | 'archived';
export type ProjectMemberRole = 'admin' | 'member' | 'guest';
export type BoardCategory =
@@ -48,6 +50,8 @@ export interface User {
avatarUrl: string | null;
role: UserRole;
status: UserStatus;
+ theme: Theme;
+ locale: Locale;
createdAt: string;
updatedAt: string;
}
diff --git a/lib/validators/userValidator.ts b/lib/validators/userValidator.ts
index 401441f..dddcf24 100644
--- a/lib/validators/userValidator.ts
+++ b/lib/validators/userValidator.ts
@@ -1,4 +1,6 @@
import { ValidationError } from '@/lib/errors';
+import type { Locale, Theme } from '@/lib/types';
+import { VALID_LOCALES, VALID_THEMES } from '@/lib/i18n/constants';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_NAME_LENGTH = 100;
@@ -20,6 +22,8 @@ export interface ProfileUpdateInput {
name?: string;
email?: string;
avatarUrl?: string;
+ theme?: Theme;
+ locale?: Locale;
}
export function validateRegister(input: RegisterInput): void {
@@ -41,7 +45,9 @@ export function validateProfileUpdate(input: ProfileUpdateInput): void {
if (
input.name === undefined &&
input.email === undefined &&
- input.avatarUrl === undefined
+ input.avatarUrl === undefined &&
+ input.theme === undefined &&
+ input.locale === undefined
) {
throw new ValidationError('更新対象のフィールドを指定してください');
}
@@ -56,6 +62,12 @@ export function validateProfileUpdate(input: ProfileUpdateInput): void {
'avatarUrl'
);
}
+ if (input.theme !== undefined && !VALID_THEMES.includes(input.theme)) {
+ throw new ValidationError('無効なテーマです', 'theme');
+ }
+ if (input.locale !== undefined && !VALID_LOCALES.includes(input.locale)) {
+ throw new ValidationError('無効な言語です', 'locale');
+ }
}
function validateName(name: string): void {
diff --git a/playwright.config.ts b/playwright.config.ts
index f8d5a2b..3f5e9f5 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -12,6 +12,9 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
+ // E2Eは既存の日本語アサーションを維持するため既定で ja ロケールを使用。
+ // (既定UIは en だが、テストは ja Cookie を与えて日本語表示を検証する)
+ storageState: 'tests/e2e/locale-ja.json',
},
projects: [
{
diff --git a/repositories/UserRepository.ts b/repositories/UserRepository.ts
index e9580a3..0174320 100644
--- a/repositories/UserRepository.ts
+++ b/repositories/UserRepository.ts
@@ -1,5 +1,5 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
-import type { User, UserRole, UserStatus } from '@/lib/types';
+import type { Locale, Theme, User, UserRole, UserStatus } from '@/lib/types';
interface UserRow {
id: number;
@@ -9,6 +9,8 @@ interface UserRow {
avatar_url: string | null;
role: string;
status: string;
+ theme: string;
+ locale: string;
created_at: string;
updated_at: string;
}
@@ -22,6 +24,8 @@ function mapUser(row: UserRow): User {
avatarUrl: row.avatar_url,
role: row.role as UserRole,
status: row.status as UserStatus,
+ theme: row.theme as Theme,
+ locale: row.locale as Locale,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
@@ -40,6 +44,8 @@ export interface UpdateUserInput {
avatarUrl?: string | null;
role?: UserRole;
status?: UserStatus;
+ theme?: Theme;
+ locale?: Locale;
}
/**
@@ -114,6 +120,14 @@ export class UserRepository {
fields.push('status = @status');
params.status = input.status;
}
+ if (input.theme !== undefined) {
+ fields.push('theme = @theme');
+ params.theme = input.theme;
+ }
+ if (input.locale !== undefined) {
+ fields.push('locale = @locale');
+ params.locale = input.locale;
+ }
this.db.execute(
`UPDATE users SET ${fields.join(', ')} WHERE id = @id`,
diff --git a/services/AuthService.ts b/services/AuthService.ts
index 2bb422e..e169c90 100644
--- a/services/AuthService.ts
+++ b/services/AuthService.ts
@@ -92,6 +92,8 @@ export class AuthService {
name: input.name,
email: input.email,
avatarUrl: input.avatarUrl,
+ theme: input.theme,
+ locale: input.locale,
});
if (!updated) {
throw new NotFoundError('User', userId);
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 75cce7b..59ca5a0 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,6 +1,7 @@
import type { Config } from 'tailwindcss';
const config: Config = {
+ darkMode: 'class',
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
diff --git a/tests/e2e/chat-sse.spec.ts b/tests/e2e/chat-sse.spec.ts
index ecc0f58..8db2704 100644
--- a/tests/e2e/chat-sse.spec.ts
+++ b/tests/e2e/chat-sse.spec.ts
@@ -22,12 +22,16 @@ test.describe('chat (SSE realtime)', () => {
const memberEmail = unique('member') + '@example.com';
// メンバーを先に登録(UIで作成+ログイン)
- const memberContext = await browser.newContext();
+ const memberContext = await browser.newContext({
+ storageState: 'tests/e2e/locale-ja.json',
+ });
const memberPage = await memberContext.newPage();
await registerAndLogin(memberPage, memberEmail, 'Member');
// オーナー登録+プロジェクト作成
- const ownerContext = await browser.newContext();
+ const ownerContext = await browser.newContext({
+ storageState: 'tests/e2e/locale-ja.json',
+ });
const ownerPage = await ownerContext.newPage();
await registerAndLogin(ownerPage, ownerEmail, 'Owner');
await ownerPage.getByLabel('プロジェクト名').fill(unique('Proj'));
diff --git a/tests/e2e/locale-ja.json b/tests/e2e/locale-ja.json
new file mode 100644
index 0000000..36d70d7
--- /dev/null
+++ b/tests/e2e/locale-ja.json
@@ -0,0 +1,15 @@
+{
+ "cookies": [
+ {
+ "name": "locale",
+ "value": "ja",
+ "domain": "localhost",
+ "path": "/",
+ "expires": -1,
+ "httpOnly": false,
+ "secure": false,
+ "sameSite": "Lax"
+ }
+ ],
+ "origins": []
+}
diff --git a/tests/e2e/theme-i18n.spec.ts b/tests/e2e/theme-i18n.spec.ts
new file mode 100644
index 0000000..afd0610
--- /dev/null
+++ b/tests/e2e/theme-i18n.spec.ts
@@ -0,0 +1,75 @@
+import { test, expect, type Page } from '@playwright/test';
+
+function unique(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
+}
+
+async function setupOwner(page: Page): Promise {
+ const email = unique('owner') + '@example.com';
+ await page.goto('/login');
+ await page.getByRole('button', { name: '新規登録はこちら' }).click();
+ await page.getByLabel('表示名').fill('Owner');
+ await page.getByLabel('メールアドレス').fill(email);
+ await page.getByLabel('パスワード').fill('password123');
+ await page.getByRole('button', { name: '登録する' }).click();
+ await expect(page).toHaveURL(/\/dashboard/);
+ await page.getByLabel('プロジェクト名').fill(unique('Proj'));
+ await page.getByRole('button', { name: '新規プロジェクト' }).click();
+ await expect(page).toHaveURL(/\/projects\/\d+$/);
+ return Number(page.url().match(/\/projects\/(\d+)/)![1]);
+}
+
+test.describe('theme & i18n', () => {
+ test('a fresh visitor gets English locale and dark theme by default', async ({
+ browser,
+ }) => {
+ // storageState を持たない = 初回訪問者(既定 en / dark)
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ await page.goto('/login');
+ await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
+ const className = await page.evaluate(
+ () => document.documentElement.className
+ );
+ expect(className).toContain('dark');
+ await ctx.close();
+ });
+
+ test('theme toggle switches dark<->light and persists', async ({ page }) => {
+ await setupOwner(page);
+ // 既定は dark
+ expect(
+ await page.evaluate(() => document.documentElement.className)
+ ).toContain('dark');
+
+ await page.getByTestId('theme-toggle').click();
+ expect(
+ await page.evaluate(() => document.documentElement.className)
+ ).not.toContain('dark');
+
+ // リロード後もライトが維持される(Cookie永続化)
+ await page.reload();
+ expect(
+ await page.evaluate(() => document.documentElement.className)
+ ).not.toContain('dark');
+ });
+
+ test('language switch ja -> en updates chrome and persists', async ({
+ page,
+ }) => {
+ const projectId = await setupOwner(page);
+ await page.goto(`/projects/${projectId}`);
+ // E2E既定は ja → ヘッダーに ダッシュボード
+ await expect(
+ page.getByRole('link', { name: 'ダッシュボード' })
+ ).toBeVisible();
+
+ // プロフィールで en に切替
+ await page.goto('/profile');
+ await page.getByTestId('profile-locale-select').selectOption('en');
+
+ // クロムが英語に切替
+ await page.goto(`/projects/${projectId}`);
+ await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
+ });
+});
diff --git a/tests/unit/lib/db/migrator.test.ts b/tests/unit/lib/db/migrator.test.ts
index c7e30f1..b7e42fd 100644
--- a/tests/unit/lib/db/migrator.test.ts
+++ b/tests/unit/lib/db/migrator.test.ts
@@ -183,6 +183,7 @@ describe('Migrator', () => {
'001_initial.sql',
'002_attachments.sql',
'003_todo_tags.sql',
+ '004_user_prefs.sql',
]);
});
});
diff --git a/tests/unit/lib/i18n/server.test.ts b/tests/unit/lib/i18n/server.test.ts
new file mode 100644
index 0000000..3472fe1
--- /dev/null
+++ b/tests/unit/lib/i18n/server.test.ts
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(),
+}));
+
+import { cookies } from 'next/headers';
+import { getLocale, getTheme, translate } from '@/lib/i18n/server';
+
+const mockedCookies = vi.mocked(cookies);
+
+function mockCookie(value: string | undefined) {
+ mockedCookies.mockResolvedValue({
+ get: () => (value === undefined ? undefined : { value }),
+ } as never);
+}
+
+function mockCookieByName(map: Record) {
+ mockedCookies.mockResolvedValue({
+ get: (name: string) =>
+ map[name] === undefined ? undefined : { value: map[name] },
+ } as never);
+}
+
+describe('i18n/server', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ describe('getLocale', () => {
+ it('defaults to en when the cookie is absent', async () => {
+ mockCookie(undefined);
+ expect(await getLocale()).toBe('en');
+ });
+ it('returns ja when the cookie is ja', async () => {
+ mockCookieByName({ locale: 'ja' });
+ expect(await getLocale()).toBe('ja');
+ });
+ it('falls back to en for an unknown value', async () => {
+ mockCookieByName({ locale: 'fr' });
+ expect(await getLocale()).toBe('en');
+ });
+ });
+
+ describe('getTheme', () => {
+ it('defaults to dark when the cookie is absent', async () => {
+ mockCookie(undefined);
+ expect(await getTheme()).toBe('dark');
+ });
+ it('returns light when the cookie is light', async () => {
+ mockCookieByName({ theme: 'light' });
+ expect(await getTheme()).toBe('light');
+ });
+ it('falls back to dark for an unknown value', async () => {
+ mockCookieByName({ theme: 'pink' });
+ expect(await getTheme()).toBe('dark');
+ });
+ });
+
+ describe('translate', () => {
+ it('returns the localized string for each locale', () => {
+ expect(translate('nav.board', 'en')).toBe('Board');
+ expect(translate('nav.board', 'ja')).toBe('掲示板');
+ });
+ it('falls back to the english string then the key for unknown keys', () => {
+ // @ts-expect-error -- nonexistent key on purpose
+ expect(translate('nonexistent.key', 'en')).toBe('nonexistent.key');
+ });
+ });
+});
diff --git a/tests/unit/repositories/UserRepository.test.ts b/tests/unit/repositories/UserRepository.test.ts
index a18c83c..fa1fb15 100644
--- a/tests/unit/repositories/UserRepository.test.ts
+++ b/tests/unit/repositories/UserRepository.test.ts
@@ -123,5 +123,29 @@ describe('UserRepository', () => {
// UPDATE affects 0 rows; findById returns null
expect(result).toBeNull();
});
+
+ it('defaults theme to dark and locale to en on create', () => {
+ const created = repo.create({
+ name: 'Pref',
+ email: 'p@example.com',
+ passwordHash: 'h',
+ });
+ expect(created.theme).toBe('dark');
+ expect(created.locale).toBe('en');
+ });
+
+ it('updates theme and locale', () => {
+ const created = repo.create({
+ name: 'Pref',
+ email: 'p@example.com',
+ passwordHash: 'h',
+ });
+ const updated = repo.update(created.id, {
+ theme: 'light',
+ locale: 'ja',
+ });
+ expect(updated?.theme).toBe('light');
+ expect(updated?.locale).toBe('ja');
+ });
});
});
diff --git a/tests/unit/services/AuthService.test.ts b/tests/unit/services/AuthService.test.ts
index 9dd28d9..5794f9b 100644
--- a/tests/unit/services/AuthService.test.ts
+++ b/tests/unit/services/AuthService.test.ts
@@ -3,7 +3,12 @@ import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { AuthService } from '@/services/AuthService';
-import { ConflictError, UnauthorizedError, NotFoundError } from '@/lib/errors';
+import {
+ ConflictError,
+ UnauthorizedError,
+ NotFoundError,
+ ValidationError,
+} from '@/lib/errors';
import { verifySessionToken } from '@/lib/auth/session';
import bcrypt from 'bcrypt';
@@ -162,6 +167,38 @@ describe('AuthService', () => {
NotFoundError
);
});
+
+ it('updates theme and locale', () => {
+ const created = authService.register({
+ name: 'Alice',
+ email: 'alice@example.com',
+ password: 'password123',
+ });
+ const updated = authService.updateProfile(created.id, {
+ theme: 'light',
+ locale: 'ja',
+ });
+ expect(updated.theme).toBe('light');
+ expect(updated.locale).toBe('ja');
+ });
+
+ it('rejects an invalid theme or locale', () => {
+ const created = authService.register({
+ name: 'Alice',
+ email: 'alice@example.com',
+ password: 'password123',
+ });
+ expect(() =>
+ authService.updateProfile(created.id, {
+ theme: 'pink' as never,
+ })
+ ).toThrow(ValidationError);
+ expect(() =>
+ authService.updateProfile(created.id, {
+ locale: 'fr' as never,
+ })
+ ).toThrow(ValidationError);
+ });
});
describe('createWithRole', () => {