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
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ http://localhost:3000 を開き、サインアップから会社(組織)を

| 機能 | 環境変数 | 既定 | 補足 |
|---|---|---|---|
| 言語 | `VITE_DEFAULT_LOCALE` | `en` | `en` または `ja`。デプロイ全体の既定言語。ユーザーは画面の切替UI(サイドバーのユーザーメニュー/アカウント設定)で切り替えでき、その選択は Cookie に保存されこの既定を上書きします。 |
| 言語 | `VITE_DEFAULT_LOCALE` | `en` | `en` または `ja`。言語は Cookie(画面の切替UI=サイドバーのユーザーメニュー/アカウント設定での選択)→ ブラウザの `Accept-Language` → この既定値、の順で決定されます。したがってこの値が使われるのは、ブラウザが対応言語を要求してこなかった場合のみです。 |
| タイムゾーン | `VITE_DEFAULT_TIMEZONE` | *(ランタイムのTZ)* | `Asia/Tokyo` のような IANA 名。「今日」やシフト時刻の判定に使います。未設定ならランタイムのTZ(SSRはサーバーの `TZ`、クライアントはブラウザのTZ)にフォールバックします。 |

設定は `apps/web/.env`(`apps/web/.env.example` をコピー)で行います。ユーザー単位のタイムゾーン設定はなく、1デプロイ=単一拠点/単一TZ を前提としています。複数タイムゾーンにまたがる運用には拠点(site)単位のTZ設定が必要です(未実装)。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ The web app (`apps/web`) reads two deploy-time defaults from its environment (Vi

| Concern | Env var | Default | Notes |
|---|---|---|---|
| Language | `VITE_DEFAULT_LOCALE` | `en` | `en` or `ja`. The deploy-wide default. Users can switch language from the in-app switcher (sidebar user menu / account settings); their choice is remembered in a cookie and overrides this default. |
| Language | `VITE_DEFAULT_LOCALE` | `en` | `en` or `ja`. The locale is resolved in this order: the cookie set by the in-app switcher (sidebar user menu / account settings) → the browser's `Accept-Language` → this default. So this value only applies when the browser asks for no supported language. |
| Timezone | `VITE_DEFAULT_TIMEZONE` | *(runtime TZ)* | An IANA name such as `Asia/Tokyo`. Used to decide "today" and shift times. If unset, it falls back to the runtime timezone (the server's `TZ` on SSR, the browser's timezone on the client). |

Set them in `apps/web/.env` (copied from `apps/web/.env.example`). There is no per-user timezone setting: the app assumes a single site/timezone per deployment. Running one instance across multiple timezones would need a site-level timezone setting (not implemented).
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/i18n/accept-language.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { pickLocaleFromAcceptLanguage } from "./accept-language";

describe("pickLocaleFromAcceptLanguage", () => {
it("picks a plain supported tag", () => {
expect(pickLocaleFromAcceptLanguage("ja")).toBe("ja");
});

it("normalizes a region subtag", () => {
expect(pickLocaleFromAcceptLanguage("ja-JP,ja;q=0.9,en;q=0.8")).toBe("ja");
expect(pickLocaleFromAcceptLanguage("en-US,en;q=0.9")).toBe("en");
});

it("prefers the highest q value over header order", () => {
expect(pickLocaleFromAcceptLanguage("ja;q=0.7,en;q=0.9")).toBe("en");
});

it("falls back to header order when q values tie", () => {
expect(pickLocaleFromAcceptLanguage("en,ja")).toBe("en");
expect(pickLocaleFromAcceptLanguage("ja,en")).toBe("ja");
});

it("skips unsupported languages", () => {
expect(pickLocaleFromAcceptLanguage("fr-FR,fr;q=0.9,ja;q=0.5")).toBe("ja");
});

it("ignores languages explicitly refused with q=0", () => {
expect(pickLocaleFromAcceptLanguage("ja;q=0,en;q=0.5")).toBe("en");
});

it("returns undefined when no supported language is requested", () => {
expect(pickLocaleFromAcceptLanguage("fr,de;q=0.9")).toBeUndefined();
expect(pickLocaleFromAcceptLanguage("*")).toBeUndefined();
expect(pickLocaleFromAcceptLanguage("")).toBeUndefined();
expect(pickLocaleFromAcceptLanguage(null)).toBeUndefined();
});
});
34 changes: 34 additions & 0 deletions apps/web/src/i18n/accept-language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { type Locale, SUPPORTED_LOCALES } from "./config";

// Pick the most preferred supported locale from an Accept-Language header
// ("ja,en-US;q=0.9,en;q=0.8"). Returns undefined when the header requests no supported language,
// so the caller can fall back to the deploy default.
export function pickLocaleFromAcceptLanguage(
header: string | null | undefined,
): Locale | undefined {
if (!header) return undefined;

const ranked = header
.split(",")
.map((part, index) => {
const [tag, ...params] = part.trim().split(";");
const q = params
.map((p) => p.trim())
.find((p) => p.toLowerCase().startsWith("q="))
?.slice(2);
const quality = q === undefined ? 1 : Number.parseFloat(q);
return {
base: tag.trim().toLowerCase().split("-")[0],
quality: Number.isNaN(quality) ? 0 : quality,
// Keeps the sort stable for equal q values, where header order is the preference.
index,
};
})
.filter((entry) => entry.quality > 0)
.sort((a, b) => b.quality - a.quality || a.index - b.index);

return ranked.find(
(entry): entry is (typeof ranked)[number] & { base: Locale } =>
(SUPPORTED_LOCALES as readonly string[]).includes(entry.base),
)?.base;
}
8 changes: 5 additions & 3 deletions apps/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const LOCALE_COOKIE = "i18next";

// The deploy default language is set via the VITE_DEFAULT_LOCALE env var (unset = "en").
// Users can override it with the language switcher, and their choice is saved to a cookie.
// It only applies when the browser asks for no supported language; a user's own choice from the
// language switcher is saved to a cookie and wins over both.
export const DEFAULT_LOCALE: Locale = toLocale(
import.meta.env.VITE_DEFAULT_LOCALE,
"en",
Expand Down Expand Up @@ -43,8 +44,9 @@ if (!i18n.isInitialized) {
supportedLngs: SUPPORTED_LOCALES as unknown as string[],
defaultNS: "common",
interpolation: { escapeValue: false },
// The default is the env var (fallbackLng). Once the user switches, the cookie takes priority.
// No browser-language auto-detection; the default is made deterministic via the env var.
// The locale is decided on the server (see i18n/server.ts: cookie -> Accept-Language -> env
// default) and rendered as <html lang>, so htmlTag is what the client reads when there's no
// cookie yet. Don't add "navigator" here: it would let the client disagree with the server.
detection: {
order: ["cookie", "htmlTag"],
caches: ["cookie"],
Expand Down
17 changes: 11 additions & 6 deletions apps/web/src/i18n/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { pickLocaleFromAcceptLanguage } from "./accept-language";
import {
DEFAULT_LOCALE,
LOCALE_COOKIE,
Expand All @@ -15,14 +16,18 @@ function readCookie(header: string, name: string): string | undefined {
return undefined;
}

// Resolve the initial SSR locale in order: cookie (user's switch) -> the env-var default.
// This matches the client's LanguageDetector result and prevents hydration mismatches.
// Resolve the initial SSR locale in order: cookie (the user's switch) -> Accept-Language
// (the browser's language) -> the env-var default.
// The result is rendered as <html lang> in __root, and the client's LanguageDetector reads it back
// via htmlTag, so both sides agree and hydration doesn't mismatch.
export const detectLocale = createServerFn({ method: "GET" }).handler(
async (): Promise<Locale> => {
const cookie = readCookie(
getRequest().headers.get("cookie") ?? "",
LOCALE_COOKIE,
const headers = getRequest().headers;
const cookie = readCookie(headers.get("cookie") ?? "", LOCALE_COOKIE);
if (cookie) return normalizeLocale(cookie);
return (
pickLocaleFromAcceptLanguage(headers.get("accept-language")) ??
DEFAULT_LOCALE
);
return cookie ? normalizeLocale(cookie) : DEFAULT_LOCALE;
},
);
Loading