feat(flow): focus session api v2 웹 계약 전환
This commit is contained in:
@@ -1,12 +1,6 @@
|
||||
import { getSceneById, type SceneTheme } from '@/entities/scene';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
|
||||
const TIMER_PRESETS = [
|
||||
{ id: '25-5', label: '25/5', focusMinutes: 25 },
|
||||
{ id: '50-10', label: '50/10', focusMinutes: 50 },
|
||||
{ id: '90-20', label: '90/20', focusMinutes: 90 },
|
||||
] as const;
|
||||
|
||||
const DURATION_SUGGESTIONS = [25, 45, 70, 90] as const;
|
||||
|
||||
export interface AtmosphereOption {
|
||||
@@ -14,6 +8,7 @@ export interface AtmosphereOption {
|
||||
name: string;
|
||||
sceneId: string;
|
||||
soundPresetId: string | null;
|
||||
recommendedDurationMinutes: number;
|
||||
description: string;
|
||||
caption: string;
|
||||
scene: SceneTheme;
|
||||
@@ -25,6 +20,7 @@ const createAtmosphereOption = (
|
||||
name: string,
|
||||
sceneId: string,
|
||||
soundPresetId: string | null,
|
||||
recommendedDurationMinutes: number,
|
||||
description: string,
|
||||
caption: string,
|
||||
): AtmosphereOption => {
|
||||
@@ -38,6 +34,7 @@ const createAtmosphereOption = (
|
||||
name,
|
||||
sceneId,
|
||||
soundPresetId,
|
||||
recommendedDurationMinutes,
|
||||
description,
|
||||
caption,
|
||||
scene,
|
||||
@@ -52,6 +49,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Rain Window',
|
||||
'rain-window',
|
||||
'rain-focus',
|
||||
45,
|
||||
'비 소리 위로 조용히 문장을 붙잡기 좋은 흐름.',
|
||||
'조용한 시작',
|
||||
),
|
||||
@@ -60,6 +58,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Quiet Library',
|
||||
'quiet-library',
|
||||
'deep-white',
|
||||
70,
|
||||
'소음 없이 길게 읽고 정리할 때 안정적인 조합.',
|
||||
'길게 읽는 날',
|
||||
),
|
||||
@@ -68,6 +67,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Dawn Cafe',
|
||||
'dawn-cafe',
|
||||
'cafe-work',
|
||||
25,
|
||||
'가볍게 손을 움직이며 초안을 시작하기 좋은 온도.',
|
||||
'워밍업용',
|
||||
),
|
||||
@@ -76,6 +76,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Forest Draft',
|
||||
'forest',
|
||||
'forest-birds',
|
||||
50,
|
||||
'딥워크 진입 전에 숨을 고르게 만드는 기본 조합.',
|
||||
'기본 리듬',
|
||||
),
|
||||
@@ -84,6 +85,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Fireplace Glow',
|
||||
'fireplace',
|
||||
'fireplace',
|
||||
70,
|
||||
'밤에 닫히지 않는 일 하나를 끝까지 가져가고 싶을 때.',
|
||||
'늦은 시간용',
|
||||
),
|
||||
@@ -92,6 +94,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Deep Night Desk',
|
||||
'city-night',
|
||||
'deep-white',
|
||||
90,
|
||||
'도시의 불빛은 멀리 두고 화면 안의 일만 남기는 조합.',
|
||||
'몰입 유지',
|
||||
),
|
||||
@@ -100,6 +103,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Snow Light',
|
||||
'snow-mountain',
|
||||
'deep-white',
|
||||
45,
|
||||
'머리를 식히면서 구조를 정리해야 할 때 선명한 공기.',
|
||||
'정리용',
|
||||
),
|
||||
@@ -108,6 +112,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Sun Window',
|
||||
'sun-window',
|
||||
'silent',
|
||||
25,
|
||||
'과하게 자극적이지 않게 아침 에너지를 가져오는 장면.',
|
||||
'낮 시간용',
|
||||
),
|
||||
@@ -116,6 +121,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Ocean Still',
|
||||
'wave-sound',
|
||||
'ocean-calm',
|
||||
70,
|
||||
'넓은 생각이 필요한 기획이나 리서치에 어울리는 흐름.',
|
||||
'넓게 생각하기',
|
||||
),
|
||||
@@ -124,6 +130,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Orbit Night',
|
||||
'outer-space',
|
||||
'deep-white',
|
||||
90,
|
||||
'길고 깊은 블록에 들어갈 때 외부 자극을 멀리 밀어낸다.',
|
||||
'장시간 집중',
|
||||
),
|
||||
@@ -132,6 +139,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Rain Notes',
|
||||
'rain-window',
|
||||
'deep-white',
|
||||
50,
|
||||
'빗소리 대신 더 조용한 백색 소음으로 문장만 남긴 버전.',
|
||||
'더 낮은 자극',
|
||||
),
|
||||
@@ -140,6 +148,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||
'Quiet Pages',
|
||||
'quiet-library',
|
||||
'silent',
|
||||
45,
|
||||
'읽기와 쓰기 사이를 오갈 때 가장 얇은 존재감으로 머문다.',
|
||||
'완전 조용함',
|
||||
),
|
||||
@@ -179,29 +188,12 @@ export const sanitizeDurationDraft = (value: string) => {
|
||||
return String(Math.min(180, parsed));
|
||||
};
|
||||
|
||||
export const getTimerPresetMetaById = (timerPresetId: string) => {
|
||||
return TIMER_PRESETS.find((preset) => preset.id === timerPresetId) ?? TIMER_PRESETS[1];
|
||||
};
|
||||
|
||||
export const resolveNearestTimerPreset = (minutes: number) => {
|
||||
return TIMER_PRESETS.reduce((best, candidate) => {
|
||||
const bestDiff = Math.abs(best.focusMinutes - minutes);
|
||||
const candidateDiff = Math.abs(candidate.focusMinutes - minutes);
|
||||
|
||||
if (candidateDiff < bestDiff) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (candidateDiff === bestDiff && candidate.focusMinutes > best.focusMinutes) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return best;
|
||||
});
|
||||
export const formatDurationMinutesLabel = (minutes: number) => {
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
export const getRecommendedDurationMinutes = (option: AtmosphereOption) => {
|
||||
return getTimerPresetMetaById(option.scene.recommendedTimerPresetId).focusMinutes;
|
||||
return option.recommendedDurationMinutes;
|
||||
};
|
||||
|
||||
export const getAtmosphereOptionById = (id: string) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media'
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { preferencesApi } from '@/features/preferences/api/preferencesApi';
|
||||
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
|
||||
import { useFocusStats, type ReviewCarryHint } from '@/features/stats';
|
||||
@@ -18,24 +19,13 @@ import {
|
||||
findAtmosphereOptionForSelection,
|
||||
getAtmosphereOptionById,
|
||||
getRecommendedDurationMinutes,
|
||||
getTimerPresetMetaById,
|
||||
parseDurationMinutes,
|
||||
resolveNearestTimerPreset,
|
||||
sanitizeDurationDraft,
|
||||
} from '../model/atmosphereEntry';
|
||||
import { AppAtmosphereEntryShell } from './AppAtmosphereEntryShell';
|
||||
|
||||
const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
|
||||
const DEFAULT_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id;
|
||||
const DEFAULT_TIMER_ID = '50-10';
|
||||
const REVIEW_ENTRY_PRESETS = {
|
||||
'forest-50-10': {
|
||||
sceneId: DEFAULT_SCENE_ID,
|
||||
soundPresetId: DEFAULT_SOUND_ID,
|
||||
timerPresetId: DEFAULT_TIMER_ID,
|
||||
label: '숲 · Forest Birds',
|
||||
},
|
||||
} as const;
|
||||
const DEFAULT_ATMOSPHERE =
|
||||
findAtmosphereOptionForSelection(DEFAULT_SCENE_ID, DEFAULT_SOUND_ID) ?? ATMOSPHERE_OPTIONS[0];
|
||||
|
||||
@@ -67,7 +57,7 @@ const entryCopy = {
|
||||
reviewReturnBodySmaller: 'Instead of extending time, a smaller goal makes it easier to keep going.',
|
||||
reviewReturnBodyClosure: 'Think about where to close the block first to finish strong.',
|
||||
reviewReturnBodyStart: 'Just aim to open one more short session to build momentum.',
|
||||
reviewReturnRitualLabel: 'Recommended Ritual · Forest · Forest Birds',
|
||||
reviewReturnRitualLabel: 'Recommended Atmosphere',
|
||||
paywallLead: 'Calm Session OS PRO',
|
||||
paywallBody: 'Pro enables faster rituals and deeper reviews for seamless entry and return.',
|
||||
};
|
||||
@@ -101,29 +91,22 @@ export const FocusDashboardWidget = () => {
|
||||
const { sceneAssetMap } = useMediaCatalog();
|
||||
const { summary: weeklySummary } = useFocusStats();
|
||||
|
||||
const reviewEntryPreset = searchParams.get('entryPreset');
|
||||
const reviewEntryPresetConfig = useMemo(() => {
|
||||
if (!reviewEntryPreset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return REVIEW_ENTRY_PRESETS[reviewEntryPreset as keyof typeof REVIEW_ENTRY_PRESETS] ?? null;
|
||||
}, [reviewEntryPreset]);
|
||||
const reviewEntryAtmosphereId = searchParams.get('entryAtmosphereId');
|
||||
const reviewEntryDurationMinutes = searchParams.get('entryDurationMinutes');
|
||||
const initialAtmosphere = useMemo(() => {
|
||||
return (
|
||||
findAtmosphereOptionForSelection(
|
||||
reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID,
|
||||
reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID,
|
||||
) ?? DEFAULT_ATMOSPHERE
|
||||
(reviewEntryAtmosphereId ? ATMOSPHERE_OPTIONS.find((option) => option.id === reviewEntryAtmosphereId) : null) ??
|
||||
DEFAULT_ATMOSPHERE
|
||||
);
|
||||
}, [reviewEntryPresetConfig]);
|
||||
}, [reviewEntryAtmosphereId]);
|
||||
const initialDurationMinutes = useMemo(() => {
|
||||
if (reviewEntryPresetConfig) {
|
||||
return getTimerPresetMetaById(reviewEntryPresetConfig.timerPresetId).focusMinutes;
|
||||
const parsed = Number(reviewEntryDurationMinutes);
|
||||
if (Number.isFinite(parsed) && parsed >= 5 && parsed <= 180) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return getRecommendedDurationMinutes(initialAtmosphere);
|
||||
}, [initialAtmosphere, reviewEntryPresetConfig]);
|
||||
}, [initialAtmosphere, reviewEntryDurationMinutes]);
|
||||
|
||||
const [goalDraft, setGoalDraft] = useState('');
|
||||
const [durationDraft, setDurationDraft] = useState(() => String(initialDurationMinutes));
|
||||
@@ -150,11 +133,6 @@ export const FocusDashboardWidget = () => {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}, [durationDraft]);
|
||||
const parsedDurationMinutes = parseDurationMinutes(durationDraft);
|
||||
const resolvedTimerPreset = useMemo(() => {
|
||||
const targetMinutes =
|
||||
parsedDurationMinutes ?? getRecommendedDurationMinutes(selectedAtmosphere);
|
||||
return resolveNearestTimerPreset(targetMinutes);
|
||||
}, [parsedDurationMinutes, selectedAtmosphere]);
|
||||
|
||||
const activeScene = useMemo(() => {
|
||||
return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0];
|
||||
@@ -184,7 +162,9 @@ export const FocusDashboardWidget = () => {
|
||||
const reviewReturnCopy =
|
||||
normalizedReviewCarryHint !== null ? reviewCarryCopyByHint[normalizedReviewCarryHint] : null;
|
||||
const reviewReturnRitualLabel =
|
||||
isPro && reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : null;
|
||||
isPro && reviewEntryAtmosphereId
|
||||
? `${entryCopy.reviewReturnRitualLabel} · ${initialAtmosphere.name} · ${initialDurationMinutes}m`
|
||||
: null;
|
||||
const reviewTeaserTitle = isPro ? entryCopy.reviewTitlePro : entryCopy.reviewTitle;
|
||||
const durationHelper =
|
||||
rawDurationValue !== null && rawDurationValue < 5
|
||||
@@ -227,6 +207,43 @@ export const FocusDashboardWidget = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReviewReturn) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void preferencesApi
|
||||
.getFocusPreferences()
|
||||
.then((preferences) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preferences.defaultAtmosphereId) {
|
||||
const preferredAtmosphere = ATMOSPHERE_OPTIONS.find(
|
||||
(option) => option.id === preferences.defaultAtmosphereId,
|
||||
);
|
||||
|
||||
if (preferredAtmosphere) {
|
||||
setSelectedAtmosphereId(preferredAtmosphere.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEditedDuration && preferences.defaultDurationMinutes) {
|
||||
setDurationDraft(String(preferences.defaultDurationMinutes));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Preference hydration should not block entry.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasEditedDuration, isReviewReturn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingSession && hasCurrentSession) {
|
||||
router.replace('/space');
|
||||
@@ -276,9 +293,10 @@ export const FocusDashboardWidget = () => {
|
||||
await focusSessionApi.startSession({
|
||||
goal: trimmedGoal,
|
||||
microStep: null,
|
||||
atmosphereId: selectedAtmosphere.id,
|
||||
focusDurationMinutes: parsedDurationMinutes,
|
||||
sceneId: selectedAtmosphere.sceneId,
|
||||
soundPresetId: selectedAtmosphere.soundPresetId,
|
||||
timerPresetId: resolvedTimerPreset.id,
|
||||
entryPoint: 'space-setup',
|
||||
});
|
||||
router.push('/space');
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
DEFAULT_PRESET_OPTIONS,
|
||||
NOTIFICATION_INTENSITY_OPTIONS,
|
||||
} from '@/shared/config/settingsOptions';
|
||||
import {
|
||||
ATMOSPHERE_OPTIONS,
|
||||
ENTRY_DURATION_SUGGESTIONS,
|
||||
} from '@/widgets/focus-dashboard/model/atmosphereEntry';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useUserFocusPreferences } from '@/features/preferences';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
@@ -114,24 +117,55 @@ export const SettingsPanelWidget = () => {
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
|
||||
<h2 className="text-base font-semibold text-brand-dark">{settings.defaultPresetTitle}</h2>
|
||||
<p className="mt-1 text-sm text-brand-dark/64">{settings.defaultPresetDescription}</p>
|
||||
<h2 className="text-base font-semibold text-brand-dark">{settings.defaultDurationTitle}</h2>
|
||||
<p className="mt-1 text-sm text-brand-dark/64">{settings.defaultDurationDescription}</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{DEFAULT_PRESET_OPTIONS.map((preset) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ENTRY_DURATION_SUGGESTIONS.map((minutes) => (
|
||||
<button
|
||||
key={minutes}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void updatePreferences({ defaultDurationMinutes: minutes });
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs transition-colors',
|
||||
preferences.defaultDurationMinutes === minutes
|
||||
? 'border-brand-primary/45 bg-brand-soft/60 text-brand-dark'
|
||||
: 'border-brand-dark/18 bg-white/75 text-brand-dark/78 hover:bg-white',
|
||||
)}
|
||||
>
|
||||
{minutes}분
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
|
||||
<h2 className="text-base font-semibold text-brand-dark">{settings.defaultAtmosphereTitle}</h2>
|
||||
<p className="mt-1 text-sm text-brand-dark/64">{settings.defaultAtmosphereDescription}</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{ATMOSPHERE_OPTIONS.slice(0, 8).map((atmosphere) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
key={atmosphere.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void updatePreferences({ defaultPresetId: preset.id });
|
||||
void updatePreferences({
|
||||
defaultAtmosphereId: atmosphere.id,
|
||||
defaultSceneId: atmosphere.sceneId,
|
||||
defaultSoundPresetId: atmosphere.soundPresetId,
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
'w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
preferences.defaultPresetId === preset.id
|
||||
preferences.defaultAtmosphereId === atmosphere.id
|
||||
? 'border-brand-primary/45 bg-brand-soft/58 text-brand-dark'
|
||||
: 'border-brand-dark/16 bg-white/72 text-brand-dark/82 hover:bg-white',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
<p className="font-medium">{atmosphere.name}</p>
|
||||
<p className="mt-1 text-xs text-brand-dark/60">{atmosphere.caption}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { SceneTheme } from '@/entities/scene';
|
||||
import type {
|
||||
GoalChip,
|
||||
SoundPreset,
|
||||
TimerPreset,
|
||||
} from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { SceneSelectCarousel } from '@/features/scene-select';
|
||||
@@ -22,16 +21,16 @@ interface SpaceSetupDrawerWidgetProps {
|
||||
scenes: SceneTheme[];
|
||||
sceneAssetMap?: SceneAssetMap;
|
||||
selectedSceneId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedDurationLabel: string;
|
||||
selectedSoundPresetId: string;
|
||||
goalInput: string;
|
||||
selectedGoalId: string | null;
|
||||
goalChips: GoalChip[];
|
||||
soundPresets: SoundPreset[];
|
||||
timerPresets: TimerPreset[];
|
||||
durationOptions: readonly number[];
|
||||
canStart: boolean;
|
||||
onSceneSelect: (sceneId: string) => void;
|
||||
onTimerSelect: (timerLabel: string) => void;
|
||||
onDurationSelect: (durationMinutes: number) => void;
|
||||
onSoundSelect: (soundPresetId: string) => void;
|
||||
onGoalChange: (value: string) => void;
|
||||
onGoalChipSelect: (chip: GoalChip) => void;
|
||||
@@ -81,16 +80,16 @@ export const SpaceSetupDrawerWidget = ({
|
||||
scenes,
|
||||
sceneAssetMap,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
selectedDurationLabel,
|
||||
selectedSoundPresetId,
|
||||
goalInput,
|
||||
selectedGoalId,
|
||||
goalChips,
|
||||
soundPresets,
|
||||
timerPresets,
|
||||
durationOptions,
|
||||
canStart,
|
||||
onSceneSelect,
|
||||
onTimerSelect,
|
||||
onDurationSelect,
|
||||
onSoundSelect,
|
||||
onGoalChange,
|
||||
onGoalChipSelect,
|
||||
@@ -208,7 +207,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
/>
|
||||
<SummaryChip
|
||||
label={setup.timerLabel}
|
||||
value={selectedTimerLabel}
|
||||
value={selectedDurationLabel}
|
||||
open={openPopover === 'timer'}
|
||||
onClick={() => togglePopover('timer')}
|
||||
/>
|
||||
@@ -237,15 +236,15 @@ export const SpaceSetupDrawerWidget = ({
|
||||
{openPopover === 'timer' ? (
|
||||
<div className="absolute left-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{timerPresets.slice(0, 3).map((preset) => {
|
||||
const selected = preset.label === selectedTimerLabel;
|
||||
{durationOptions.map((minutes) => {
|
||||
const selected = `${minutes}m` === selectedDurationLabel;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
key={minutes}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTimerSelect(preset.label);
|
||||
onDurationSelect(minutes);
|
||||
setOpenPopover(null);
|
||||
}}
|
||||
className={cn(
|
||||
@@ -255,7 +254,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
{minutes}m
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,12 +3,12 @@ export type SessionEntryPoint = 'space-setup' | 'goal-complete' | 'resume-restor
|
||||
|
||||
export type SelectionOverride = {
|
||||
sound: boolean;
|
||||
timer: boolean;
|
||||
duration: boolean;
|
||||
};
|
||||
|
||||
export interface StoredWorkspaceSelection {
|
||||
sceneId?: string;
|
||||
timerPresetId?: string;
|
||||
durationMinutes?: number;
|
||||
soundPresetId?: string;
|
||||
goal?: string;
|
||||
override?: Partial<SelectionOverride>;
|
||||
|
||||
@@ -16,11 +16,16 @@ import type { FocusSession } from '@/features/focus-session';
|
||||
import { preferencesApi } from '@/features/preferences/api/preferencesApi';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||
import {
|
||||
findAtmosphereOptionForSelection,
|
||||
getAtmosphereOptionById,
|
||||
getRecommendedDurationMinutes,
|
||||
} from '@/widgets/focus-dashboard/model/atmosphereEntry';
|
||||
import type { SelectionOverride } from './types';
|
||||
import {
|
||||
formatDurationMinutesLabel,
|
||||
readStoredWorkspaceSelection,
|
||||
resolveTimerLabelFromPresetId,
|
||||
resolveTimerPresetIdFromLabel,
|
||||
resolveInitialDurationMinutes,
|
||||
} from './workspaceSelection';
|
||||
import { useWorkspacePersistence } from './useWorkspacePersistence';
|
||||
import { useWorkspaceMediaDiagnostics } from './useWorkspaceMediaDiagnostics';
|
||||
@@ -29,12 +34,7 @@ interface UseSpaceWorkspaceSelectionParams {
|
||||
initialSceneId: string;
|
||||
initialGoal: string;
|
||||
initialFocusPlanItemId: string | null;
|
||||
initialTimerLabel: string;
|
||||
sceneQuery: string | null;
|
||||
goalQuery: string;
|
||||
soundQuery: string | null;
|
||||
timerQuery: string | null;
|
||||
hasQueryOverrides: boolean;
|
||||
initialDurationMinutes: number;
|
||||
currentSession: FocusSession | null;
|
||||
sceneAssetMap: SceneAssetMap;
|
||||
selectedPresetId: string;
|
||||
@@ -46,6 +46,7 @@ interface UseSpaceWorkspaceSelectionParams {
|
||||
updateCurrentSelection: (payload: {
|
||||
sceneId?: string;
|
||||
soundPresetId?: string | null;
|
||||
atmosphereId?: string | null;
|
||||
}) => Promise<FocusSession | null>;
|
||||
mediaCatalogError: string | null;
|
||||
usedFallbackManifest: boolean;
|
||||
@@ -66,12 +67,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
initialSceneId,
|
||||
initialGoal,
|
||||
initialFocusPlanItemId,
|
||||
initialTimerLabel,
|
||||
sceneQuery,
|
||||
goalQuery,
|
||||
soundQuery,
|
||||
timerQuery,
|
||||
hasQueryOverrides,
|
||||
initialDurationMinutes,
|
||||
currentSession,
|
||||
sceneAssetMap,
|
||||
selectedPresetId,
|
||||
@@ -86,7 +82,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
hasResolvedManifest,
|
||||
}: UseSpaceWorkspaceSelectionParams) => {
|
||||
const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId);
|
||||
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
|
||||
const [selectedDurationMinutes, setSelectedDurationMinutes] = useState(initialDurationMinutes);
|
||||
const [goalInput, setGoalInput] = useState(initialGoal);
|
||||
const [linkedFocusPlanItemId, setLinkedFocusPlanItemId] = useState<string | null>(initialFocusPlanItemId);
|
||||
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
||||
@@ -95,7 +91,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
const [hasHydratedSelection, setHasHydratedSelection] = useState(false);
|
||||
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
|
||||
sound: false,
|
||||
timer: false,
|
||||
duration: false,
|
||||
});
|
||||
const didHydrateServerPreferencesRef = useRef(false);
|
||||
|
||||
@@ -110,6 +106,10 @@ export const useSpaceWorkspaceSelection = ({
|
||||
}, [selectedScene]);
|
||||
|
||||
const canStart = goalInput.trim().length > 0;
|
||||
const selectedDurationLabel = useMemo(
|
||||
() => formatDurationMinutesLabel(selectedDurationMinutes),
|
||||
[selectedDurationMinutes],
|
||||
);
|
||||
|
||||
const applyRecommendedSelections = useCallback((
|
||||
sceneId: string,
|
||||
@@ -121,12 +121,17 @@ export const useSpaceWorkspaceSelection = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!overrideState.timer) {
|
||||
const recommendedTimerLabel = resolveTimerLabelFromPresetId(scene.recommendedTimerPresetId);
|
||||
if (!overrideState.duration) {
|
||||
const recommendedAtmosphere =
|
||||
findAtmosphereOptionForSelection(sceneId, scene.recommendedSoundPresetId) ??
|
||||
findAtmosphereOptionForSelection(sceneId);
|
||||
|
||||
if (recommendedTimerLabel) {
|
||||
setSelectedTimerLabel(recommendedTimerLabel);
|
||||
}
|
||||
setSelectedDurationMinutes(
|
||||
resolveInitialDurationMinutes(
|
||||
undefined,
|
||||
recommendedAtmosphere ? getRecommendedDurationMinutes(recommendedAtmosphere) : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -140,12 +145,16 @@ export const useSpaceWorkspaceSelection = ({
|
||||
const persistSpaceSelection = useCallback((selection: {
|
||||
sceneId?: string;
|
||||
soundPresetId?: string | null;
|
||||
durationMinutes?: number;
|
||||
}) => {
|
||||
const preferencePayload: {
|
||||
defaultAtmosphereId?: string | null;
|
||||
defaultDurationMinutes?: number | null;
|
||||
defaultSceneId?: string | null;
|
||||
defaultSoundPresetId?: string | null;
|
||||
} = {};
|
||||
const currentSessionPayload: {
|
||||
atmosphereId?: string | null;
|
||||
sceneId?: string;
|
||||
soundPresetId?: string | null;
|
||||
} = {};
|
||||
@@ -160,6 +169,18 @@ export const useSpaceWorkspaceSelection = ({
|
||||
currentSessionPayload.soundPresetId = selection.soundPresetId;
|
||||
}
|
||||
|
||||
if (selection.durationMinutes !== undefined) {
|
||||
preferencePayload.defaultDurationMinutes = selection.durationMinutes;
|
||||
}
|
||||
|
||||
const nextSceneId = selection.sceneId ?? selectedSceneId;
|
||||
const nextSoundPresetId =
|
||||
selection.soundPresetId === undefined ? selectedPresetId : selection.soundPresetId;
|
||||
const nextAtmosphere = findAtmosphereOptionForSelection(nextSceneId, nextSoundPresetId);
|
||||
|
||||
preferencePayload.defaultAtmosphereId = nextAtmosphere?.id ?? null;
|
||||
currentSessionPayload.atmosphereId = nextAtmosphere?.id ?? null;
|
||||
|
||||
void (async () => {
|
||||
const [preferencesResult, sessionResult] = await Promise.allSettled([
|
||||
preferencesApi.updateFocusPreferences(preferencePayload),
|
||||
@@ -181,7 +202,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [currentSession, pushStatusLine, updateCurrentSelection]);
|
||||
}, [currentSession, pushStatusLine, selectedPresetId, selectedSceneId, updateCurrentSelection]);
|
||||
|
||||
const handleSelectScene = useCallback((sceneId: string) => {
|
||||
void (async () => {
|
||||
@@ -215,21 +236,26 @@ export const useSpaceWorkspaceSelection = ({
|
||||
unlockPlayback,
|
||||
]);
|
||||
|
||||
const handleSelectTimer = useCallback((timerLabel: string, markOverride = false) => {
|
||||
setSelectedTimerLabel(timerLabel);
|
||||
const handleSelectDuration = useCallback((durationMinutes: number, markOverride = false) => {
|
||||
if (!Number.isFinite(durationMinutes) || durationMinutes < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedDurationMinutes(durationMinutes);
|
||||
persistSpaceSelection({ durationMinutes });
|
||||
|
||||
if (!markOverride) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionOverride((current) => {
|
||||
if (current.timer) {
|
||||
if (current.duration) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, timer: true };
|
||||
return { ...current, duration: true };
|
||||
});
|
||||
}, []);
|
||||
}, [persistSpaceSelection]);
|
||||
|
||||
const handleSelectSound = useCallback((presetId: string, markOverride = false) => {
|
||||
void (async () => {
|
||||
@@ -287,17 +313,16 @@ export const useSpaceWorkspaceSelection = ({
|
||||
const storedSelection = readStoredWorkspaceSelection();
|
||||
const restoredSelectionOverride: SelectionOverride = {
|
||||
sound: Boolean(storedSelection.override?.sound),
|
||||
timer: Boolean(storedSelection.override?.timer),
|
||||
duration: Boolean(storedSelection.override?.duration),
|
||||
};
|
||||
const restoredSceneId =
|
||||
!sceneQuery && storedSelection.sceneId && getSceneById(storedSelection.sceneId)
|
||||
storedSelection.sceneId && getSceneById(storedSelection.sceneId)
|
||||
? normalizeSceneId(storedSelection.sceneId)
|
||||
: null;
|
||||
const restoredTimerLabel = !timerQuery
|
||||
? resolveTimerLabelFromPresetId(storedSelection.timerPresetId)
|
||||
: null;
|
||||
const restoredDurationMinutes =
|
||||
typeof storedSelection.durationMinutes === 'number' ? storedSelection.durationMinutes : null;
|
||||
const restoredSoundPresetId =
|
||||
!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)
|
||||
storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)
|
||||
? storedSelection.soundPresetId
|
||||
: null;
|
||||
const restoredGoal = storedSelection.goal?.trim() ?? '';
|
||||
@@ -308,15 +333,15 @@ export const useSpaceWorkspaceSelection = ({
|
||||
setSelectedSceneId(restoredSceneId);
|
||||
}
|
||||
|
||||
if (restoredTimerLabel) {
|
||||
setSelectedTimerLabel(restoredTimerLabel);
|
||||
if (restoredDurationMinutes) {
|
||||
setSelectedDurationMinutes(restoredDurationMinutes);
|
||||
}
|
||||
|
||||
if (restoredSoundPresetId) {
|
||||
setSelectedPresetId(restoredSoundPresetId);
|
||||
}
|
||||
|
||||
if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) {
|
||||
if (restoredGoal.length > 0) {
|
||||
setResumeGoal(restoredGoal);
|
||||
setShowResumePrompt(true);
|
||||
}
|
||||
@@ -327,7 +352,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [goalQuery, hasQueryOverrides, sceneQuery, setSelectedPresetId, soundQuery, timerQuery]);
|
||||
}, [setSelectedPresetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydratedSelection || didHydrateServerPreferencesRef.current) {
|
||||
@@ -340,19 +365,27 @@ export const useSpaceWorkspaceSelection = ({
|
||||
void preferencesApi
|
||||
.getFocusPreferences()
|
||||
.then((preferences) => {
|
||||
if (cancelled || currentSession || hasQueryOverrides) {
|
||||
if (cancelled || currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPreferredSceneId = normalizeSceneId(preferences.defaultSceneId);
|
||||
const preferredAtmosphere =
|
||||
preferences.defaultAtmosphereId
|
||||
? getAtmosphereOptionById(preferences.defaultAtmosphereId)
|
||||
: null;
|
||||
const normalizedPreferredSceneId = normalizeSceneId(
|
||||
preferredAtmosphere?.sceneId ?? preferences.defaultSceneId,
|
||||
);
|
||||
const nextSceneId =
|
||||
normalizedPreferredSceneId && getSceneById(normalizedPreferredSceneId)
|
||||
? normalizedPreferredSceneId
|
||||
: null;
|
||||
const nextSoundPresetId =
|
||||
preferences.defaultSoundPresetId &&
|
||||
SOUND_PRESETS.some((preset) => preset.id === preferences.defaultSoundPresetId)
|
||||
? preferences.defaultSoundPresetId
|
||||
(preferredAtmosphere?.soundPresetId ?? preferences.defaultSoundPresetId) &&
|
||||
SOUND_PRESETS.some(
|
||||
(preset) => preset.id === (preferredAtmosphere?.soundPresetId ?? preferences.defaultSoundPresetId),
|
||||
)
|
||||
? (preferredAtmosphere?.soundPresetId ?? preferences.defaultSoundPresetId)
|
||||
: null;
|
||||
|
||||
if (nextSceneId) {
|
||||
@@ -363,6 +396,11 @@ export const useSpaceWorkspaceSelection = ({
|
||||
setSelectedPresetId(nextSoundPresetId);
|
||||
setSelectionOverride((current) => ({ ...current, sound: true }));
|
||||
}
|
||||
|
||||
if (preferences.defaultDurationMinutes) {
|
||||
setSelectedDurationMinutes(preferences.defaultDurationMinutes);
|
||||
setSelectionOverride((current) => ({ ...current, duration: true }));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Focus preference load failure should not block entering the space.
|
||||
@@ -371,15 +409,13 @@ export const useSpaceWorkspaceSelection = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentSession, hasHydratedSelection, hasQueryOverrides, setSelectedPresetId]);
|
||||
}, [currentSession, hasHydratedSelection, setSelectedPresetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTimerLabel =
|
||||
resolveTimerLabelFromPresetId(currentSession.timerPresetId) ?? selectedTimerLabel;
|
||||
const nextSoundPresetId =
|
||||
currentSession.soundPresetId &&
|
||||
SOUND_PRESETS.some((preset) => preset.id === currentSession.soundPresetId)
|
||||
@@ -387,7 +423,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
: selectedPresetId;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
setSelectedSceneId(normalizeSceneId(currentSession.sceneId) ?? currentSession.sceneId);
|
||||
setSelectedTimerLabel(nextTimerLabel);
|
||||
setSelectedDurationMinutes(Math.max(5, Math.round(currentSession.focusDurationSeconds / 60)));
|
||||
setSelectedPresetId(nextSoundPresetId);
|
||||
setGoalInput(currentSession.goal);
|
||||
setLinkedFocusPlanItemId(currentSession.focusPlanItemId ?? null);
|
||||
@@ -398,12 +434,12 @@ export const useSpaceWorkspaceSelection = ({
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]);
|
||||
}, [currentSession, selectedPresetId, setSelectedPresetId]);
|
||||
|
||||
useWorkspacePersistence({
|
||||
hasHydratedSelection,
|
||||
selectedScene,
|
||||
selectedTimerPresetId: resolveTimerPresetIdFromLabel(selectedTimerLabel),
|
||||
selectedDurationMinutes,
|
||||
selectedPresetId,
|
||||
goalInput,
|
||||
showResumePrompt,
|
||||
@@ -422,7 +458,8 @@ export const useSpaceWorkspaceSelection = ({
|
||||
|
||||
return {
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
selectedDurationMinutes,
|
||||
selectedDurationLabel,
|
||||
goalInput,
|
||||
linkedFocusPlanItemId,
|
||||
selectedGoalId,
|
||||
@@ -440,7 +477,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
setShowResumePrompt,
|
||||
setResumeGoal,
|
||||
handleSelectScene,
|
||||
handleSelectTimer,
|
||||
handleSelectDuration,
|
||||
handleSelectSound,
|
||||
handleGoalChipSelect,
|
||||
handleGoalChange,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { FocusSession } from '@/features/focus-session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||
import { resolveTimerPresetIdFromLabel } from './workspaceSelection';
|
||||
import { findAtmosphereOptionForSelection } from '@/widgets/focus-dashboard/model/atmosphereEntry';
|
||||
import type { SessionEntryPoint, WorkspaceMode } from './types';
|
||||
|
||||
interface UseSpaceWorkspaceSessionControlsParams {
|
||||
@@ -19,16 +19,17 @@ interface UseSpaceWorkspaceSessionControlsParams {
|
||||
goalInput: string;
|
||||
linkedFocusPlanItemId: string | null;
|
||||
selectedSceneId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedDurationMinutes: number;
|
||||
selectedPresetId: string;
|
||||
soundPlaybackError: string | null;
|
||||
pushStatusLine: (payload: HudStatusLinePayload) => void;
|
||||
unlockPlayback: (requestedUrl?: string | null) => Promise<boolean>;
|
||||
resolveSoundPlaybackUrl: (presetId: string) => string | null;
|
||||
startSession: (input: {
|
||||
atmosphereId: string;
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
timerPresetId: string;
|
||||
focusDurationMinutes: number;
|
||||
soundPresetId: string | null;
|
||||
focusPlanItemId?: string;
|
||||
entryPoint: SessionEntryPoint;
|
||||
@@ -50,8 +51,9 @@ interface UseSpaceWorkspaceSessionControlsParams {
|
||||
advanceGoal: (input: {
|
||||
completedGoal: string;
|
||||
nextGoal: string;
|
||||
sceneId: string;
|
||||
timerPresetId: string;
|
||||
sceneId?: string;
|
||||
atmosphereId?: string | null;
|
||||
focusDurationMinutes?: number;
|
||||
soundPresetId: string;
|
||||
focusPlanItemId?: string;
|
||||
}) => Promise<{ nextSession: FocusSession } | null>;
|
||||
@@ -74,7 +76,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
goalInput,
|
||||
linkedFocusPlanItemId,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
selectedDurationMinutes,
|
||||
selectedPresetId,
|
||||
soundPlaybackError,
|
||||
pushStatusLine,
|
||||
@@ -122,16 +124,17 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
|
||||
const startFocusFlow = useCallback(async () => {
|
||||
const trimmedGoal = goalInput.trim();
|
||||
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
|
||||
const selectedAtmosphere = findAtmosphereOptionForSelection(selectedSceneId, selectedPresetId);
|
||||
|
||||
if (!trimmedGoal || !timerPresetId) {
|
||||
if (!trimmedGoal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startedSession = await startSession({
|
||||
atmosphereId: selectedAtmosphere?.id ?? selectedSceneId,
|
||||
sceneId: selectedSceneId,
|
||||
goal: trimmedGoal,
|
||||
timerPresetId,
|
||||
focusDurationMinutes: selectedDurationMinutes,
|
||||
soundPresetId: selectedPresetId,
|
||||
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
|
||||
entryPoint: pendingSessionEntryPoint,
|
||||
@@ -150,9 +153,9 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
goalInput,
|
||||
pendingSessionEntryPoint,
|
||||
pushStatusLine,
|
||||
selectedDurationMinutes,
|
||||
selectedPresetId,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
linkedFocusPlanItemId,
|
||||
setPreviewPlaybackState,
|
||||
startSession,
|
||||
@@ -248,9 +251,11 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
const handleGoalAdvance = useCallback(async (nextGoal: string) => {
|
||||
const trimmedNextGoal = nextGoal.trim();
|
||||
const trimmedCurrentGoal = goalInput.trim();
|
||||
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
|
||||
const selectedAtmosphere =
|
||||
findAtmosphereOptionForSelection(selectedSceneId, selectedPresetId)
|
||||
?? (currentSession?.atmosphereId ? { id: currentSession.atmosphereId } : null);
|
||||
|
||||
if (!trimmedNextGoal || !trimmedCurrentGoal || !timerPresetId || !currentSession) {
|
||||
if (!trimmedNextGoal || !trimmedCurrentGoal || !currentSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -259,8 +264,9 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
const nextState = await advanceGoal({
|
||||
completedGoal: trimmedCurrentGoal,
|
||||
nextGoal: trimmedNextGoal,
|
||||
atmosphereId: selectedAtmosphere?.id ?? null,
|
||||
focusDurationMinutes: selectedDurationMinutes,
|
||||
sceneId: selectedSceneId,
|
||||
timerPresetId,
|
||||
soundPresetId: selectedPresetId,
|
||||
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
|
||||
});
|
||||
@@ -290,9 +296,9 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
linkedFocusPlanItemId,
|
||||
pushStatusLine,
|
||||
resolveSoundPlaybackUrl,
|
||||
selectedDurationMinutes,
|
||||
selectedPresetId,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
setGoalInput,
|
||||
setLinkedFocusPlanItemId,
|
||||
setPendingSessionEntryPoint,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SceneTheme } from '@/entities/scene';
|
||||
interface UseWorkspacePersistenceParams {
|
||||
hasHydratedSelection: boolean;
|
||||
selectedScene: SceneTheme;
|
||||
selectedTimerPresetId: string | undefined;
|
||||
selectedDurationMinutes: number;
|
||||
selectedPresetId: string;
|
||||
goalInput: string;
|
||||
showResumePrompt: boolean;
|
||||
@@ -19,7 +19,7 @@ interface UseWorkspacePersistenceParams {
|
||||
export const useWorkspacePersistence = ({
|
||||
hasHydratedSelection,
|
||||
selectedScene,
|
||||
selectedTimerPresetId,
|
||||
selectedDurationMinutes,
|
||||
selectedPresetId,
|
||||
goalInput,
|
||||
showResumePrompt,
|
||||
@@ -41,7 +41,7 @@ export const useWorkspacePersistence = ({
|
||||
WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
sceneId: selectedScene.id,
|
||||
timerPresetId: selectedTimerPresetId,
|
||||
durationMinutes: selectedDurationMinutes,
|
||||
soundPresetId: selectedPresetId,
|
||||
goal: normalizedGoal,
|
||||
override: selectionOverride,
|
||||
@@ -53,7 +53,7 @@ export const useWorkspacePersistence = ({
|
||||
resumeGoal,
|
||||
selectedPresetId,
|
||||
selectedScene.id,
|
||||
selectedTimerPresetId,
|
||||
selectedDurationMinutes,
|
||||
selectionOverride,
|
||||
showResumePrompt,
|
||||
]);
|
||||
|
||||
@@ -3,20 +3,16 @@ import {
|
||||
normalizeSceneId,
|
||||
SCENE_THEMES,
|
||||
} from '@/entities/scene';
|
||||
import {
|
||||
SOUND_PRESETS,
|
||||
TIMER_PRESETS,
|
||||
type TimerPreset,
|
||||
} from '@/entities/session';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
|
||||
export type SelectionOverride = {
|
||||
sound: boolean;
|
||||
timer: boolean;
|
||||
duration: boolean;
|
||||
};
|
||||
|
||||
export interface StoredWorkspaceSelection {
|
||||
sceneId?: string;
|
||||
timerPresetId?: string;
|
||||
durationMinutes?: number;
|
||||
soundPresetId?: string;
|
||||
goal?: string;
|
||||
override?: Partial<SelectionOverride>;
|
||||
@@ -85,56 +81,32 @@ export const resolveInitialSoundPreset = (
|
||||
return SOUND_PRESETS[0].id;
|
||||
};
|
||||
|
||||
export const TIMER_SELECTION_PRESETS = TIMER_PRESETS.filter(
|
||||
(preset): preset is TimerPreset & { focusMinutes: number; breakMinutes: number } =>
|
||||
typeof preset.focusMinutes === 'number' && typeof preset.breakMinutes === 'number',
|
||||
).slice(0, 3);
|
||||
export const DURATION_SELECTION_OPTIONS = [25, 45, 70, 90] as const;
|
||||
|
||||
export const resolveTimerLabelFromPresetId = (presetId?: string) => {
|
||||
if (!presetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.id === presetId);
|
||||
|
||||
if (!preset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preset.label;
|
||||
};
|
||||
|
||||
export const resolveTimerPresetIdFromLabel = (timerLabel: string) => {
|
||||
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.label === timerLabel);
|
||||
return preset?.id;
|
||||
};
|
||||
|
||||
export const resolveInitialTimerLabel = (
|
||||
timerLabelFromQuery: string | null,
|
||||
storedPresetId?: string,
|
||||
recommendedPresetId?: string,
|
||||
export const resolveInitialDurationMinutes = (
|
||||
storedDurationMinutes?: number,
|
||||
recommendedDurationMinutes?: number,
|
||||
) => {
|
||||
if (timerLabelFromQuery && TIMER_SELECTION_PRESETS.some((preset) => preset.label === timerLabelFromQuery)) {
|
||||
return timerLabelFromQuery;
|
||||
if (typeof storedDurationMinutes === 'number' && storedDurationMinutes >= 5 && storedDurationMinutes <= 180) {
|
||||
return storedDurationMinutes;
|
||||
}
|
||||
|
||||
const storedLabel = resolveTimerLabelFromPresetId(storedPresetId);
|
||||
|
||||
if (storedLabel) {
|
||||
return storedLabel;
|
||||
if (
|
||||
typeof recommendedDurationMinutes === 'number' &&
|
||||
recommendedDurationMinutes >= 5 &&
|
||||
recommendedDurationMinutes <= 180
|
||||
) {
|
||||
return recommendedDurationMinutes;
|
||||
}
|
||||
|
||||
const recommendedLabel = resolveTimerLabelFromPresetId(recommendedPresetId);
|
||||
|
||||
if (recommendedLabel) {
|
||||
return recommendedLabel;
|
||||
}
|
||||
|
||||
return TIMER_SELECTION_PRESETS[0]?.label ?? '25/5';
|
||||
return 50;
|
||||
};
|
||||
|
||||
export const resolveFocusTimeDisplayFromTimerLabel = (timerLabel: string) => {
|
||||
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.label === timerLabel);
|
||||
const focusMinutes = preset?.focusMinutes ?? 25;
|
||||
return `${String(focusMinutes).padStart(2, '0')}:00`;
|
||||
export const formatDurationMinutesLabel = (minutes: number) => {
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
export const resolveFocusTimeDisplayFromDurationMinutes = (durationMinutes: number) => {
|
||||
const safeMinutes = Number.isFinite(durationMinutes) ? Math.max(5, Math.min(180, durationMinutes)) : 25;
|
||||
return `${String(safeMinutes).padStart(2, '0')}:00`;
|
||||
};
|
||||
|
||||
@@ -19,31 +19,24 @@ import { useHudStatusLine } from "@/shared/lib/useHudStatusLine";
|
||||
import { copy } from "@/shared/i18n";
|
||||
import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud";
|
||||
import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
findAtmosphereOptionForSelection,
|
||||
getRecommendedDurationMinutes,
|
||||
} from "@/widgets/focus-dashboard/model/atmosphereEntry";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
|
||||
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
|
||||
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
|
||||
import {
|
||||
resolveFocusTimeDisplayFromTimerLabel,
|
||||
resolveInitialSceneId,
|
||||
resolveInitialSoundPreset,
|
||||
resolveInitialTimerLabel,
|
||||
TIMER_SELECTION_PRESETS,
|
||||
DURATION_SELECTION_OPTIONS,
|
||||
resolveFocusTimeDisplayFromDurationMinutes,
|
||||
resolveInitialDurationMinutes,
|
||||
} from "../model/workspaceSelection";
|
||||
import { FocusTopToast } from "./FocusTopToast";
|
||||
|
||||
export const SpaceWorkspaceWidget = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
|
||||
const goalQuery = searchParams.get("goal")?.trim() ?? "";
|
||||
const focusPlanItemIdQuery = searchParams.get("planItemId");
|
||||
const soundQuery = searchParams.get("sound");
|
||||
const timerQuery = searchParams.get("timer");
|
||||
const hasQueryOverrides = Boolean(
|
||||
sceneQuery || goalQuery || focusPlanItemIdQuery || soundQuery || timerQuery,
|
||||
);
|
||||
|
||||
const {
|
||||
addThought,
|
||||
@@ -59,31 +52,28 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const { isPro } = usePlanTier();
|
||||
const { review, summary: weeklySummary } = useFocusStats();
|
||||
|
||||
const initialSceneId = useMemo(
|
||||
() => resolveInitialSceneId(sceneQuery, undefined),
|
||||
[sceneQuery],
|
||||
);
|
||||
const initialSceneId = useMemo(() => SCENE_THEMES[0].id, []);
|
||||
const initialScene = useMemo(
|
||||
() => getSceneById(initialSceneId) ?? SCENE_THEMES[0],
|
||||
[initialSceneId],
|
||||
);
|
||||
const initialSoundPresetId = useMemo(
|
||||
() =>
|
||||
resolveInitialSoundPreset(
|
||||
soundQuery,
|
||||
undefined,
|
||||
initialScene.recommendedSoundPresetId,
|
||||
),
|
||||
[initialScene.recommendedSoundPresetId, soundQuery],
|
||||
() => initialScene.recommendedSoundPresetId,
|
||||
[initialScene.recommendedSoundPresetId],
|
||||
);
|
||||
const initialTimerLabel = useMemo(
|
||||
const initialAtmosphere = useMemo(
|
||||
() =>
|
||||
resolveInitialTimerLabel(
|
||||
timerQuery,
|
||||
findAtmosphereOptionForSelection(initialSceneId, initialSoundPresetId) ??
|
||||
findAtmosphereOptionForSelection(initialSceneId),
|
||||
[initialSceneId, initialSoundPresetId],
|
||||
);
|
||||
const initialDurationMinutes = useMemo(
|
||||
() =>
|
||||
resolveInitialDurationMinutes(
|
||||
undefined,
|
||||
initialScene.recommendedTimerPresetId,
|
||||
initialAtmosphere ? getRecommendedDurationMinutes(initialAtmosphere) : undefined,
|
||||
),
|
||||
[initialScene.recommendedTimerPresetId, timerQuery],
|
||||
[initialAtmosphere],
|
||||
);
|
||||
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("setup");
|
||||
@@ -143,14 +133,9 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
const selection = useSpaceWorkspaceSelection({
|
||||
initialSceneId,
|
||||
initialGoal: goalQuery,
|
||||
initialFocusPlanItemId: focusPlanItemIdQuery,
|
||||
initialTimerLabel,
|
||||
sceneQuery,
|
||||
goalQuery,
|
||||
soundQuery,
|
||||
timerQuery,
|
||||
hasQueryOverrides,
|
||||
initialGoal: "",
|
||||
initialFocusPlanItemId: null,
|
||||
initialDurationMinutes,
|
||||
currentSession,
|
||||
sceneAssetMap,
|
||||
selectedPresetId,
|
||||
@@ -177,7 +162,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
goalInput: selection.goalInput,
|
||||
linkedFocusPlanItemId: selection.linkedFocusPlanItemId,
|
||||
selectedSceneId: selection.selectedSceneId,
|
||||
selectedTimerLabel: selection.selectedTimerLabel,
|
||||
selectedDurationMinutes: selection.selectedDurationMinutes,
|
||||
selectedPresetId,
|
||||
soundPlaybackError,
|
||||
pushStatusLine,
|
||||
@@ -222,10 +207,10 @@ export const SpaceWorkspaceWidget = () => {
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
|
||||
if (!isBootstrapping && !currentSession) {
|
||||
router.replace("/app");
|
||||
}
|
||||
}, [isBootstrapping, currentSession, hasQueryOverrides, router]);
|
||||
}, [isBootstrapping, currentSession, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBootstrapping || didResolveEntryRouteRef.current) {
|
||||
@@ -237,7 +222,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
if (!currentSession) {
|
||||
return;
|
||||
}
|
||||
}, [currentSession, isBootstrapping, router]);
|
||||
}, [currentSession, isBootstrapping]);
|
||||
|
||||
useEffect(() => {
|
||||
const preferMobile =
|
||||
@@ -255,7 +240,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
const resolvedTimeDisplay =
|
||||
timeDisplay ??
|
||||
resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel);
|
||||
resolveFocusTimeDisplayFromDurationMinutes(selection.selectedDurationMinutes);
|
||||
|
||||
return (
|
||||
<div className="relative h-dvh overflow-hidden text-white">
|
||||
@@ -277,17 +262,17 @@ export const SpaceWorkspaceWidget = () => {
|
||||
scenes={selection.setupScenes}
|
||||
sceneAssetMap={sceneAssetMap}
|
||||
selectedSceneId={selection.selectedScene.id}
|
||||
selectedTimerLabel={selection.selectedTimerLabel}
|
||||
selectedDurationLabel={selection.selectedDurationLabel}
|
||||
selectedSoundPresetId={selectedPresetId}
|
||||
goalInput={selection.goalInput}
|
||||
selectedGoalId={selection.selectedGoalId}
|
||||
goalChips={GOAL_CHIPS}
|
||||
soundPresets={SOUND_PRESETS}
|
||||
timerPresets={TIMER_SELECTION_PRESETS}
|
||||
durationOptions={DURATION_SELECTION_OPTIONS}
|
||||
canStart={selection.canStart}
|
||||
onSceneSelect={selection.handleSelectScene}
|
||||
onTimerSelect={(timerLabel) =>
|
||||
selection.handleSelectTimer(timerLabel, true)
|
||||
onDurationSelect={(durationMinutes) =>
|
||||
selection.handleSelectDuration(durationMinutes, true)
|
||||
}
|
||||
onSoundSelect={(presetId) =>
|
||||
selection.handleSelectSound(presetId, true)
|
||||
|
||||
@@ -11,12 +11,8 @@ import { cn } from '@/shared/lib/cn';
|
||||
|
||||
const DEFAULT_STATS_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
|
||||
|
||||
const reviewStageSceneByPreset = (presetId: string) => {
|
||||
if (presetId.startsWith('forest')) {
|
||||
return getSceneById('forest') ?? SCENE_THEMES[0];
|
||||
}
|
||||
|
||||
return getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0];
|
||||
const reviewStageSceneByCarryForward = (sceneId: string) => {
|
||||
return getSceneById(sceneId) ?? getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0];
|
||||
};
|
||||
|
||||
export const StatsOverviewWidget = () => {
|
||||
@@ -26,8 +22,8 @@ export const StatsOverviewWidget = () => {
|
||||
const { review, isLoading, error, source, refetch } = useFocusStats();
|
||||
|
||||
const activeScene = useMemo(
|
||||
() => reviewStageSceneByPreset(review.carryForward.presetId),
|
||||
[review.carryForward.presetId],
|
||||
() => reviewStageSceneByCarryForward(review.carryForward.sceneId),
|
||||
[review.carryForward.sceneId],
|
||||
);
|
||||
const sourceLabel = source === 'api' ? stats.sourceApi : stats.sourceMock;
|
||||
const syncLabel = error ? error : isLoading ? stats.loading : stats.synced;
|
||||
@@ -221,9 +217,11 @@ export const StatsOverviewWidget = () => {
|
||||
{isPro && <span className="text-white/30">PRO</span>}
|
||||
</p>
|
||||
<p className="mt-3 text-[15px] font-medium tracking-tight text-white/90">
|
||||
{review.carryForward.presetLabel}
|
||||
{review.carryForward.atmosphereLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/50">
|
||||
{review.carryForward.durationMinutes}분 · 가장 무리 없이 들어갈 수 있는 흐름.
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-white/50">가장 무리 없이 들어갈 수 있는 흐름.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user