feat: 다국어 지원 (프랑스, 독일) 및 항해 화면 크루 포함

This commit is contained in:
2026-02-16 23:21:31 +09:00
parent 2ec2ba4b3a
commit f008af0d9b
11 changed files with 773 additions and 48 deletions

View File

@@ -9,7 +9,7 @@ import { getHistory } from '@/shared/lib/store';
import { Voyage } from '@/shared/types';
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { t } = useI18n();
const { t, locale } = useI18n();
// Next.js 15: params is a Promise
const resolvedParams = use(params);
const [log, setLog] = useState<Voyage | null>(null);
@@ -27,6 +27,13 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
const route = findRouteById(log.routeId);
const routeName = route ? t(route.nameKey, undefined, log.routeName) : log.routeName;
const statusLabel = t(VOYAGE_STATUS_LABEL_KEYS[log.status], undefined, t('status.in_progress'));
const missionLabel = (() => {
const trimmed = log.missionText.trim();
if (!trimmed || trimmed === '미입력') {
return t('log.mission.empty');
}
return log.missionText;
})();
return (
<div className="flex flex-col flex-1 p-6 space-y-8 animate-in fade-in duration-300">
@@ -39,9 +46,9 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
&larr; Lobby
</Link>
</div>
<h1 className="text-2xl font-bold text-white">{log.missionText}</h1>
<h1 className="text-2xl font-bold text-white">{missionLabel}</h1>
<div className="flex gap-3 mt-2 text-sm text-slate-500">
<span>{new Date(log.startedAt).toLocaleString()}</span>
<span>{new Date(log.startedAt).toLocaleString(locale)}</span>
<span></span>
<span>{routeName} ({log.durationMinutes}{t('common.minuteShort')})</span>
</div>
@@ -53,16 +60,11 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
<p className="text-lg text-indigo-100">{statusLabel}</p>
</div>
<div className="grid gap-6 sm:grid-cols-2">
<div>
<div>
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.progressTitle')}</h3>
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.debriefProgress || '-'}</p>
</div>
<div>
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.nextActionTitle')}</h3>
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.nextAction || '-'}</p>
</div>
</div>
{log.notes && (

View File

@@ -9,7 +9,7 @@ import { getHistory } from '@/shared/lib/store';
import { Voyage, VoyageStatus } from '@/shared/types';
export default function LogListPage() {
const { t } = useI18n();
const { t, locale } = useI18n();
const [logs, setLogs] = useState<Voyage[]>([]);
useEffect(() => {
@@ -20,6 +20,14 @@ export default function LogListPage() {
return t(VOYAGE_STATUS_LABEL_KEYS[s], undefined, t('status.in_progress'));
};
const getMissionLabel = (missionText: string) => {
const trimmed = missionText.trim();
if (!trimmed || trimmed === '미입력') {
return t('log.mission.empty');
}
return missionText;
};
return (
<div className="flex flex-col flex-1 p-6">
<div className="mb-6 flex items-center justify-between gap-3">
@@ -55,14 +63,14 @@ export default function LogListPage() {
>
<div className="flex justify-between items-start mb-1">
<span className="text-xs text-slate-500">
{new Date(log.startedAt).toLocaleDateString()}
{new Date(log.startedAt).toLocaleDateString(locale)}
</span>
<span className="text-xs font-medium text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded">
{getStatusLabel(log.status)}
</span>
</div>
<h3 className="font-semibold text-slate-200 truncate mb-1">
{log.missionText}
{getMissionLabel(log.missionText)}
</h3>
<p className="text-xs text-slate-500">
{routeName} · {log.durationMinutes}

View File

@@ -16,9 +16,7 @@ export const startVoyage = ({
routeName: string;
}) => {
const missionText = mission.trim();
if (!missionText) {
return false;
}
const normalizedMissionText = missionText || '미입력';
const newVoyage: Voyage = {
id: createVoyageId(),
@@ -27,7 +25,7 @@ export const startVoyage = ({
durationMinutes: route.durationMinutes,
startedAt: Date.now(),
status: 'in_progress',
missionText,
missionText: normalizedMissionText,
};
saveCurrentVoyage(newVoyage);

View File

@@ -3,6 +3,24 @@
import { FormEvent, useState } from 'react';
import { useI18n } from '@/features/i18n/model/useI18n';
const OPTIONAL_PREFIX_PATTERN = /^(?:선택|optional|任意|facultatif)\s*[:]\s*/i;
const EXAMPLE_PREFIX_PATTERN =
/^(?:예|例|e\.?\s?g\.?|eg\.?|example|exemple|beispiel|p\.?\s?ex\.?|z\.?\s?b\.?)\s*[:)\.]\s*/i;
const buildMissionPlaceholder = (raw: string, optionalLabel: string) => {
const sanitized = raw
.trim()
.replace(OPTIONAL_PREFIX_PATTERN, '')
.replace(EXAMPLE_PREFIX_PATTERN, '')
.trim();
if (!sanitized) return '';
const normalizedOptionalLabel = optionalLabel.trim();
if (!normalizedOptionalLabel) return sanitized;
return `${sanitized} (${normalizedOptionalLabel})`;
};
export function BoardingMissionForm({
onDock,
onCancel,
@@ -17,11 +35,13 @@ export function BoardingMissionForm({
const { t } = useI18n();
const [mission, setMission] = useState('');
const trimmedMission = mission.trim();
const canSubmit = Boolean(trimmedMission);
const missionPlaceholder = buildMissionPlaceholder(
t('boarding.missionPlaceholder'),
t('boarding.optionalLabel'),
);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canSubmit) return;
onDock(trimmedMission);
};
@@ -38,7 +58,7 @@ export function BoardingMissionForm({
type="text"
value={mission}
onChange={(event) => setMission(event.target.value)}
placeholder={t('boarding.missionPlaceholder')}
placeholder={missionPlaceholder}
className="w-full border-b-2 border-slate-700 bg-slate-900/50 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600 focus:border-indigo-500"
autoFocus={autoFocus}
/>
@@ -56,8 +76,7 @@ export function BoardingMissionForm({
)}
<button
type="submit"
disabled={!canSubmit}
className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
>
{t('boarding.submit')}
</button>

View File

@@ -9,10 +9,11 @@ export const getFlightStarCount = (width: number, height: number) => {
? FLIGHT_STARFIELD_TUNING.starCount.mobile.min
: FLIGHT_STARFIELD_TUNING.starCount.desktop.min;
const max = isMobile
? FLIGHT_STARFIELD_TUNING.starCount.mobile.max
: FLIGHT_STARFIELD_TUNING.starCount.desktop.max;
? FLIGHT_STARFIELD_TUNING.maxStars.mobile
: FLIGHT_STARFIELD_TUNING.maxStars.desktop;
const byArea = Math.round(
(width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor,
((width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor) *
FLIGHT_STARFIELD_TUNING.densityMultiplier,
);
return clamp(byArea, min, max);

View File

@@ -223,7 +223,7 @@ export function FlightStarfieldCanvas({
const from = projectFlightStar(star, vp, star.z);
if (moveStars) {
star.z -= star.speed;
star.z -= star.speed * FLIGHT_STARFIELD_TUNING.speedScale;
}
const to = projectFlightStar(star, vp, star.z);

View File

@@ -0,0 +1,62 @@
export const FEATURE_FLAGS = {
crewPresencePanelEnabled: false,
crewPresenceNotificationsEnabled: false,
} as const;
export const CREW_PRESENCE_ROLLOUT_GUARDRAILS = {
forcePanelOff: false,
forceNotificationsOff: false,
reduceRenderCap: false,
relaxUpdateInterval: false,
reducedRenderCap: 30,
defaultRenderCap: 50,
defaultUpdateIntervalMs: 30_000,
relaxedUpdateIntervalMs: 60_000,
} as const;
export const CREW_PRESENCE_ROLLBACK_TOGGLE_BINDINGS = {
forcePanelOff: "rollback.panel_off",
forceNotificationsOff: "rollback.notifications_off",
reduceRenderCap: "rollback.cap_reduce",
relaxUpdateInterval: "rollback.interval_relax",
} as const;
export type CrewPresenceRuntimeConfig = {
panelEnabled: boolean;
notificationsEnabled: boolean;
renderCap: number;
updateIntervalMs: number;
};
export const resolveCrewPresenceRuntimeConfig = (): CrewPresenceRuntimeConfig => {
const panelEnabled =
FEATURE_FLAGS.crewPresencePanelEnabled &&
!CREW_PRESENCE_ROLLOUT_GUARDRAILS.forcePanelOff;
const notificationsEnabled =
FEATURE_FLAGS.crewPresenceNotificationsEnabled &&
!CREW_PRESENCE_ROLLOUT_GUARDRAILS.forceNotificationsOff;
const renderCap = CREW_PRESENCE_ROLLOUT_GUARDRAILS.reduceRenderCap
? CREW_PRESENCE_ROLLOUT_GUARDRAILS.reducedRenderCap
: CREW_PRESENCE_ROLLOUT_GUARDRAILS.defaultRenderCap;
const updateIntervalMs = CREW_PRESENCE_ROLLOUT_GUARDRAILS.relaxUpdateInterval
? CREW_PRESENCE_ROLLOUT_GUARDRAILS.relaxedUpdateIntervalMs
: CREW_PRESENCE_ROLLOUT_GUARDRAILS.defaultUpdateIntervalMs;
return {
panelEnabled,
notificationsEnabled,
renderCap,
updateIntervalMs,
};
};
export const isCrewPresenceRuntimeConfigSafe = (
config: CrewPresenceRuntimeConfig,
): boolean => config.renderCap > 0 && config.updateIntervalMs >= 1_000;
export const CREW_PRESENCE_QA_GUARDRAIL_CHECKS = [
"panel_toggle_guard",
"notifications_toggle_guard",
"render_cap_guard",
"update_interval_guard",
] as const;

View File

@@ -1,6 +1,6 @@
import { VoyageStatus } from "@/shared/types";
export const SUPPORTED_LOCALES = ["ko", "en", "ja"] as const;
export const SUPPORTED_LOCALES = ["ko", "en", "ja", "fr", "de"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
@@ -12,6 +12,8 @@ export const LOCALE_LABELS: Record<Locale, string> = {
ko: "한국어",
en: "English",
ja: "日本語",
fr: "Français",
de: "Deutsch",
};
const enMessages = {
@@ -39,7 +41,8 @@ const enMessages = {
"boarding.check": "Boarding Check",
"boarding.routeBoarding": "{routeName} Route Boarding",
"boarding.missionLabel": "Core mission for this voyage",
"boarding.missionPlaceholder": "e.g. Finish 3 intro paragraphs",
"boarding.missionPlaceholder": "Optional: e.g. Finish 3 intro paragraphs",
"boarding.optionalLabel": "Optional",
"boarding.cancel": "Cancel",
"boarding.submit": "Dock Complete (Launch)",
"flight.badge.paused": "Paused",
@@ -47,6 +50,23 @@ const enMessages = {
"flight.missionLabel": "Voyage Mission",
"flight.pause": "Pause",
"flight.resume": "Resume",
"flight.crewPresence.header": "Sailing together now: {count}",
"flight.crewPresence.expand": "Expand",
"flight.crewPresence.collapse": "Collapse",
"flight.crewPresence.listLimit": "Showing up to {limit} crew",
"flight.crewPresence.overflow": "+{count} more",
"flight.crewPresence.status.online": "Online",
"flight.crewPresence.status.idle": "Idle",
"flight.crewPresence.status.offline": "Offline",
"flight.crewPresence.activityHint": "Based on recent activity",
"flight.crewPresence.elapsedMinutes": "{minutes}m ago",
"flight.crewPresence.goal.private": "Goal private",
"flight.crewPresence.goal.unset": "Goal not set",
"flight.crewPresence.notifications.policyOn": "Alerts ON",
"flight.crewPresence.notifications.filterHint":
"Showing friend/my-crew events only",
"flight.crewPresence.notifications.target.friend": "Friend",
"flight.crewPresence.notifications.target.myCrew": "My crew",
"flight.finish.debrief": "Arrived (Debrief)",
"flight.finish.end": "End Voyage",
"flight.debrief.title": "Wrap up this voyage",
@@ -65,6 +85,7 @@ const enMessages = {
"e.g. What worked, what did not, and what I learned",
"debrief.save": "Save to Logbook",
"log.title": "My Voyage Logs",
"log.mission.empty": "No mission entered",
"log.empty": "No voyages recorded yet.",
"log.firstVoyage": "Start your first voyage",
"log.detail.back": "Back to list",
@@ -112,7 +133,8 @@ const koMessages = {
"boarding.check": "Boarding Check",
"boarding.routeBoarding": "{routeName} 항로 탑승",
"boarding.missionLabel": "이번 항해의 핵심 목표",
"boarding.missionPlaceholder": "예: 서론 3문단 완성하기",
"boarding.missionPlaceholder": "선택: 예) 서론 3문단 완성하기",
"boarding.optionalLabel": "선택",
"boarding.cancel": "취소",
"boarding.submit": "도킹 완료 (출항)",
"flight.badge.paused": "일시정지",
@@ -120,6 +142,23 @@ const koMessages = {
"flight.missionLabel": "이번 항해 목표",
"flight.pause": "일시정지",
"flight.resume": "다시 시작",
"flight.crewPresence.header": "현재 함께 항해 중 {count}명",
"flight.crewPresence.expand": "펼치기",
"flight.crewPresence.collapse": "접기",
"flight.crewPresence.listLimit": "최대 {limit}명까지 표시",
"flight.crewPresence.overflow": "+{count}명",
"flight.crewPresence.status.online": "온라인",
"flight.crewPresence.status.idle": "자리비움",
"flight.crewPresence.status.offline": "오프라인",
"flight.crewPresence.activityHint": "최근 활동 기반",
"flight.crewPresence.elapsedMinutes": "{minutes}분 전",
"flight.crewPresence.goal.private": "목표 비공개",
"flight.crewPresence.goal.unset": "목표 미설정",
"flight.crewPresence.notifications.policyOn": "알림 ON",
"flight.crewPresence.notifications.filterHint":
"friend/my-crew 이벤트만 표시",
"flight.crewPresence.notifications.target.friend": "친구",
"flight.crewPresence.notifications.target.myCrew": "내 크루",
"flight.finish.debrief": "도착 (회고)",
"flight.finish.end": "항해 종료",
"flight.debrief.title": "이번 항해를 정리하세요",
@@ -139,6 +178,7 @@ const koMessages = {
"예: 잘한 점, 아쉬운 점, 느낀 점을 짧게 남겨보세요",
"debrief.save": "항해일지 저장",
"log.title": "나의 항해 기록",
"log.mission.empty": "미입력",
"log.empty": "아직 기록된 항해가 없습니다.",
"log.firstVoyage": "첫 항해 떠나기",
"log.detail.back": "목록으로",
@@ -183,7 +223,8 @@ const jaMessages = {
"boarding.check": "搭乗チェック",
"boarding.routeBoarding": "{routeName} 航路に搭乗",
"boarding.missionLabel": "今回の航海のコア目標",
"boarding.missionPlaceholder": "例: 導入の3段落を完成する",
"boarding.missionPlaceholder": "任意: 例) 導入の3段落を完成する",
"boarding.optionalLabel": "任意",
"boarding.cancel": "キャンセル",
"boarding.submit": "ドッキング完了(出航)",
"flight.badge.paused": "一時停止",
@@ -191,6 +232,23 @@ const jaMessages = {
"flight.missionLabel": "今回の航海目標",
"flight.pause": "一時停止",
"flight.resume": "再開",
"flight.crewPresence.header": "現在一緒に航海中 {count}人",
"flight.crewPresence.expand": "展開",
"flight.crewPresence.collapse": "折りたたむ",
"flight.crewPresence.listLimit": "最大{limit}人まで表示",
"flight.crewPresence.overflow": "+{count}人",
"flight.crewPresence.status.online": "オンライン",
"flight.crewPresence.status.idle": "離席",
"flight.crewPresence.status.offline": "オフライン",
"flight.crewPresence.activityHint": "最近のアクティビティに基づく",
"flight.crewPresence.elapsedMinutes": "{minutes}分前",
"flight.crewPresence.goal.private": "目標は非公開",
"flight.crewPresence.goal.unset": "目標未設定",
"flight.crewPresence.notifications.policyOn": "通知 ON",
"flight.crewPresence.notifications.filterHint":
"friend/my-crew イベントのみ表示",
"flight.crewPresence.notifications.target.friend": "フレンド",
"flight.crewPresence.notifications.target.myCrew": "マイクルー",
"flight.finish.debrief": "到着(振り返り)",
"flight.finish.end": "航海終了",
"flight.debrief.title": "今回の航海を整理しましょう",
@@ -210,6 +268,7 @@ const jaMessages = {
"例: 良かった点・難しかった点・気づきを短く残してください",
"debrief.save": "航海ログに保存",
"log.title": "私の航海記録",
"log.mission.empty": "未入力",
"log.empty": "まだ記録された航海がありません。",
"log.firstVoyage": "最初の航海を始める",
"log.detail.back": "一覧へ戻る",
@@ -230,11 +289,207 @@ const jaMessages = {
} satisfies I18nMessages;
const normalizedEnMessages: I18nMessages = enMessages;
const frMessages: I18nMessages = {
"layout.nav.log": "Journal de bord",
"layout.nav.settings": "Parametres",
"layout.nav.language": "Langue",
"common.loading": "Chargement...",
"common.minuteShort": "min",
"routes.station.name": "Station spatiale",
"routes.station.tag": "Attente/Flexible",
"routes.station.description":
"Une zone sure ou vous pouvez rester sans limite de temps",
"routes.orion.name": "Orion",
"routes.orion.tag": "Travail profond",
"routes.orion.description": "Voyage de concentration de 60 minutes",
"routes.gemini.name": "Gemini",
"routes.gemini.tag": "Sprint court",
"routes.gemini.description": "Voyage de concentration de 30 minutes",
"lobby.title": "Vers quelle constellation voulez-vous naviguer ?",
"lobby.subtitle": "Choisissez une orbite qui favorise votre concentration.",
"lobby.cta.station": "Entrer dans la station (Attente)",
"lobby.cta.launch": "Decoller maintenant",
"lobby.modal.boardingCheck": "Verification d'embarquement",
"lobby.modal.routeBoarding": "Embarquement route {routeName}",
"lobby.modal.description":
"Definissez votre mission avant de commencer ce voyage.",
"boarding.check": "Verification d'embarquement",
"boarding.routeBoarding": "Embarquement route {routeName}",
"boarding.missionLabel": "Mission principale pour ce voyage",
"boarding.missionPlaceholder":
"Facultatif: terminer 3 paragraphes d'introduction",
"boarding.optionalLabel": "Facultatif",
"boarding.cancel": "Annuler",
"boarding.submit": "Amarrage termine (Decoller)",
"flight.badge.paused": "En pause",
"flight.badge.cruising": "En croisiere",
"flight.missionLabel": "Mission du voyage",
"flight.pause": "Pause",
"flight.resume": "Reprendre",
"flight.crewPresence.header": "En navigation ensemble actuellement : {count}",
"flight.crewPresence.expand": "Afficher",
"flight.crewPresence.collapse": "Reduire",
"flight.crewPresence.listLimit": "Afficher jusqu'a {limit} membres",
"flight.crewPresence.overflow": "+{count} personnes",
"flight.crewPresence.status.online": "En ligne",
"flight.crewPresence.status.idle": "Inactif",
"flight.crewPresence.status.offline": "Hors ligne",
"flight.crewPresence.activityHint": "Base sur l'activite recente",
"flight.crewPresence.elapsedMinutes": "il y a {minutes} min",
"flight.crewPresence.goal.private": "Objectif prive",
"flight.crewPresence.goal.unset": "Objectif non defini",
"flight.crewPresence.notifications.policyOn": "Alertes ON",
"flight.crewPresence.notifications.filterHint":
"Afficher uniquement les evenements friend/my-crew",
"flight.crewPresence.notifications.target.friend": "Ami",
"flight.crewPresence.notifications.target.myCrew": "Mon crew",
"flight.finish.debrief": "Arrivee (Debrief)",
"flight.finish.end": "Terminer le voyage",
"flight.debrief.title": "Concluez ce voyage",
"flight.debrief.description":
"Ecrivez une courte note et enregistrez-la dans votre journal.",
"debrief.page.title": "Vous etes arrive en orbite en securite",
"debrief.page.description":
"Consignez brievement ce voyage puis terminez.",
"debrief.status.label": "Resultat du voyage",
"debrief.option.completed.label": "Mission accomplie",
"debrief.option.completed.desc":
"J'ai termine ce que j'avais prevu de faire.",
"debrief.option.partial.label": "Progression partielle",
"debrief.option.partial.desc":
"J'ai avance les points cles et garde les prochaines etapes.",
"debrief.option.reoriented.label": "Mission redefinie",
"debrief.option.reoriented.desc":
"J'ai reajuste le perimetre et les priorites pendant le travail.",
"debrief.reflection.label": "Reflexion apres ce voyage",
"debrief.reflection.placeholder":
"Exemple: ce qui a fonctionne, ce qui n'a pas marche, et ce que j'ai appris",
"debrief.save": "Enregistrer dans le journal",
"log.title": "Mes journaux de voyage",
"log.mission.empty": "Mission non renseignee",
"log.empty": "Aucun voyage enregistre pour le moment.",
"log.firstVoyage": "Commencer votre premier voyage",
"log.detail.back": "Retour a la liste",
"log.detail.loadingOrNotFound": "Chargement ou introuvable...",
"log.detail.statusTitle": "Statut du resultat",
"log.detail.progressTitle": "Ce que j'ai obtenu",
"log.detail.nextActionTitle": "Action suivante",
"log.detail.initialNoteTitle": "Note initiale",
"settings.title": "Parametres",
"settings.hideSeconds.title": "Masquer les secondes",
"settings.hideSeconds.description":
"Afficher uniquement les minutes sur le minuteur pour reduire la pression.",
"status.completed": "Termine",
"status.partial": "Partiel",
"status.reoriented": "Reoriente",
"status.aborted": "Interrompu tot",
"status.in_progress": "En cours",
};
const deMessages: I18nMessages = {
"layout.nav.log": "Logbuch",
"layout.nav.settings": "Einstellungen",
"layout.nav.language": "Sprache",
"common.loading": "Wird geladen...",
"common.minuteShort": "Min",
"routes.station.name": "Raumstation",
"routes.station.tag": "Warten/Flexibel",
"routes.station.description":
"Ein sicherer Bereich, in dem Sie ohne Zeitlimit bleiben konnen",
"routes.orion.name": "Orion",
"routes.orion.tag": "Tiefe Arbeit",
"routes.orion.description": "60-Minuten-Fokusreise",
"routes.gemini.name": "Gemini",
"routes.gemini.tag": "Kurzer Sprint",
"routes.gemini.description": "30-Minuten-Fokusreise",
"lobby.title": "Zu welcher Konstellation mochten Sie reisen?",
"lobby.subtitle":
"Wahlen Sie eine Umlaufbahn, die Ihre Konzentration unterstutzt.",
"lobby.cta.station": "Station betreten (Warten)",
"lobby.cta.launch": "Jetzt starten",
"lobby.modal.boardingCheck": "Boarding-Check",
"lobby.modal.routeBoarding": "Boarding fur Route {routeName}",
"lobby.modal.description":
"Legen Sie Ihre Mission fest, bevor diese Reise beginnt.",
"boarding.check": "Boarding-Check",
"boarding.routeBoarding": "Boarding fur Route {routeName}",
"boarding.missionLabel": "Kernmission fur diese Reise",
"boarding.missionPlaceholder":
"Optional: 3 Einleitungsabsatze fertigstellen",
"boarding.optionalLabel": "Optional",
"boarding.cancel": "Abbrechen",
"boarding.submit": "Andocken abgeschlossen (Start)",
"flight.badge.paused": "Pausiert",
"flight.badge.cruising": "Im Flug",
"flight.missionLabel": "Reisemission",
"flight.pause": "Pause",
"flight.resume": "Fortsetzen",
"flight.crewPresence.header": "Aktuell gemeinsam auf Reise: {count}",
"flight.crewPresence.expand": "Ausklappen",
"flight.crewPresence.collapse": "Einklappen",
"flight.crewPresence.listLimit": "Bis zu {limit} Crewmitglieder anzeigen",
"flight.crewPresence.overflow": "+{count} mehr",
"flight.crewPresence.status.online": "Online",
"flight.crewPresence.status.idle": "Inaktiv",
"flight.crewPresence.status.offline": "Offline",
"flight.crewPresence.activityHint": "Basierend auf letzter Aktivitat",
"flight.crewPresence.elapsedMinutes": "vor {minutes} Min",
"flight.crewPresence.goal.private": "Ziel privat",
"flight.crewPresence.goal.unset": "Ziel nicht festgelegt",
"flight.crewPresence.notifications.policyOn": "Hinweise ON",
"flight.crewPresence.notifications.filterHint":
"Nur friend/my-crew Ereignisse anzeigen",
"flight.crewPresence.notifications.target.friend": "Freund",
"flight.crewPresence.notifications.target.myCrew": "Meine Crew",
"flight.finish.debrief": "Ankunft (Debrief)",
"flight.finish.end": "Reise beenden",
"flight.debrief.title": "Diese Reise abschliessen",
"flight.debrief.description":
"Schreiben Sie eine kurze Notiz und speichern Sie sie im Logbuch.",
"debrief.page.title": "Sie haben die Umlaufbahn sicher erreicht",
"debrief.page.description":
"Dokumentieren Sie diese Reise kurz und schliessen Sie ab.",
"debrief.status.label": "Reiseergebnis",
"debrief.option.completed.label": "Mission abgeschlossen",
"debrief.option.completed.desc":
"Ich habe das erledigt, was ich mir vorgenommen hatte.",
"debrief.option.partial.label": "Teilweise Fortschritte",
"debrief.option.partial.desc":
"Ich habe wichtige Teile vorangebracht und nachste Schritte offen gelassen.",
"debrief.option.reoriented.label": "Mission neu ausgerichtet",
"debrief.option.reoriented.desc":
"Ich habe Umfang und Prioritaten wahrend der Arbeit neu festgelegt.",
"debrief.reflection.label": "Reflexion nach dieser Reise",
"debrief.reflection.placeholder":
"Beispiel: was gut lief, was nicht gut lief, und was ich gelernt habe",
"debrief.save": "Im Logbuch speichern",
"log.title": "Meine Reiseprotokolle",
"log.mission.empty": "Keine Mission eingetragen",
"log.empty": "Noch keine Reisen aufgezeichnet.",
"log.firstVoyage": "Ihre erste Reise starten",
"log.detail.back": "Zuruck zur Liste",
"log.detail.loadingOrNotFound": "Wird geladen oder nicht gefunden...",
"log.detail.statusTitle": "Ergebnisstatus",
"log.detail.progressTitle": "Was ich erreicht habe",
"log.detail.nextActionTitle": "Nachste Aktion",
"log.detail.initialNoteTitle": "Anfangsnotiz",
"settings.title": "Einstellungen",
"settings.hideSeconds.title": "Sekunden ausblenden",
"settings.hideSeconds.description":
"Nur Minuten auf dem Timer anzeigen, um Druck zu reduzieren.",
"status.completed": "Abgeschlossen",
"status.partial": "Teilweise",
"status.reoriented": "Neu ausgerichtet",
"status.aborted": "Fruh abgebrochen",
"status.in_progress": "In Bearbeitung",
};
export const I18N_MESSAGES: Record<Locale, I18nMessages> = {
ko: koMessages,
en: normalizedEnMessages,
ja: jaMessages,
fr: frMessages,
de: deMessages,
};
export type I18nKey = MessageKey;

View File

@@ -1,9 +1,15 @@
export const FLIGHT_STARFIELD_TUNING = {
mobileBreakpoint: 768,
densityDivisor: 42000,
densityMultiplier: 1.35,
speedScale: 0.3,
starCount: {
mobile: { min: 12, max: 30 },
desktop: { min: 18, max: 45 },
mobile: { min: 16, max: 48 },
desktop: { min: 24, max: 72 },
},
maxStars: {
mobile: 48,
desktop: 72,
},
vanishXJitter: { min: 10, max: 25 },
speedTiers: {

View File

@@ -0,0 +1,71 @@
export type CrewPresenceEventName =
| "panel_visible"
| "panel_collapsed"
| "presence_event_click";
export type CrewPresenceNotificationTarget = "friend" | "my-crew";
export type CrewPresenceEventPayload = {
decisionId: string;
routeId: string;
totalCount: number;
visibleCount: number;
renderCap?: number;
updateIntervalMs?: number;
collapsed?: boolean;
targetType?: CrewPresenceNotificationTarget;
};
type CrewPresenceDashboardBinding = {
metricKey: string;
chartKey: string;
};
export const CREW_PRESENCE_DASHBOARD_BINDINGS: Record<
CrewPresenceEventName,
CrewPresenceDashboardBinding
> = {
panel_visible: {
metricKey: "crew_presence.panel_visible.count",
chartKey: "crew_presence_panel_visible_trend",
},
panel_collapsed: {
metricKey: "crew_presence.panel_collapsed.count",
chartKey: "crew_presence_panel_collapsed_trend",
},
presence_event_click: {
metricKey: "crew_presence.presence_event_click.count",
chartKey: "crew_presence_event_click_target_split",
},
};
declare global {
interface Window {
__HUSHROOM_ANALYTICS__?: {
track: (eventName: string, payload: Record<string, unknown>) => void;
};
}
}
export const trackCrewPresenceEvent = (
eventName: CrewPresenceEventName,
payload: CrewPresenceEventPayload,
) => {
try {
if (typeof window === "undefined") return;
const dashboardBinding = CREW_PRESENCE_DASHBOARD_BINDINGS[eventName];
window.dispatchEvent(
new CustomEvent("hushroom:crew-presence-analytics", {
detail: { eventName, payload, dashboardBinding },
}),
);
window.__HUSHROOM_ANALYTICS__?.track(eventName, {
...payload,
...dashboardBinding,
});
} catch {
// Keep analytics failures isolated from UX flow.
}
};

View File

@@ -9,14 +9,121 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/features/i18n/model/useI18n";
import {
isCrewPresenceRuntimeConfigSafe,
resolveCrewPresenceRuntimeConfig,
} from "@/shared/config/featureFlags";
import { DEBRIEF_STATUS_OPTIONS } from "@/shared/config/i18n";
import { findRouteById } from "@/shared/config/routes";
import {
CrewPresenceNotificationTarget,
trackCrewPresenceEvent,
} from "@/shared/lib/analytics";
import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store";
import { Voyage, VoyageStatus } from "@/shared/types";
const FINISH_HOLD_MS = 1000;
const HOLD_STAGE_ONE_MS = 100;
const HOLD_STAGE_ONE_PROGRESS = 0.2;
const CREW_PRESENCE_GUARDRAIL_FALLBACK_CAP = 1;
const CREW_PRESENCE_GUARDRAIL_FALLBACK_INTERVAL_MS = 60_000;
const CREW_PRESENCE_DECISION_ID = "DEC-20260216-01";
type CrewPresenceStatus = "online" | "idle" | "offline";
type CrewGoalVisibility = "public" | "private" | "unset";
type CrewNotificationTarget = "friend" | "my-crew" | "global";
type CrewPresenceMember = {
id: string;
name: string;
status: CrewPresenceStatus;
elapsedMinutes: number;
goalVisibility: CrewGoalVisibility;
goalText: string;
};
type CrewPresenceNotification = {
id: string;
targetType: CrewNotificationTarget;
message: string;
};
const CREW_PRESENCE_STATUS_CYCLE: CrewPresenceStatus[] = [
"online",
"idle",
"offline",
];
const CREW_PRESENCE_GOAL_CYCLE: CrewGoalVisibility[] = [
"public",
"private",
"unset",
];
const CREW_PRESENCE_MOCK_MEMBERS: CrewPresenceMember[] = Array.from(
{ length: 57 },
(_, index) => ({
id: `crew-${index + 1}`,
name: `Crew ${index + 1}`,
status:
CREW_PRESENCE_STATUS_CYCLE[index % CREW_PRESENCE_STATUS_CYCLE.length],
elapsedMinutes: (index + 1) * 2,
goalVisibility:
CREW_PRESENCE_GOAL_CYCLE[index % CREW_PRESENCE_GOAL_CYCLE.length],
goalText: `Mission ${index + 1}`,
}),
);
const CREW_STATUS_LABEL_KEYS: Record<CrewPresenceStatus, string> = {
online: "flight.crewPresence.status.online",
idle: "flight.crewPresence.status.idle",
offline: "flight.crewPresence.status.offline",
};
const CREW_GOAL_STATE_LABEL_KEYS: Record<
Exclude<CrewGoalVisibility, "public">,
string
> = {
private: "flight.crewPresence.goal.private",
unset: "flight.crewPresence.goal.unset",
};
const CREW_NOTIFICATION_ALLOWED_TARGETS = ["friend", "my-crew"] as const;
const CREW_PRESENCE_MOCK_NOTIFICATIONS: CrewPresenceNotification[] = [
{
id: "notif-1",
targetType: "friend",
message: "Crew 2 just joined this voyage",
},
{
id: "notif-2",
targetType: "my-crew",
message: "Crew 7 switched to online",
},
{
id: "notif-3",
targetType: "global",
message: "A public voyage wave started",
},
];
const CREW_NOTIFICATION_TARGET_LABEL_KEYS: Record<
(typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number],
string
> = {
friend: "flight.crewPresence.notifications.target.friend",
"my-crew": "flight.crewPresence.notifications.target.myCrew",
};
const isCrewNotificationTargetAllowed = (
targetType: CrewNotificationTarget,
): targetType is (typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number] =>
CREW_NOTIFICATION_ALLOWED_TARGETS.includes(
targetType as (typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number],
);
type AllowedCrewPresenceNotification = CrewPresenceNotification & {
targetType: CrewPresenceNotificationTarget;
};
type FlightHudWidgetProps = {
voyage: Voyage | null;
@@ -42,6 +149,7 @@ export function FlightHudWidget({
const [status, setStatus] = useState<VoyageStatus | null>(null);
const [progress, setProgress] = useState("");
const [holdProgress, setHoldProgress] = useState(0);
const [isCrewPanelCollapsed, setIsCrewPanelCollapsed] = useState(true);
const holdStartAtRef = useRef<number | null>(null);
const holdRafRef = useRef<number | null>(null);
const isHoldCompletedRef = useRef(false);
@@ -110,7 +218,10 @@ export function FlightHudWidget({
const stageTwoElapsed = elapsed - HOLD_STAGE_ONE_MS;
const stageTwoDuration = FINISH_HOLD_MS - HOLD_STAGE_ONE_MS;
const stageTwoProgressRatio = Math.min(1, stageTwoElapsed / stageTwoDuration);
const stageTwoProgressRatio = Math.min(
1,
stageTwoElapsed / stageTwoDuration,
);
return Math.min(
1,
@@ -150,23 +261,107 @@ export function FlightHudWidget({
};
}, []);
if (!voyage) return null;
const route = findRouteById(voyage.routeId);
const voyageRouteId = voyage?.routeId ?? "";
const route = voyageRouteId ? findRouteById(voyageRouteId) : null;
const routeName = route
? t(route.nameKey, undefined, voyage.routeName)
: voyage.routeName;
? t(route.nameKey, undefined, voyage?.routeName ?? "")
: (voyage?.routeName ?? "");
const hasMissionText =
Boolean(voyage?.missionText?.trim()) &&
voyage?.missionText?.trim() !== "미입력";
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
value: option.value as VoyageStatus,
label: t(option.labelKey),
desc: t(option.descKey),
}));
const crewRuntimeConfig = resolveCrewPresenceRuntimeConfig();
const hasSafeCrewRuntimeConfig =
isCrewPresenceRuntimeConfigSafe(crewRuntimeConfig);
const crewRenderCap = hasSafeCrewRuntimeConfig
? crewRuntimeConfig.renderCap
: CREW_PRESENCE_GUARDRAIL_FALLBACK_CAP;
const crewUpdateIntervalMs = hasSafeCrewRuntimeConfig
? crewRuntimeConfig.updateIntervalMs
: CREW_PRESENCE_GUARDRAIL_FALLBACK_INTERVAL_MS;
const isCrewPresencePanelVisible =
hasSafeCrewRuntimeConfig && crewRuntimeConfig.panelEnabled;
const isCrewNotificationPolicyEnabled =
hasSafeCrewRuntimeConfig && crewRuntimeConfig.notificationsEnabled;
const crewTotalCount = CREW_PRESENCE_MOCK_MEMBERS.length;
const visibleCrewMembers = CREW_PRESENCE_MOCK_MEMBERS.slice(0, crewRenderCap);
const overflowCrewCount = Math.max(0, crewTotalCount - crewRenderCap);
const filteredCrewNotifications: AllowedCrewPresenceNotification[] =
CREW_PRESENCE_MOCK_NOTIFICATIONS.filter(
(notification): notification is AllowedCrewPresenceNotification =>
isCrewNotificationTargetAllowed(notification.targetType),
);
const buildCommonCrewAnalyticsPayload = () => ({
decisionId: CREW_PRESENCE_DECISION_ID,
routeId: voyageRouteId,
totalCount: crewTotalCount,
visibleCount: visibleCrewMembers.length,
renderCap: crewRenderCap,
updateIntervalMs: crewUpdateIntervalMs,
});
const handleCrewPanelToggle = () => {
setIsCrewPanelCollapsed((prev) => {
const nextCollapsed = !prev;
trackCrewPresenceEvent("panel_collapsed", {
...buildCommonCrewAnalyticsPayload(),
collapsed: nextCollapsed,
});
return nextCollapsed;
});
};
const handlePresenceEventClick = (
targetType: CrewPresenceNotificationTarget,
) => {
trackCrewPresenceEvent("presence_event_click", {
...buildCommonCrewAnalyticsPayload(),
targetType,
});
};
useEffect(() => {
if (!isCrewPresencePanelVisible) {
return;
}
const trackPanelVisible = () => {
trackCrewPresenceEvent("panel_visible", {
...buildCommonCrewAnalyticsPayload(),
collapsed: isCrewPanelCollapsed,
});
};
trackPanelVisible();
const intervalId = window.setInterval(
trackPanelVisible,
crewUpdateIntervalMs,
);
return () => {
window.clearInterval(intervalId);
};
}, [
crewTotalCount,
isCrewPanelCollapsed,
isCrewPresencePanelVisible,
crewUpdateIntervalMs,
visibleCrewMembers.length,
voyageRouteId,
]);
if (!voyage) return null;
return (
<>
<div className="absolute top-8 z-10 text-center">
<span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur">
{routeName} · {isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
{routeName} ·{" "}
{isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
</span>
</div>
@@ -176,6 +371,7 @@ export function FlightHudWidget({
{formattedTime}
</div>
{hasMissionText && (
<div className="relative z-10 mb-24 w-full max-w-2xl px-4">
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs">
@@ -186,6 +382,113 @@ export function FlightHudWidget({
</p>
</section>
</div>
)}
{isCrewPresencePanelVisible && (
<div className="relative z-10 mb-8 w-full max-w-2xl px-4">
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
<div className="flex items-center justify-between gap-4">
<h2 className="text-sm font-semibold text-slate-100 md:text-base">
{t("flight.crewPresence.header", {
count: crewTotalCount,
})}
</h2>
<button
type="button"
onClick={handleCrewPanelToggle}
className="rounded-lg border border-slate-600/80 bg-slate-900/40 px-3 py-1.5 text-xs font-semibold text-slate-200 transition-colors hover:border-slate-400 hover:text-white"
>
{isCrewPanelCollapsed
? t("flight.crewPresence.expand")
: t("flight.crewPresence.collapse")}
</button>
</div>
{!isCrewPanelCollapsed && (
<div className="mt-4 space-y-3">
<p className="text-xs text-slate-400">
{t("flight.crewPresence.listLimit", {
limit: crewRenderCap,
})}
</p>
<p className="text-xs text-slate-400">
{t("flight.crewPresence.activityHint")}
</p>
<ul className="max-h-56 space-y-2 overflow-y-auto rounded-xl border border-slate-700/70 bg-slate-900/35 p-3">
{visibleCrewMembers.map((member) => (
<li
key={member.id}
className="space-y-1 rounded-lg border border-slate-700/70 bg-slate-900/60 px-3 py-2 text-sm text-slate-100"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{member.name}</span>
<span className="rounded-md border border-slate-600/80 px-2 py-0.5 text-[11px] text-slate-200">
{t(CREW_STATUS_LABEL_KEYS[member.status])}
</span>
</div>
<div className="text-xs text-slate-300">
{t("flight.crewPresence.elapsedMinutes", {
minutes: member.elapsedMinutes,
})}
</div>
<div className="text-xs text-slate-300">
{member.goalVisibility === "public"
? member.goalText
: t(
CREW_GOAL_STATE_LABEL_KEYS[member.goalVisibility],
)}
</div>
</li>
))}
</ul>
{overflowCrewCount > 0 && (
<p className="text-xs font-semibold text-slate-300">
{t("flight.crewPresence.overflow", {
count: overflowCrewCount,
})}
</p>
)}
{isCrewNotificationPolicyEnabled &&
filteredCrewNotifications.length > 0 && (
<section className="rounded-xl border border-slate-700/70 bg-slate-900/35 p-3">
<p className="text-xs font-semibold text-slate-200">
{t("flight.crewPresence.notifications.policyOn")}
</p>
<p className="mt-1 text-xs text-slate-400">
{t("flight.crewPresence.notifications.filterHint")}
</p>
<ul className="mt-2 space-y-1.5">
{filteredCrewNotifications.map((notification) => (
<li key={notification.id}>
<button
type="button"
onClick={() =>
handlePresenceEventClick(
notification.targetType,
)
}
className="w-full rounded-md border border-slate-700/70 bg-slate-900/60 px-2.5 py-1.5 text-left text-xs text-slate-200"
>
<span className="font-semibold text-slate-100">
{t(
CREW_NOTIFICATION_TARGET_LABEL_KEYS[
notification.targetType
],
)}
</span>{" "}
<span className="text-slate-300">
{notification.message}
</span>
</button>
</li>
))}
</ul>
</section>
)}
</div>
)}
</section>
</div>
)}
<div className="absolute bottom-12 z-10 flex gap-6">
<button