Compare commits

...

5 Commits

12 changed files with 907 additions and 108 deletions

View File

@@ -9,7 +9,7 @@ import { getHistory } from '@/shared/lib/store';
import { Voyage } from '@/shared/types'; import { Voyage } from '@/shared/types';
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { t } = useI18n(); const { t, locale } = useI18n();
// Next.js 15: params is a Promise // Next.js 15: params is a Promise
const resolvedParams = use(params); const resolvedParams = use(params);
const [log, setLog] = useState<Voyage | null>(null); 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 route = findRouteById(log.routeId);
const routeName = route ? t(route.nameKey, undefined, log.routeName) : log.routeName; 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 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 ( return (
<div className="flex flex-col flex-1 p-6 space-y-8 animate-in fade-in duration-300"> <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 &larr; Lobby
</Link> </Link>
</div> </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"> <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></span>
<span>{routeName} ({log.durationMinutes}{t('common.minuteShort')})</span> <span>{routeName} ({log.durationMinutes}{t('common.minuteShort')})</span>
</div> </div>
@@ -53,16 +60,11 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
<p className="text-lg text-indigo-100">{statusLabel}</p> <p className="text-lg text-indigo-100">{statusLabel}</p>
</div> </div>
<div className="grid gap-6 sm:grid-cols-2"> <div>
<div> <div>
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.progressTitle')}</h3> <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> <p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.debriefProgress || '-'}</p>
</div> </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> </div>
{log.notes && ( {log.notes && (

View File

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

View File

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

View File

@@ -3,6 +3,24 @@
import { FormEvent, useState } from 'react'; import { FormEvent, useState } from 'react';
import { useI18n } from '@/features/i18n/model/useI18n'; 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({ export function BoardingMissionForm({
onDock, onDock,
onCancel, onCancel,
@@ -17,11 +35,13 @@ export function BoardingMissionForm({
const { t } = useI18n(); const { t } = useI18n();
const [mission, setMission] = useState(''); const [mission, setMission] = useState('');
const trimmedMission = mission.trim(); const trimmedMission = mission.trim();
const canSubmit = Boolean(trimmedMission); const missionPlaceholder = buildMissionPlaceholder(
t('boarding.missionPlaceholder'),
t('boarding.optionalLabel'),
);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => { const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!canSubmit) return;
onDock(trimmedMission); onDock(trimmedMission);
}; };
@@ -38,7 +58,7 @@ export function BoardingMissionForm({
type="text" type="text"
value={mission} value={mission}
onChange={(event) => setMission(event.target.value)} 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" 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} autoFocus={autoFocus}
/> />
@@ -56,8 +76,7 @@ export function BoardingMissionForm({
)} )}
<button <button
type="submit" 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 ${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 disabled:bg-slate-800 disabled:text-slate-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
> >
{t('boarding.submit')} {t('boarding.submit')}
</button> </button>

View File

@@ -41,23 +41,33 @@ const formatHHMMSS = (totalSeconds: number) => {
export function useFlightSession() { export function useFlightSession() {
const router = useRouter(); const router = useRouter();
const [voyage] = useState<Voyage | null>(() => getVoyageFromStore()); const [voyage, setVoyage] = useState<Voyage | null>(null);
const [timeLeft, setTimeLeft] = useState<number>(() => const [timeLeft, setTimeLeft] = useState<number>(0);
getInitialTimerSeconds(getVoyageFromStore()),
);
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const endTimeRef = useRef<number>(getEndTime(getVoyageFromStore())); const [isSessionReady, setIsSessionReady] = useState(false);
const endTimeRef = useRef<number>(0);
const pausedElapsedMsRef = useRef<number>(0); const pausedElapsedMsRef = useRef<number>(0);
const pausedAtMsRef = useRef<number | null>(null); const pausedAtMsRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
const storedVoyage = getVoyageFromStore();
if (storedVoyage) {
setVoyage(storedVoyage);
setTimeLeft(getInitialTimerSeconds(storedVoyage));
endTimeRef.current = getEndTime(storedVoyage);
}
setIsSessionReady(true);
}, []);
useEffect(() => {
if (!isSessionReady) return;
if (voyage) return; if (voyage) return;
router.replace('/'); router.replace('/');
}, [voyage, router]); }, [isSessionReady, voyage, router]);
useEffect(() => { useEffect(() => {
if (!voyage || isPaused) return; if (!isSessionReady || !voyage || isPaused) return;
const interval = setInterval(() => { const interval = setInterval(() => {
if (voyage.durationMinutes === 0) { if (voyage.durationMinutes === 0) {
@@ -78,7 +88,7 @@ export function useFlightSession() {
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [voyage, isPaused]); }, [isSessionReady, voyage, isPaused]);
const handlePauseToggle = () => { const handlePauseToggle = () => {
if (voyage?.durationMinutes === 0) { if (voyage?.durationMinutes === 0) {

View File

@@ -1,7 +1,7 @@
import { FLIGHT_STARFIELD_TUNING } from '@/shared/config/starfield'; import { FLIGHT_STARFIELD_TUNING } from "@/shared/config/starfield";
import { clamp, randomInRange } from '@/shared/lib/math/number'; import { clamp, randomInRange } from "@/shared/lib/math/number";
import { FlightStar } from '@/features/flight-starfield/model/types'; import { FlightStar } from "@/features/flight-starfield/model/types";
export const getFlightStarCount = (width: number, height: number) => { export const getFlightStarCount = (width: number, height: number) => {
const isMobile = width < FLIGHT_STARFIELD_TUNING.mobileBreakpoint; const isMobile = width < FLIGHT_STARFIELD_TUNING.mobileBreakpoint;
@@ -9,9 +9,12 @@ export const getFlightStarCount = (width: number, height: number) => {
? FLIGHT_STARFIELD_TUNING.starCount.mobile.min ? FLIGHT_STARFIELD_TUNING.starCount.mobile.min
: FLIGHT_STARFIELD_TUNING.starCount.desktop.min; : FLIGHT_STARFIELD_TUNING.starCount.desktop.min;
const max = isMobile const max = isMobile
? FLIGHT_STARFIELD_TUNING.starCount.mobile.max ? FLIGHT_STARFIELD_TUNING.maxStars.mobile
: FLIGHT_STARFIELD_TUNING.starCount.desktop.max; : FLIGHT_STARFIELD_TUNING.maxStars.desktop;
const byArea = Math.round((width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor); const byArea = Math.round(
((width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor) *
FLIGHT_STARFIELD_TUNING.densityMultiplier,
);
return clamp(byArea, min, max); return clamp(byArea, min, max);
}; };
@@ -51,7 +54,8 @@ const createFlightSpeed = () => {
}; };
const createFlightVisualTier = () => { const createFlightVisualTier = () => {
const highlight = Math.random() < FLIGHT_STARFIELD_TUNING.radius.highlightChance; const highlight =
Math.random() < FLIGHT_STARFIELD_TUNING.radius.highlightChance;
const tailRoll = Math.random(); const tailRoll = Math.random();
const tailLength = const tailLength =
tailRoll < FLIGHT_STARFIELD_TUNING.tail.pointChance tailRoll < FLIGHT_STARFIELD_TUNING.tail.pointChance
@@ -95,7 +99,8 @@ const createFlightVisualTier = () => {
const createFlightSpawnRadius = (width: number, height: number) => { const createFlightSpawnRadius = (width: number, height: number) => {
const roll = Math.random(); const roll = Math.random();
const maxWideRadius = Math.min( const maxWideRadius = Math.min(
Math.max(width, height) * FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxScaleOfViewport, Math.max(width, height) *
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxScaleOfViewport,
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxAbsolute, FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxAbsolute,
); );
const ringOuter = Math.min( const ringOuter = Math.min(

View File

@@ -1,17 +1,21 @@
'use client'; "use client";
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import { projectFlightStar, createVanishingPoint } from '@/features/flight-starfield/lib/projection'; import {
createVanishingPoint,
projectFlightStar,
} from "@/features/flight-starfield/lib/projection";
import { import {
createFlightStar, createFlightStar,
createFlightVanishXJitter, createFlightVanishXJitter,
getFlightStarCount, getFlightStarCount,
shouldRecycleFlightStar, shouldRecycleFlightStar,
} from '@/features/flight-starfield/model/starfieldModel'; } from "@/features/flight-starfield/model/starfieldModel";
import { FlightStar } from '@/features/flight-starfield/model/types'; import { FlightStar } from "@/features/flight-starfield/model/types";
import { clamp } from '@/shared/lib/math/number'; import { FLIGHT_STARFIELD_TUNING } from "@/shared/config/starfield";
import { getPrefersReducedMotionMediaQuery } from '@/shared/lib/motion/prefersReducedMotion'; import { clamp } from "@/shared/lib/math/number";
import { getPrefersReducedMotionMediaQuery } from "@/shared/lib/motion/prefersReducedMotion";
export function FlightStarfieldCanvas({ export function FlightStarfieldCanvas({
vanishYOffset = -68, vanishYOffset = -68,
@@ -30,12 +34,13 @@ export function FlightStarfieldCanvas({
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const context = canvas.getContext('2d'); const context = canvas.getContext("2d");
if (!context) return; if (!context) return;
let width = window.innerWidth; let width = window.innerWidth;
let height = window.innerHeight; let height = window.innerHeight;
let animationFrameId = 0; let animationFrameId = 0;
let dpr = 1;
const motionQuery = getPrefersReducedMotionMediaQuery(); const motionQuery = getPrefersReducedMotionMediaQuery();
let prefersReducedMotion = motionQuery.matches; let prefersReducedMotion = motionQuery.matches;
@@ -45,12 +50,22 @@ export function FlightStarfieldCanvas({
const vanishXJitter = vanishXJitterRef.current ?? 0; const vanishXJitter = vanishXJitterRef.current ?? 0;
const setCanvasSize = () => { const setCanvasSize = () => {
width = window.innerWidth; const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
height = window.innerHeight; const viewportHeight =
canvas.width = width; window.visualViewport?.height ?? window.innerHeight;
canvas.height = height; width = Math.max(1, Math.floor(viewportWidth));
height = Math.max(1, Math.floor(viewportHeight));
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(width * dpr));
canvas.height = Math.max(1, Math.floor(height * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.imageSmoothingEnabled = false;
}; };
const snapToDevicePixel = (value: number) => Math.round(value * dpr) / dpr;
const getVanishingPoint = () => const getVanishingPoint = () =>
createVanishingPoint({ createVanishingPoint({
width, width,
@@ -99,12 +114,23 @@ export function FlightStarfieldCanvas({
const deltaX = toX - fromX; const deltaX = toX - fromX;
const deltaY = toY - fromY; const deltaY = toY - fromY;
const movementLength = Math.hypot(deltaX, deltaY); const movementLength = Math.hypot(deltaX, deltaY);
const tailLineWidth = clamp(star.radius * 0.9, 0.78, 1.6);
const canDrawTail =
star.tailLength >=
FLIGHT_STARFIELD_TUNING.tail.cleanup.minTailLengthToDraw &&
movementLength >=
FLIGHT_STARFIELD_TUNING.tail.cleanup.minMovementToDraw &&
visibleAlpha >= FLIGHT_STARFIELD_TUNING.tail.cleanup.minAlphaToDraw &&
tailLineWidth >=
FLIGHT_STARFIELD_TUNING.tail.cleanup.minLineWidthToDraw;
if (star.tailLength < 1 || movementLength < 0.001) { if (!canDrawTail) {
const snappedToX = snapToDevicePixel(toX);
const snappedToY = snapToDevicePixel(toY);
context.globalAlpha = visibleAlpha; context.globalAlpha = visibleAlpha;
context.fillStyle = '#f8fbff'; context.fillStyle = "#f8fbff";
context.beginPath(); context.beginPath();
context.arc(toX, toY, star.radius, 0, Math.PI * 2); context.arc(snappedToX, snappedToY, star.radius, 0, Math.PI * 2);
context.fill(); context.fill();
context.globalAlpha = 1; context.globalAlpha = 1;
return; return;
@@ -112,24 +138,37 @@ export function FlightStarfieldCanvas({
const directionX = deltaX / movementLength; const directionX = deltaX / movementLength;
const directionY = deltaY / movementLength; const directionY = deltaY / movementLength;
const tailX = toX - directionX * star.tailLength; const tailX = snapToDevicePixel(toX - directionX * star.tailLength);
const tailY = toY - directionY * star.tailLength; const tailY = snapToDevicePixel(toY - directionY * star.tailLength);
const snappedToX = snapToDevicePixel(toX);
const snappedToY = snapToDevicePixel(toY);
const gradient = context.createLinearGradient(tailX, tailY, toX, toY); const gradient = context.createLinearGradient(
gradient.addColorStop(0, 'rgba(248, 251, 255, 0)'); tailX,
tailY,
snappedToX,
snappedToY,
);
gradient.addColorStop(0, "rgba(248, 251, 255, 0)");
gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`); gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`);
context.strokeStyle = gradient; context.strokeStyle = gradient;
context.lineWidth = clamp(star.radius * 0.9, 0.65, 1.6); context.lineWidth = tailLineWidth;
context.beginPath(); context.beginPath();
context.moveTo(tailX, tailY); context.moveTo(tailX, tailY);
context.lineTo(toX, toY); context.lineTo(snappedToX, snappedToY);
context.stroke(); context.stroke();
context.globalAlpha = Math.min(1, visibleAlpha + 0.08); context.globalAlpha = Math.min(1, visibleAlpha + 0.08);
context.fillStyle = '#f8fbff'; context.fillStyle = "#f8fbff";
context.beginPath(); context.beginPath();
context.arc(toX, toY, clamp(star.radius * 0.72, 0.6, 1.45), 0, Math.PI * 2); context.arc(
snappedToX,
snappedToY,
clamp(star.radius * 0.72, 0.6, 1.45),
0,
Math.PI * 2,
);
context.fill(); context.fill();
context.globalAlpha = 1; context.globalAlpha = 1;
}; };
@@ -139,14 +178,15 @@ export function FlightStarfieldCanvas({
const veil = context.createRadialGradient( const veil = context.createRadialGradient(
vp.x, vp.x,
vp.y, vp.y,
centerProtectRadius * 0.18, centerProtectRadius * 0.12,
vp.x, vp.x,
vp.y, vp.y,
centerProtectRadius * 1.35, centerProtectRadius * 1.35,
); );
veil.addColorStop(0, 'rgba(160, 185, 235, 0.08)'); veil.addColorStop(0, "rgba(176, 201, 242, 0.11)");
veil.addColorStop(0.55, 'rgba(90, 114, 170, 0.03)'); veil.addColorStop(0.22, "rgba(138, 164, 216, 0.06)");
veil.addColorStop(1, 'rgba(0, 0, 0, 0)'); veil.addColorStop(0.55, "rgba(90, 114, 170, 0.025)");
veil.addColorStop(1, "rgba(0, 0, 0, 0)");
context.fillStyle = veil; context.fillStyle = veil;
context.fillRect(0, 0, width, height); context.fillRect(0, 0, width, height);
@@ -161,14 +201,19 @@ export function FlightStarfieldCanvas({
height / 2, height / 2,
Math.max(width, height) * 0.95, Math.max(width, height) * 0.95,
); );
vignette.addColorStop(0, 'rgba(0, 0, 0, 0)'); vignette.addColorStop(0, "rgba(0, 0, 0, 0)");
vignette.addColorStop(1, 'rgba(0, 0, 0, 0.82)'); vignette.addColorStop(1, "rgba(0, 0, 0, 0.82)");
context.fillStyle = vignette; context.fillStyle = vignette;
context.fillRect(0, 0, width, height); context.fillRect(0, 0, width, height);
}; };
const drawFrame = (moveStars: boolean) => { const drawFrame = (moveStars: boolean) => {
context.fillStyle = 'rgba(2, 5, 10, 0.3)'; context.setTransform(1, 0, 0, 1, 0, 0);
context.globalAlpha = 1;
context.globalCompositeOperation = "source-over";
context.clearRect(0, 0, canvas.width, canvas.height);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.fillStyle = "rgb(2, 5, 10)";
context.fillRect(0, 0, width, height); context.fillRect(0, 0, width, height);
drawCenterVeil(); drawCenterVeil();
@@ -178,7 +223,7 @@ export function FlightStarfieldCanvas({
const from = projectFlightStar(star, vp, star.z); const from = projectFlightStar(star, vp, star.z);
if (moveStars) { if (moveStars) {
star.z -= star.speed; star.z -= star.speed * FLIGHT_STARFIELD_TUNING.speedScale;
} }
const to = projectFlightStar(star, vp, star.z); const to = projectFlightStar(star, vp, star.z);
@@ -226,7 +271,6 @@ export function FlightStarfieldCanvas({
}; };
const renderStatic = () => { const renderStatic = () => {
context.clearRect(0, 0, width, height);
drawFrame(false); drawFrame(false);
}; };
@@ -254,8 +298,9 @@ export function FlightStarfieldCanvas({
} }
}; };
window.addEventListener('resize', handleResize); window.addEventListener("resize", handleResize);
motionQuery.addEventListener('change', handleMotionChange); window.visualViewport?.addEventListener("resize", handleResize);
motionQuery.addEventListener("change", handleMotionChange);
if (prefersReducedMotion || isPaused) { if (prefersReducedMotion || isPaused) {
renderStatic(); renderStatic();
@@ -264,11 +309,17 @@ export function FlightStarfieldCanvas({
} }
return () => { return () => {
window.removeEventListener('resize', handleResize); window.removeEventListener("resize", handleResize);
motionQuery.removeEventListener('change', handleMotionChange); window.visualViewport?.removeEventListener("resize", handleResize);
motionQuery.removeEventListener("change", handleMotionChange);
stopAnimation(); stopAnimation();
}; };
}, [vanishYOffset, centerProtectRadius, isPaused]); }, [vanishYOffset, centerProtectRadius, isPaused]);
return <canvas ref={canvasRef} className="fixed inset-0 z-0 bg-black pointer-events-none" />; return (
<canvas
ref={canvasRef}
className="fixed inset-0 z-0 h-screen w-screen bg-black pointer-events-none"
/>
);
} }

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"; 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]; export type Locale = (typeof SUPPORTED_LOCALES)[number];
@@ -12,6 +12,8 @@ export const LOCALE_LABELS: Record<Locale, string> = {
ko: "한국어", ko: "한국어",
en: "English", en: "English",
ja: "日本語", ja: "日本語",
fr: "Français",
de: "Deutsch",
}; };
const enMessages = { const enMessages = {
@@ -39,7 +41,8 @@ const enMessages = {
"boarding.check": "Boarding Check", "boarding.check": "Boarding Check",
"boarding.routeBoarding": "{routeName} Route Boarding", "boarding.routeBoarding": "{routeName} Route Boarding",
"boarding.missionLabel": "Core mission for this voyage", "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.cancel": "Cancel",
"boarding.submit": "Dock Complete (Launch)", "boarding.submit": "Dock Complete (Launch)",
"flight.badge.paused": "Paused", "flight.badge.paused": "Paused",
@@ -47,6 +50,23 @@ const enMessages = {
"flight.missionLabel": "Voyage Mission", "flight.missionLabel": "Voyage Mission",
"flight.pause": "Pause", "flight.pause": "Pause",
"flight.resume": "Resume", "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.debrief": "Arrived (Debrief)",
"flight.finish.end": "End Voyage", "flight.finish.end": "End Voyage",
"flight.debrief.title": "Wrap up this 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", "e.g. What worked, what did not, and what I learned",
"debrief.save": "Save to Logbook", "debrief.save": "Save to Logbook",
"log.title": "My Voyage Logs", "log.title": "My Voyage Logs",
"log.mission.empty": "No mission entered",
"log.empty": "No voyages recorded yet.", "log.empty": "No voyages recorded yet.",
"log.firstVoyage": "Start your first voyage", "log.firstVoyage": "Start your first voyage",
"log.detail.back": "Back to list", "log.detail.back": "Back to list",
@@ -82,11 +103,12 @@ const enMessages = {
"status.reoriented": "Reoriented", "status.reoriented": "Reoriented",
"status.aborted": "Aborted Early", "status.aborted": "Aborted Early",
"status.in_progress": "In Progress", "status.in_progress": "In Progress",
} as const; } satisfies Record<string, string>;
type I18nMessages = typeof enMessages; type MessageKey = keyof typeof enMessages;
type I18nMessages = Record<MessageKey, string>;
const koMessages: I18nMessages = { const koMessages = {
"layout.nav.log": "항해일지", "layout.nav.log": "항해일지",
"layout.nav.settings": "설정", "layout.nav.settings": "설정",
"layout.nav.language": "언어", "layout.nav.language": "언어",
@@ -111,7 +133,8 @@ const koMessages: I18nMessages = {
"boarding.check": "Boarding Check", "boarding.check": "Boarding Check",
"boarding.routeBoarding": "{routeName} 항로 탑승", "boarding.routeBoarding": "{routeName} 항로 탑승",
"boarding.missionLabel": "이번 항해의 핵심 목표", "boarding.missionLabel": "이번 항해의 핵심 목표",
"boarding.missionPlaceholder": "예: 서론 3문단 완성하기", "boarding.missionPlaceholder": "선택: 예) 서론 3문단 완성하기",
"boarding.optionalLabel": "선택",
"boarding.cancel": "취소", "boarding.cancel": "취소",
"boarding.submit": "도킹 완료 (출항)", "boarding.submit": "도킹 완료 (출항)",
"flight.badge.paused": "일시정지", "flight.badge.paused": "일시정지",
@@ -119,6 +142,23 @@ const koMessages: I18nMessages = {
"flight.missionLabel": "이번 항해 목표", "flight.missionLabel": "이번 항해 목표",
"flight.pause": "일시정지", "flight.pause": "일시정지",
"flight.resume": "다시 시작", "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.debrief": "도착 (회고)",
"flight.finish.end": "항해 종료", "flight.finish.end": "항해 종료",
"flight.debrief.title": "이번 항해를 정리하세요", "flight.debrief.title": "이번 항해를 정리하세요",
@@ -138,6 +178,7 @@ const koMessages: I18nMessages = {
"예: 잘한 점, 아쉬운 점, 느낀 점을 짧게 남겨보세요", "예: 잘한 점, 아쉬운 점, 느낀 점을 짧게 남겨보세요",
"debrief.save": "항해일지 저장", "debrief.save": "항해일지 저장",
"log.title": "나의 항해 기록", "log.title": "나의 항해 기록",
"log.mission.empty": "미입력",
"log.empty": "아직 기록된 항해가 없습니다.", "log.empty": "아직 기록된 항해가 없습니다.",
"log.firstVoyage": "첫 항해 떠나기", "log.firstVoyage": "첫 항해 떠나기",
"log.detail.back": "목록으로", "log.detail.back": "목록으로",
@@ -155,9 +196,9 @@ const koMessages: I18nMessages = {
"status.reoriented": "🧭 방향 재설정", "status.reoriented": "🧭 방향 재설정",
"status.aborted": "🚨 조기 귀환", "status.aborted": "🚨 조기 귀환",
"status.in_progress": "진행 중", "status.in_progress": "진행 중",
}; } satisfies I18nMessages;
const jaMessages: I18nMessages = { const jaMessages = {
"layout.nav.log": "航海ログ", "layout.nav.log": "航海ログ",
"layout.nav.settings": "設定", "layout.nav.settings": "設定",
"layout.nav.language": "言語", "layout.nav.language": "言語",
@@ -182,7 +223,8 @@ const jaMessages: I18nMessages = {
"boarding.check": "搭乗チェック", "boarding.check": "搭乗チェック",
"boarding.routeBoarding": "{routeName} 航路に搭乗", "boarding.routeBoarding": "{routeName} 航路に搭乗",
"boarding.missionLabel": "今回の航海のコア目標", "boarding.missionLabel": "今回の航海のコア目標",
"boarding.missionPlaceholder": "例: 導入の3段落を完成する", "boarding.missionPlaceholder": "任意: 例) 導入の3段落を完成する",
"boarding.optionalLabel": "任意",
"boarding.cancel": "キャンセル", "boarding.cancel": "キャンセル",
"boarding.submit": "ドッキング完了(出航)", "boarding.submit": "ドッキング完了(出航)",
"flight.badge.paused": "一時停止", "flight.badge.paused": "一時停止",
@@ -190,6 +232,23 @@ const jaMessages: I18nMessages = {
"flight.missionLabel": "今回の航海目標", "flight.missionLabel": "今回の航海目標",
"flight.pause": "一時停止", "flight.pause": "一時停止",
"flight.resume": "再開", "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.debrief": "到着(振り返り)",
"flight.finish.end": "航海終了", "flight.finish.end": "航海終了",
"flight.debrief.title": "今回の航海を整理しましょう", "flight.debrief.title": "今回の航海を整理しましょう",
@@ -209,6 +268,7 @@ const jaMessages: I18nMessages = {
"例: 良かった点・難しかった点・気づきを短く残してください", "例: 良かった点・難しかった点・気づきを短く残してください",
"debrief.save": "航海ログに保存", "debrief.save": "航海ログに保存",
"log.title": "私の航海記録", "log.title": "私の航海記録",
"log.mission.empty": "未入力",
"log.empty": "まだ記録された航海がありません。", "log.empty": "まだ記録された航海がありません。",
"log.firstVoyage": "最初の航海を始める", "log.firstVoyage": "最初の航海を始める",
"log.detail.back": "一覧へ戻る", "log.detail.back": "一覧へ戻る",
@@ -226,15 +286,213 @@ const jaMessages: I18nMessages = {
"status.reoriented": "🧭 方針再設定", "status.reoriented": "🧭 方針再設定",
"status.aborted": "🚨 早期帰還", "status.aborted": "🚨 早期帰還",
"status.in_progress": "進行中", "status.in_progress": "進行中",
} 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> = { export const I18N_MESSAGES: Record<Locale, I18nMessages> = {
ko: koMessages, ko: koMessages,
en: enMessages, en: normalizedEnMessages,
ja: jaMessages, ja: jaMessages,
fr: frMessages,
de: deMessages,
}; };
export type I18nKey = keyof I18nMessages; export type I18nKey = MessageKey;
export type TranslationParams = Record<string, string | number>; export type TranslationParams = Record<string, string | number>;

View File

@@ -1,22 +1,34 @@
export const FLIGHT_STARFIELD_TUNING = { export const FLIGHT_STARFIELD_TUNING = {
mobileBreakpoint: 768, mobileBreakpoint: 768,
densityDivisor: 42000, densityDivisor: 42000,
densityMultiplier: 1.35,
speedScale: 0.3,
starCount: { starCount: {
mobile: { min: 12, max: 30 }, mobile: { min: 16, max: 48 },
desktop: { min: 18, max: 45 }, desktop: { min: 24, max: 72 },
},
maxStars: {
mobile: 48,
desktop: 72,
}, },
vanishXJitter: { min: 10, max: 25 }, vanishXJitter: { min: 10, max: 25 },
speedTiers: { speedTiers: {
slow: { chance: 0.9, min: 0.003, max: 0.007 }, slow: { chance: 0.9, min: 0.00255, max: 0.00595 },
medium: { chance: 0.99, min: 0.007, max: 0.011 }, medium: { chance: 0.99, min: 0.00595, max: 0.00935 },
fast: { min: 0.011, max: 0.014 }, fast: { min: 0.00935, max: 0.0119 },
}, },
tail: { tail: {
pointChance: 0.82, pointChance: 0.82,
shortChance: 0.86, shortChance: 0.86,
pointRange: { min: 0, max: 2.5 }, pointRange: { min: 0.5, max: 2.5 },
shortRange: { min: 2.5, max: 3.8 }, shortRange: { min: 2.5, max: 3.8 },
longRange: { min: 4, max: 10 }, longRange: { min: 4, max: 10 },
cleanup: {
minAlphaToDraw: 0.08,
minMovementToDraw: 0.12,
minLineWidthToDraw: 0.86,
minTailLengthToDraw: 1,
},
}, },
spawnRadius: { spawnRadius: {
centerChance: 0.08, centerChance: 0.08,

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, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useI18n } from "@/features/i18n/model/useI18n"; import { useI18n } from "@/features/i18n/model/useI18n";
import {
isCrewPresenceRuntimeConfigSafe,
resolveCrewPresenceRuntimeConfig,
} from "@/shared/config/featureFlags";
import { DEBRIEF_STATUS_OPTIONS } from "@/shared/config/i18n"; import { DEBRIEF_STATUS_OPTIONS } from "@/shared/config/i18n";
import { findRouteById } from "@/shared/config/routes"; import { findRouteById } from "@/shared/config/routes";
import {
CrewPresenceNotificationTarget,
trackCrewPresenceEvent,
} from "@/shared/lib/analytics";
import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store"; import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store";
import { Voyage, VoyageStatus } from "@/shared/types"; import { Voyage, VoyageStatus } from "@/shared/types";
const FINISH_HOLD_MS = 1000; const FINISH_HOLD_MS = 1000;
const HOLD_STAGE_ONE_MS = 100; const HOLD_STAGE_ONE_MS = 100;
const HOLD_STAGE_ONE_PROGRESS = 0.2; 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 = { type FlightHudWidgetProps = {
voyage: Voyage | null; voyage: Voyage | null;
@@ -42,6 +149,7 @@ export function FlightHudWidget({
const [status, setStatus] = useState<VoyageStatus | null>(null); const [status, setStatus] = useState<VoyageStatus | null>(null);
const [progress, setProgress] = useState(""); const [progress, setProgress] = useState("");
const [holdProgress, setHoldProgress] = useState(0); const [holdProgress, setHoldProgress] = useState(0);
const [isCrewPanelCollapsed, setIsCrewPanelCollapsed] = useState(true);
const holdStartAtRef = useRef<number | null>(null); const holdStartAtRef = useRef<number | null>(null);
const holdRafRef = useRef<number | null>(null); const holdRafRef = useRef<number | null>(null);
const isHoldCompletedRef = useRef(false); const isHoldCompletedRef = useRef(false);
@@ -110,7 +218,10 @@ export function FlightHudWidget({
const stageTwoElapsed = elapsed - HOLD_STAGE_ONE_MS; const stageTwoElapsed = elapsed - HOLD_STAGE_ONE_MS;
const stageTwoDuration = FINISH_HOLD_MS - 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( return Math.min(
1, 1,
@@ -150,23 +261,107 @@ export function FlightHudWidget({
}; };
}, []); }, []);
if (!voyage) return null; const voyageRouteId = voyage?.routeId ?? "";
const route = voyageRouteId ? findRouteById(voyageRouteId) : null;
const route = findRouteById(voyage.routeId);
const routeName = route const routeName = route
? t(route.nameKey, undefined, voyage.routeName) ? t(route.nameKey, undefined, voyage?.routeName ?? "")
: voyage.routeName; : (voyage?.routeName ?? "");
const hasMissionText =
Boolean(voyage?.missionText?.trim()) &&
voyage?.missionText?.trim() !== "미입력";
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({ const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
value: option.value as VoyageStatus, value: option.value as VoyageStatus,
label: t(option.labelKey), label: t(option.labelKey),
desc: t(option.descKey), 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 ( return (
<> <>
<div className="absolute top-8 z-10 text-center"> <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"> <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> </span>
</div> </div>
@@ -176,6 +371,7 @@ export function FlightHudWidget({
{formattedTime} {formattedTime}
</div> </div>
{hasMissionText && (
<div className="relative z-10 mb-24 w-full max-w-2xl px-4"> <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"> <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"> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs">
@@ -186,6 +382,113 @@ export function FlightHudWidget({
</p> </p>
</section> </section>
</div> </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"> <div className="absolute bottom-12 z-10 flex gap-6">
<button <button