From f008af0d9b41f358d2cd89033b5f7b4af9934bd5 Mon Sep 17 00:00:00 2001 From: corpi Date: Mon, 16 Feb 2026 23:21:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20(=ED=94=84=EB=9E=91=EC=8A=A4,=20=EB=8F=85=EC=9D=BC)?= =?UTF-8?q?=20=EB=B0=8F=20=ED=95=AD=ED=95=B4=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=81=AC=EB=A3=A8=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/log/[id]/page.tsx | 20 +- src/app/log/page.tsx | 14 +- src/features/boarding/model/startVoyage.ts | 6 +- .../boarding/ui/BoardingMissionForm.tsx | 29 +- .../flight-starfield/model/starfieldModel.ts | 7 +- .../ui/FlightStarfieldCanvas.tsx | 2 +- src/shared/config/featureFlags.ts | 62 ++++ src/shared/config/i18n.ts | 263 +++++++++++++- src/shared/config/starfield.ts | 10 +- src/shared/lib/analytics.ts | 71 ++++ src/widgets/flight-hud/ui/FlightHudWidget.tsx | 337 +++++++++++++++++- 11 files changed, 773 insertions(+), 48 deletions(-) create mode 100644 src/shared/config/featureFlags.ts create mode 100644 src/shared/lib/analytics.ts diff --git a/src/app/log/[id]/page.tsx b/src/app/log/[id]/page.tsx index 22011bc..9f545ec 100644 --- a/src/app/log/[id]/page.tsx +++ b/src/app/log/[id]/page.tsx @@ -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(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 (
@@ -39,9 +46,9 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string ← Lobby
-

{log.missionText}

+

{missionLabel}

- {new Date(log.startedAt).toLocaleString()} + {new Date(log.startedAt).toLocaleString(locale)} {routeName} ({log.durationMinutes}{t('common.minuteShort')})
@@ -53,16 +60,11 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string

{statusLabel}

-
+

{t('log.detail.progressTitle')}

{log.debriefProgress || '-'}

- -
-

{t('log.detail.nextActionTitle')}

-

{log.nextAction || '-'}

-
{log.notes && ( diff --git a/src/app/log/page.tsx b/src/app/log/page.tsx index cca9f84..0ebb21e 100644 --- a/src/app/log/page.tsx +++ b/src/app/log/page.tsx @@ -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([]); 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 (
@@ -55,14 +63,14 @@ export default function LogListPage() { >
- {new Date(log.startedAt).toLocaleDateString()} + {new Date(log.startedAt).toLocaleDateString(locale)} {getStatusLabel(log.status)}

- {log.missionText} + {getMissionLabel(log.missionText)}

{routeName} · {log.durationMinutes} diff --git a/src/features/boarding/model/startVoyage.ts b/src/features/boarding/model/startVoyage.ts index c7e0952..8aacba0 100644 --- a/src/features/boarding/model/startVoyage.ts +++ b/src/features/boarding/model/startVoyage.ts @@ -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); diff --git a/src/features/boarding/ui/BoardingMissionForm.tsx b/src/features/boarding/ui/BoardingMissionForm.tsx index e40b8b1..98e32c7 100644 --- a/src/features/boarding/ui/BoardingMissionForm.tsx +++ b/src/features/boarding/ui/BoardingMissionForm.tsx @@ -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) => { 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({ )} diff --git a/src/features/flight-starfield/model/starfieldModel.ts b/src/features/flight-starfield/model/starfieldModel.ts index 90bed0e..3b733a8 100644 --- a/src/features/flight-starfield/model/starfieldModel.ts +++ b/src/features/flight-starfield/model/starfieldModel.ts @@ -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); diff --git a/src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx b/src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx index 11aae14..8aee5af 100644 --- a/src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx +++ b/src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx @@ -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); diff --git a/src/shared/config/featureFlags.ts b/src/shared/config/featureFlags.ts new file mode 100644 index 0000000..10d21e3 --- /dev/null +++ b/src/shared/config/featureFlags.ts @@ -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; diff --git a/src/shared/config/i18n.ts b/src/shared/config/i18n.ts index 1acce80..2236194 100644 --- a/src/shared/config/i18n.ts +++ b/src/shared/config/i18n.ts @@ -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 = { 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 = { ko: koMessages, en: normalizedEnMessages, ja: jaMessages, + fr: frMessages, + de: deMessages, }; export type I18nKey = MessageKey; diff --git a/src/shared/config/starfield.ts b/src/shared/config/starfield.ts index 27adf53..b5fd9a1 100644 --- a/src/shared/config/starfield.ts +++ b/src/shared/config/starfield.ts @@ -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: { diff --git a/src/shared/lib/analytics.ts b/src/shared/lib/analytics.ts new file mode 100644 index 0000000..33a8ab5 --- /dev/null +++ b/src/shared/lib/analytics.ts @@ -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) => 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. + } +}; diff --git a/src/widgets/flight-hud/ui/FlightHudWidget.tsx b/src/widgets/flight-hud/ui/FlightHudWidget.tsx index a5f529e..cd5a0fc 100644 --- a/src/widgets/flight-hud/ui/FlightHudWidget.tsx +++ b/src/widgets/flight-hud/ui/FlightHudWidget.tsx @@ -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 = { + online: "flight.crewPresence.status.online", + idle: "flight.crewPresence.status.idle", + offline: "flight.crewPresence.status.offline", +}; + +const CREW_GOAL_STATE_LABEL_KEYS: Record< + Exclude, + 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(null); const [progress, setProgress] = useState(""); const [holdProgress, setHoldProgress] = useState(0); + const [isCrewPanelCollapsed, setIsCrewPanelCollapsed] = useState(true); const holdStartAtRef = useRef(null); const holdRafRef = useRef(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 ( <>

- {routeName} · {isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")} + {routeName} ·{" "} + {isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
@@ -176,16 +371,124 @@ export function FlightHudWidget({ {formattedTime}
-
-
-

- {t("flight.missionLabel")} -

-

- {voyage.missionText} -

-
-
+ {hasMissionText && ( +
+
+

+ {t("flight.missionLabel")} +

+

+ {voyage.missionText} +

+
+
+ )} + + {isCrewPresencePanelVisible && ( +
+
+
+

+ {t("flight.crewPresence.header", { + count: crewTotalCount, + })} +

+ +
+ {!isCrewPanelCollapsed && ( +
+

+ {t("flight.crewPresence.listLimit", { + limit: crewRenderCap, + })} +

+

+ {t("flight.crewPresence.activityHint")} +

+
    + {visibleCrewMembers.map((member) => ( +
  • +
    + {member.name} + + {t(CREW_STATUS_LABEL_KEYS[member.status])} + +
    +
    + {t("flight.crewPresence.elapsedMinutes", { + minutes: member.elapsedMinutes, + })} +
    +
    + {member.goalVisibility === "public" + ? member.goalText + : t( + CREW_GOAL_STATE_LABEL_KEYS[member.goalVisibility], + )} +
    +
  • + ))} +
+ {overflowCrewCount > 0 && ( +

+ {t("flight.crewPresence.overflow", { + count: overflowCrewCount, + })} +

+ )} + {isCrewNotificationPolicyEnabled && + filteredCrewNotifications.length > 0 && ( +
+

+ {t("flight.crewPresence.notifications.policyOn")} +

+

+ {t("flight.crewPresence.notifications.filterHint")} +

+
    + {filteredCrewNotifications.map((notification) => ( +
  • + +
  • + ))} +
+
+ )} +
+ )} +
+
+ )}