feat(flow): focus session api v2 웹 계약 전환
This commit is contained in:
@@ -23,7 +23,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[0].tags],
|
||||
recommendedSound: copy.scenes[0].recommendedSound,
|
||||
recommendedSoundPresetId: 'rain-focus',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: copy.scenes[0].recommendedTime,
|
||||
vibeLabel: copy.scenes[0].vibeLabel,
|
||||
hubColor: '#D6E6F7',
|
||||
@@ -45,7 +44,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[1].tags],
|
||||
recommendedSound: copy.scenes[1].recommendedSound,
|
||||
recommendedSoundPresetId: 'cafe-work',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: copy.scenes[1].recommendedTime,
|
||||
vibeLabel: copy.scenes[1].vibeLabel,
|
||||
hubColor: '#F5DDCB',
|
||||
@@ -67,7 +65,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[2].tags],
|
||||
recommendedSound: copy.scenes[2].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: copy.scenes[2].recommendedTime,
|
||||
vibeLabel: copy.scenes[2].vibeLabel,
|
||||
hubColor: '#DCE4D1',
|
||||
@@ -89,7 +86,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[3].tags],
|
||||
recommendedSound: copy.scenes[3].recommendedSound,
|
||||
recommendedSoundPresetId: 'ocean-calm',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: copy.scenes[3].recommendedTime,
|
||||
vibeLabel: copy.scenes[3].vibeLabel,
|
||||
hubColor: '#CFE9EA',
|
||||
@@ -111,7 +107,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[4].tags],
|
||||
recommendedSound: copy.scenes[4].recommendedSound,
|
||||
recommendedSoundPresetId: 'forest-birds',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: copy.scenes[4].recommendedTime,
|
||||
vibeLabel: copy.scenes[4].vibeLabel,
|
||||
hubColor: '#D1E7C9',
|
||||
@@ -133,7 +128,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[5].tags],
|
||||
recommendedSound: copy.scenes[5].recommendedSound,
|
||||
recommendedSoundPresetId: 'fireplace',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: copy.scenes[5].recommendedTime,
|
||||
vibeLabel: copy.scenes[5].vibeLabel,
|
||||
hubColor: '#F2D4C0',
|
||||
@@ -155,7 +149,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[6].tags],
|
||||
recommendedSound: copy.scenes[6].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: copy.scenes[6].recommendedTime,
|
||||
vibeLabel: copy.scenes[6].vibeLabel,
|
||||
hubColor: '#D9D3ED',
|
||||
@@ -177,7 +170,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[7].tags],
|
||||
recommendedSound: copy.scenes[7].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: copy.scenes[7].recommendedTime,
|
||||
vibeLabel: copy.scenes[7].vibeLabel,
|
||||
hubColor: '#D8E7F3',
|
||||
@@ -199,7 +191,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[8].tags],
|
||||
recommendedSound: copy.scenes[8].recommendedSound,
|
||||
recommendedSoundPresetId: 'silent',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: copy.scenes[8].recommendedTime,
|
||||
vibeLabel: copy.scenes[8].vibeLabel,
|
||||
hubColor: '#F6EDC7',
|
||||
@@ -221,7 +212,6 @@ export const SCENE_THEMES: SceneTheme[] = [
|
||||
tags: [...copy.scenes[9].tags],
|
||||
recommendedSound: copy.scenes[9].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '90-20',
|
||||
recommendedTime: copy.scenes[9].recommendedTime,
|
||||
vibeLabel: copy.scenes[9].vibeLabel,
|
||||
hubColor: '#D4DCF4',
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface SceneTheme {
|
||||
tags: SceneTag[];
|
||||
recommendedSound: string;
|
||||
recommendedSoundPresetId: string;
|
||||
recommendedTimerPresetId: string;
|
||||
recommendedTime: string;
|
||||
vibeLabel: string;
|
||||
hubColor: string;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './model/mockSession';
|
||||
export * from './model/focusSystem';
|
||||
export * from './model/types';
|
||||
export * from './model/useThoughtInbox';
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
export interface FocusPlanItem {
|
||||
id: string;
|
||||
title: string;
|
||||
goal: string;
|
||||
blockLabel: string;
|
||||
ritualLabel: string;
|
||||
energyLabel: string;
|
||||
successSignal: string;
|
||||
sceneId: string;
|
||||
soundPresetId: string;
|
||||
timerLabel: string;
|
||||
proOnly?: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
goalPrompt: string;
|
||||
cadenceLabel: string;
|
||||
notificationTone: string;
|
||||
sceneId: string;
|
||||
soundPresetId: string;
|
||||
timerLabel: string;
|
||||
proOnly?: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionOutcome {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
nextSuggestion: string;
|
||||
}
|
||||
|
||||
export interface WeeklyReviewMetric {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
delta: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface WeeklyReviewInsight {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
proOnly?: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface WeeklyReview {
|
||||
headline: string;
|
||||
summary: string;
|
||||
metrics: WeeklyReviewMetric[];
|
||||
insights: WeeklyReviewInsight[];
|
||||
}
|
||||
|
||||
export interface AsyncCheckIn {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
message: string;
|
||||
timeLabel: string;
|
||||
reactionSummary: string;
|
||||
proOnly?: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
const FOCUS_PLAN_ITEMS_BASE: FocusPlanItem[] = [
|
||||
{
|
||||
id: 'design-qa',
|
||||
title: '디자인 QA 요청 3개 정리',
|
||||
goal: '디자인 QA 요청 3개 우선순위 정리',
|
||||
blockLabel: '15분 triage + 10분 결정',
|
||||
ritualLabel: 'Soft Landing 25/5',
|
||||
energyLabel: '낮은 진입 장벽',
|
||||
successSignal: '버릴 것 1개, 오늘 처리 1개만 확정',
|
||||
sceneId: 'quiet-library',
|
||||
soundPresetId: 'deep-white',
|
||||
timerLabel: '25/5',
|
||||
},
|
||||
{
|
||||
id: 'proposal-intro',
|
||||
title: '제안서 첫 문단 다듬기',
|
||||
goal: '제안서 첫 문단 다듬기',
|
||||
blockLabel: '30분 초안 + 20분 정리',
|
||||
ritualLabel: 'Rain Draft 50/10',
|
||||
energyLabel: '깊은 사고',
|
||||
successSignal: '첫 문단을 소리 내어 읽었을 때 어색함이 없어질 것',
|
||||
sceneId: 'rain-window',
|
||||
soundPresetId: 'rain-focus',
|
||||
timerLabel: '50/10',
|
||||
proOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'ship-one-function',
|
||||
title: '핵심 함수 1개 마무리',
|
||||
goal: '핵심 함수 1개 마무리',
|
||||
blockLabel: '40분 구현 + 10분 정리',
|
||||
ritualLabel: 'Forest Ship 50/10',
|
||||
energyLabel: '중간 몰입',
|
||||
successSignal: '리뷰 전에 스스로 설명 가능한 상태 만들기',
|
||||
sceneId: 'forest',
|
||||
soundPresetId: 'forest-birds',
|
||||
timerLabel: '50/10',
|
||||
proOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const SESSION_TEMPLATES_BASE: SessionTemplate[] = [
|
||||
{
|
||||
id: 'soft-landing',
|
||||
name: 'Soft Landing',
|
||||
description: '몸을 풀듯 천천히 진입하는 시작 ritual',
|
||||
goalPrompt: '메일 3개 또는 작은 정리 1개',
|
||||
cadenceLabel: '25/5 · 낮은 압박',
|
||||
notificationTone: '조용함',
|
||||
sceneId: 'sun-window',
|
||||
soundPresetId: 'rain-focus',
|
||||
timerLabel: '25/5',
|
||||
},
|
||||
{
|
||||
id: 'rain-draft',
|
||||
name: 'Rain Draft',
|
||||
description: '문서, 글쓰기, 제안서 초안용 깊은 진입 ritual',
|
||||
goalPrompt: '문단 1개 또는 초안 1개',
|
||||
cadenceLabel: '50/10 · 깊은 몰입',
|
||||
notificationTone: '기본',
|
||||
sceneId: 'rain-window',
|
||||
soundPresetId: 'rain-focus',
|
||||
timerLabel: '50/10',
|
||||
proOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'forest-ship',
|
||||
name: 'Forest Ship',
|
||||
description: '코드, QA, 실행 정리용 차분한 마감 ritual',
|
||||
goalPrompt: '함수 1개 또는 리뷰 2개',
|
||||
cadenceLabel: '50/10 · 차분한 추진',
|
||||
notificationTone: '강함',
|
||||
sceneId: 'forest',
|
||||
soundPresetId: 'forest-birds',
|
||||
timerLabel: '50/10',
|
||||
proOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SESSION_OUTCOMES: SessionOutcome[] = [
|
||||
{
|
||||
id: 'carry-forward',
|
||||
title: '다음 한 조각으로 이어가기',
|
||||
description: '세션이 끝난 뒤 바로 다음 블록을 붙여서 흐름을 지킵니다.',
|
||||
nextSuggestion: '방금 한 작업의 다음 문단 1개',
|
||||
},
|
||||
{
|
||||
id: 'promote-inbox',
|
||||
title: '주차한 생각 끌어올리기',
|
||||
description: 'distraction dump에 넣어둔 항목을 오늘 큐로 승격합니다.',
|
||||
nextSuggestion: '인박스에서 가장 가벼운 메모 1개',
|
||||
},
|
||||
{
|
||||
id: 'reset-with-smaller-goal',
|
||||
title: '더 작게 다시 시작하기',
|
||||
description: '무너진 날에는 범위를 절반으로 줄여 다시 시작합니다.',
|
||||
nextSuggestion: '5분 안에 끝낼 수 있는 아주 작은 조각',
|
||||
},
|
||||
];
|
||||
|
||||
export const CALM_WEEKLY_REVIEW: WeeklyReview = {
|
||||
headline: '이번 주는 짧은 진입 ritual이 시작 성공률을 끌어올렸어요.',
|
||||
summary:
|
||||
'길게 버티는 것보다, 짧게 시작하고 이어가는 패턴이 가장 안정적이었어요. 점심 직전에는 목표를 더 작게 쪼개는 편이 좋았습니다.',
|
||||
metrics: [
|
||||
{ id: 'start-success', label: '시작 성공률', value: '78%', delta: '+12%' },
|
||||
{ id: 'completion-rate', label: '완료율', value: '64%', delta: '+9%' },
|
||||
{ id: 'recovery-rate', label: '중단 후 복귀율', value: '71%', delta: '+6%', locked: true },
|
||||
{ id: 'ritual-fit', label: 'ritual 적합도', value: 'Soft Landing', delta: '가장 안정적', locked: true },
|
||||
],
|
||||
insights: [
|
||||
{
|
||||
id: 'fragile-window',
|
||||
label: '흔들리는 시간대',
|
||||
value: '11:30 - 12:30',
|
||||
description: '점심 직전엔 25/5와 작은 목표가 더 잘 맞았어요.',
|
||||
},
|
||||
{
|
||||
id: 'best-window',
|
||||
label: '잘 풀리는 시간대',
|
||||
value: '14:00 - 16:00',
|
||||
description: '오후 초반엔 50/10 깊은 몰입 블록이 유지됐어요.',
|
||||
},
|
||||
{
|
||||
id: 'best-ritual',
|
||||
label: '가장 잘 맞는 ritual',
|
||||
value: 'Soft Landing',
|
||||
description: '짧은 준비와 낮은 압박이 시작 지연을 줄였습니다.',
|
||||
proOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'recovery-cue',
|
||||
label: '복귀 신호',
|
||||
value: '문장 1개만 고치기',
|
||||
description: '큰 목표보다 작은 복귀 문장이 재시작을 쉽게 만들었어요.',
|
||||
proOnly: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ASYNC_CHECK_INS_BASE: AsyncCheckIn[] = [
|
||||
{
|
||||
id: 'buddy-mina',
|
||||
name: 'Mina',
|
||||
role: '브랜드 디자이너',
|
||||
status: '오늘은 천천히',
|
||||
message: '오후에는 QA만 정리하고 끝내려 해요.',
|
||||
timeLabel: '12분 전',
|
||||
reactionSummary: '👍 3 · 🫶 1',
|
||||
},
|
||||
{
|
||||
id: 'buddy-jisoo',
|
||||
name: 'Jisoo',
|
||||
role: '프리랜서 라이터',
|
||||
status: '25분만 달릴게요',
|
||||
message: '첫 문단만 정리해두면 오늘은 충분해요.',
|
||||
timeLabel: '31분 전',
|
||||
reactionSummary: '👏 2 · 🔥 1',
|
||||
},
|
||||
{
|
||||
id: 'digest-repeat-buddy',
|
||||
name: '반복 파트너 digest',
|
||||
role: 'PRO',
|
||||
status: '이번 주 리듬 요약',
|
||||
message: '같은 시간대에 서로 시작한 날이 4일이었어요.',
|
||||
timeLabel: '금요일 오전',
|
||||
reactionSummary: '주간 digest',
|
||||
proOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const markLocked = <T extends { proOnly?: boolean }>(items: T[], isPro: boolean) => {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
locked: Boolean(!isPro && item.proOnly),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getFocusPlanItems = (isPro: boolean) => {
|
||||
return markLocked(FOCUS_PLAN_ITEMS_BASE, isPro);
|
||||
};
|
||||
|
||||
export const getSessionTemplates = (isPro: boolean) => {
|
||||
return markLocked(SESSION_TEMPLATES_BASE, isPro);
|
||||
};
|
||||
|
||||
export const getWeeklyReview = (isPro: boolean): WeeklyReview => {
|
||||
return {
|
||||
...CALM_WEEKLY_REVIEW,
|
||||
metrics: CALM_WEEKLY_REVIEW.metrics.map((metric) => ({
|
||||
...metric,
|
||||
locked: Boolean(!isPro && metric.locked),
|
||||
})),
|
||||
insights: markLocked(CALM_WEEKLY_REVIEW.insights, isPro),
|
||||
};
|
||||
};
|
||||
|
||||
export const getAsyncCheckIns = (isPro: boolean) => {
|
||||
return markLocked(ASYNC_CHECK_INS_BASE, isPro);
|
||||
};
|
||||
|
||||
export const buildSessionStartHref = (params: {
|
||||
goal: string;
|
||||
sceneId: string;
|
||||
soundPresetId: string;
|
||||
timerLabel: string;
|
||||
}) => {
|
||||
const query = new URLSearchParams({
|
||||
goal: params.goal,
|
||||
scene: params.sceneId,
|
||||
sound: params.soundPresetId,
|
||||
timer: params.timerLabel,
|
||||
});
|
||||
|
||||
return `/space?${query.toString()}`;
|
||||
};
|
||||
|
||||
export const getFocusPlanStartHref = (item: FocusPlanItem) => {
|
||||
return buildSessionStartHref({
|
||||
goal: item.goal,
|
||||
sceneId: item.sceneId,
|
||||
soundPresetId: item.soundPresetId,
|
||||
timerLabel: item.timerLabel,
|
||||
});
|
||||
};
|
||||
|
||||
export const getSessionTemplateStartHref = (template: SessionTemplate) => {
|
||||
return buildSessionStartHref({
|
||||
goal: template.goalPrompt,
|
||||
sceneId: template.sceneId,
|
||||
soundPresetId: template.soundPresetId,
|
||||
timerLabel: template.timerLabel,
|
||||
});
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
RecentThought,
|
||||
ReactionOption,
|
||||
SoundPreset,
|
||||
TimerPreset,
|
||||
} from './types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
@@ -19,8 +18,6 @@ export const REACTION_OPTIONS: ReactionOption[] = [...copy.session.reactionOptio
|
||||
|
||||
export const SOUND_PRESETS: SoundPreset[] = [...copy.session.soundPresets];
|
||||
|
||||
export const TIMER_PRESETS: TimerPreset[] = [...copy.session.timerPresets];
|
||||
|
||||
export const DISTRACTION_DUMP_PLACEHOLDER = [...copy.session.distractionDumpPlaceholder];
|
||||
|
||||
export const TODAY_STATS: FocusStatCard[] = [...copy.session.todayStats];
|
||||
|
||||
@@ -19,13 +19,6 @@ export interface SoundPreset {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TimerPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
focusMinutes?: number;
|
||||
breakMinutes?: number;
|
||||
}
|
||||
|
||||
export interface FocusStatCard {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ interface RawFocusSession {
|
||||
id: number | string;
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
timerPresetId: string;
|
||||
atmosphereId?: string | null;
|
||||
soundPresetId: string | null;
|
||||
focusPlanItemId?: number | null;
|
||||
microStep?: string | null;
|
||||
@@ -35,7 +35,7 @@ export interface FocusSession {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
timerPresetId: string;
|
||||
atmosphereId?: string | null;
|
||||
soundPresetId: string | null;
|
||||
focusPlanItemId?: string | null;
|
||||
microStep?: string | null;
|
||||
@@ -55,7 +55,8 @@ export interface FocusSession {
|
||||
export interface StartFocusSessionRequest {
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
timerPresetId: string;
|
||||
focusDurationMinutes: number;
|
||||
atmosphereId: string;
|
||||
soundPresetId?: string | null;
|
||||
focusPlanItemId?: string;
|
||||
microStep?: string | null;
|
||||
@@ -71,6 +72,7 @@ export interface CompleteFocusSessionRequest {
|
||||
|
||||
export interface UpdateCurrentFocusSessionSelectionRequest {
|
||||
sceneId?: string;
|
||||
atmosphereId?: string | null;
|
||||
soundPresetId?: string | null;
|
||||
}
|
||||
|
||||
@@ -86,8 +88,9 @@ export interface ExtendCurrentPhaseRequest {
|
||||
export interface AdvanceCurrentGoalRequest {
|
||||
completedGoal: string;
|
||||
nextGoal: string;
|
||||
sceneId: string;
|
||||
timerPresetId: string;
|
||||
sceneId?: string;
|
||||
focusDurationMinutes?: number;
|
||||
atmosphereId?: string | null;
|
||||
soundPresetId?: string | null;
|
||||
focusPlanItemId?: string;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import {
|
||||
DEFAULT_PRESET_OPTIONS,
|
||||
NOTIFICATION_INTENSITY_OPTIONS,
|
||||
} from '@/shared/config/settingsOptions';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { apiClient } from '@/shared/lib/apiClient';
|
||||
|
||||
export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number];
|
||||
export type DefaultPresetId = (typeof DEFAULT_PRESET_OPTIONS)[number]['id'];
|
||||
|
||||
export interface UserFocusPreferences {
|
||||
reduceMotion: boolean;
|
||||
notificationIntensity: NotificationIntensity;
|
||||
defaultPresetId: DefaultPresetId;
|
||||
defaultAtmosphereId?: string | null;
|
||||
defaultDurationMinutes?: number | null;
|
||||
defaultSceneId: string | null;
|
||||
defaultSoundPresetId: string | null;
|
||||
}
|
||||
@@ -21,7 +20,8 @@ export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
|
||||
export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
|
||||
reduceMotion: false,
|
||||
notificationIntensity: copy.preferences.defaultNotificationIntensity,
|
||||
defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id,
|
||||
defaultAtmosphereId: null,
|
||||
defaultDurationMinutes: null,
|
||||
defaultSceneId: null,
|
||||
defaultSoundPresetId: null,
|
||||
};
|
||||
@@ -30,7 +30,7 @@ export const preferencesApi = {
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 로그인한 사용자의 집중 관련 개인 설정을 반환한다.
|
||||
* - 최소 reduceMotion, notificationIntensity, defaultPresetId를 포함한다.
|
||||
* - 최소 reduceMotion, notificationIntensity, defaultAtmosphereId, defaultDurationMinutes를 포함한다.
|
||||
* - 아직 저장된 값이 없으면 서버 기본값을 내려주거나 null 필드 없이 기본 스키마로 응답한다.
|
||||
*/
|
||||
getFocusPreferences: async (): Promise<UserFocusPreferences> => {
|
||||
|
||||
@@ -86,8 +86,11 @@ export interface WeeklyReviewViewModel {
|
||||
completionQuality: WeeklyReviewSection;
|
||||
carryForward: {
|
||||
hintKey: ReviewCarryHint;
|
||||
presetId: string;
|
||||
presetLabel: string;
|
||||
atmosphereId: string;
|
||||
atmosphereLabel: string;
|
||||
sceneId: string;
|
||||
soundPresetId: string | null;
|
||||
durationMinutes: number;
|
||||
keepDoing: string;
|
||||
tryNext: string;
|
||||
ctaLabel: string;
|
||||
@@ -256,13 +259,17 @@ const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['c
|
||||
const params = new URLSearchParams({
|
||||
review: 'weekly',
|
||||
carryHint: hintKey,
|
||||
entryPreset: 'forest-50-10',
|
||||
entryAtmosphereId: 'forest-draft',
|
||||
entryDurationMinutes: '50',
|
||||
});
|
||||
|
||||
return {
|
||||
hintKey,
|
||||
presetId: 'forest-50-10',
|
||||
presetLabel: 'Forest · Forest Birds',
|
||||
atmosphereId: 'forest-draft',
|
||||
atmosphereLabel: 'Forest Draft',
|
||||
sceneId: 'forest',
|
||||
soundPresetId: 'forest-birds',
|
||||
durationMinutes: 50,
|
||||
keepDoing,
|
||||
tryNext,
|
||||
ctaLabel: copy.stats.reviewCarryCta,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const NOTIFICATION_INTENSITY_OPTIONS = copy.settings.notificationIntensityOptions;
|
||||
|
||||
export const DEFAULT_PRESET_OPTIONS = copy.settings.defaultPresetOptions;
|
||||
|
||||
@@ -9,14 +9,11 @@ export const app = {
|
||||
reduceMotionDescription: '전환 애니메이션을 최소화합니다. (UI 토글 목업)',
|
||||
notificationIntensityTitle: '알림 강도',
|
||||
notificationIntensityDescription: '집중 시작/종료 신호의 존재감을 선택합니다.',
|
||||
defaultPresetTitle: '기본 프리셋',
|
||||
defaultPresetDescription: '입장 시 자동 선택될 추천 세트를 고릅니다.',
|
||||
defaultDurationTitle: '기본 집중 시간',
|
||||
defaultDurationDescription: '새 세션에 기본으로 채울 시간을 정합니다.',
|
||||
defaultAtmosphereTitle: '기본 Atmosphere',
|
||||
defaultAtmosphereDescription: '새 세션에 기본으로 제안할 분위기를 정합니다.',
|
||||
notificationIntensityOptions: ['조용함', '기본', '강함'],
|
||||
defaultPresetOptions: [
|
||||
{ id: 'balanced', label: 'Balanced 25/5 + Rain Focus' },
|
||||
{ id: 'deep-work', label: 'Deep Work 50/10 + Deep White' },
|
||||
{ id: 'gentle', label: 'Gentle 25/5 + Silent' },
|
||||
],
|
||||
},
|
||||
stats: {
|
||||
title: 'Weekly Review',
|
||||
@@ -79,10 +76,10 @@ export const app = {
|
||||
reviewCarryTryClosure: '시작은 있었지만 마무리가 약했어요. 다음 주에는 완료 직전에 다른 블록으로 넘어가지 않는 흐름을 한 번 만들어 보세요.',
|
||||
reviewCarryTryStart: '시작 횟수가 적었어요. 다음 주에는 길이를 늘리기보다 첫 세션을 한 번 더 여는 것에 집중해 보세요.',
|
||||
reviewCarryCta: '이 흐름으로 다음 세션 시작',
|
||||
reviewCarryCtaPro: '추천 ritual과 함께 /app 돌아가기',
|
||||
reviewCarryCtaPro: '추천 atmosphere와 함께 /app 돌아가기',
|
||||
reviewCarryKeepTitle: '다음 주에 유지할 것',
|
||||
reviewCarryTryTitle: '다음 주에 바꿔볼 것',
|
||||
reviewCarryPresetLabel: '추천 ritual',
|
||||
reviewCarryPresetLabel: '추천 atmosphere',
|
||||
today: '오늘',
|
||||
last7Days: '최근 7일',
|
||||
chartTitle: '집중 흐름 그래프',
|
||||
@@ -155,12 +152,6 @@ export const app = {
|
||||
{ id: 'fireplace', label: 'Fireplace' },
|
||||
{ id: 'silent', label: 'Silent' },
|
||||
],
|
||||
timerPresets: [
|
||||
{ id: '25-5', label: '25/5', focusMinutes: 25, breakMinutes: 5 },
|
||||
{ id: '50-10', label: '50/10', focusMinutes: 50, breakMinutes: 10 },
|
||||
{ id: '90-20', label: '90/20', focusMinutes: 90, breakMinutes: 20 },
|
||||
{ id: 'custom', label: '커스텀' },
|
||||
],
|
||||
distractionDumpPlaceholder: ['디자인 QA 요청 확인', '세금계산서 발행 메모', '오후 미팅 질문 1개 정리'],
|
||||
todayStats: [
|
||||
{ id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' },
|
||||
|
||||
@@ -8,14 +8,11 @@ export const settings = {
|
||||
reduceMotionDescription: '전환 애니메이션을 최소화합니다. (UI 토글 목업)',
|
||||
notificationIntensityTitle: '알림 강도',
|
||||
notificationIntensityDescription: '집중 시작/종료 신호의 존재감을 선택합니다.',
|
||||
defaultPresetTitle: '기본 프리셋',
|
||||
defaultPresetDescription: '입장 시 자동 선택될 추천 세트를 고릅니다.',
|
||||
defaultDurationTitle: '기본 집중 시간',
|
||||
defaultDurationDescription: '새 세션에 기본으로 채울 시간을 정합니다.',
|
||||
defaultAtmosphereTitle: '기본 Atmosphere',
|
||||
defaultAtmosphereDescription: '새 세션에 기본으로 제안할 분위기를 정합니다.',
|
||||
notificationIntensityOptions: ['조용함', '기본', '강함'],
|
||||
defaultPresetOptions: [
|
||||
{ id: 'balanced', label: 'Balanced 25/5 + Rain Focus' },
|
||||
{ id: 'deep-work', label: 'Deep Work 50/10 + Deep White' },
|
||||
{ id: 'gentle', label: 'Gentle 25/5 + Silent' },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const stats = {
|
||||
@@ -114,7 +111,7 @@ export const plan = {
|
||||
{
|
||||
id: 'rituals',
|
||||
name: 'Rituals',
|
||||
description: 'scene + sound + timer 조합을 반복 가능한 시작 방식으로 저장합니다.',
|
||||
description: 'atmosphere와 duration 조합을 반복 가능한 시작 방식으로 저장합니다.',
|
||||
},
|
||||
{
|
||||
id: 'weekly-review',
|
||||
|
||||
@@ -30,12 +30,6 @@ export const session = {
|
||||
{ id: 'fireplace', label: 'Fireplace' },
|
||||
{ id: 'silent', label: 'Silent' },
|
||||
],
|
||||
timerPresets: [
|
||||
{ id: '25-5', label: '25/5', focusMinutes: 25, breakMinutes: 5 },
|
||||
{ id: '50-10', label: '50/10', focusMinutes: 50, breakMinutes: 10 },
|
||||
{ id: '90-20', label: '90/20', focusMinutes: 90, breakMinutes: 20 },
|
||||
{ id: 'custom', label: '커스텀' },
|
||||
],
|
||||
distractionDumpPlaceholder: ['디자인 QA 요청 확인', '세금계산서 발행 메모', '오후 미팅 질문 1개 정리'],
|
||||
todayStats: [
|
||||
{ id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' },
|
||||
|
||||
@@ -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