diff --git a/frontend/src/features/liberation/pc/Liberation.jsx b/frontend/src/features/liberation/pc/Liberation.jsx index f6032c4..d696af3 100644 --- a/frontend/src/features/liberation/pc/Liberation.jsx +++ b/frontend/src/features/liberation/pc/Liberation.jsx @@ -1,17 +1,22 @@ -import { useState, useEffect, useLayoutEffect, useMemo } from 'react' +import { useState, useLayoutEffect, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import { api } from '../../../api/client' import { GENESIS_CHAPTERS, GENESIS_TOTAL, - WEEKLY_BOSSES, MONTHLY_BOSSES, - calcPoints, formatDate, - todayKST, } from '../data' import { useLiberationStore } from '../store' +import { + bossEarn, + calcWeekPoints, + calcDoneEarn, + calcMonthlyEarn, + getSchedulerWeekRange, + computeCompletionDate, +} from '../utils' import QuestSelector from './components/QuestSelector' import PointsInput from './components/PointsInput' import ProgressBar from './components/ProgressBar' @@ -20,46 +25,6 @@ import DatePicker from '../../../components/common/DatePicker' import ConfirmDialog from '../../../components/common/ConfirmDialog' import { useLayout } from '../../../components/pc/Layout' -function makeEmptyWeekly() { - const bosses = {} - WEEKLY_BOSSES.forEach((b) => { - bosses[b.key] = { difficulty: 'none', party: 1, done: false } - }) - return { - bosses, - blackMage: { difficulty: 'none', party: 1, done: false }, - } -} - -function bossEarn(boss, sel) { - if (!sel) return 0 - const d = boss.difficulties.find((x) => x.key === sel.difficulty) - if (!d) return 0 - return calcPoints(d.points, sel.party) -} - -function calcWeekPoints(weekData) { - let points = 0 - WEEKLY_BOSSES.forEach((b) => { - points += bossEarn(b, weekData.bosses[b.key]) - }) - return points -} - -function calcDoneEarn(weekData) { - let points = 0 - WEEKLY_BOSSES.forEach((b) => { - const sel = weekData.bosses[b.key] - if (sel?.done) points += bossEarn(b, sel) - }) - return points -} - -function calcMonthlyEarn(weekData) { - return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage) -} - - export default function Liberation() { const { setFullscreen } = useLayout() useLayoutEffect(() => { @@ -87,7 +52,7 @@ export default function Liberation() { const resetSlot = useLiberationStore((s) => s.resetSlot) const setState = (updater) => updateSlot(updater) - // 포인트 이월 계산: 현재 퀘스트의 required를 초과하면 자동으로 다음 퀘스트로 넘어감 + // 포인트 이월: 현재 퀘스트 required를 초과하면 자동으로 다음 퀘스트로 넘어감 const priorConsumed = GENESIS_CHAPTERS .slice(0, state.startChapter) .reduce((s, c) => s + c.required, 0) @@ -103,12 +68,11 @@ export default function Liberation() { const alreadyDone = initialAccumulated >= GENESIS_TOTAL const weeklyEarn = calcWeekPoints(state.weekly) const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0) - // 주간/월간 분리 const doneEarn = calcDoneEarn(state.weekly) const monthlyEarn = calcMonthlyEarn(state.weekly) const monthlyDoneThisMonth = !!state.weekly.blackMage?.done - // 주차별 모드에서는 모든 주차 합산 (검은 마법사는 월별 슬롯 1회만 카운트) + // 주차별 모드 헤더 합산 (검은 마법사는 월별 슬롯 1회만 카운트) const headerWeekly = calcMode === 'weekly' ? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0) : weeklyEarn @@ -120,15 +84,7 @@ export default function Liberation() { sw.forEach((w, idx) => { const diff = w.config.blackMage?.difficulty if (!diff || diff === 'none') return - const r = (() => { - const start = dayjs(state.startDate).tz('Asia/Seoul').startOf('day') - const dow = start.day() - const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow - const nextThu = start.add(daysToNextThu, 'day') - if (idx + 1 === 1) return { start, end: nextThu.subtract(1, 'day') } - const ws = nextThu.add((idx + 1 - 2) * 7, 'day') - return { start: ws, end: ws.add(6, 'day') } - })() + const r = getSchedulerWeekRange(state.startDate, idx + 1) const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')] for (const m of months) { if (!(m in claimed)) { @@ -140,132 +96,12 @@ export default function Liberation() { return Object.values(claimed).reduce((s, v) => s + v, 0) })() - // 날짜 이벤트 시뮬레이션으로 해방일 계산 - function computeCompletionDate() { - if (alreadyDone) return todayKST() - if (remaining <= 0) return dayjs(state.startDate).tz('Asia/Seoul').startOf('day').toDate() - - const startKST = dayjs(state.startDate).tz('Asia/Seoul').startOf('day') - const events = [] - - if (calcMode === 'weekly') { - // 주차별 모드: 각 주차의 설정에 따라 적립 - const sw = state.schedulerWeeks || [] - if (sw.length === 0) return null - // 주간 보스: 시작일 당일 = 1주차 설정, 이후 매 목요일 = 2주차/3주차 설정 - const dow = startKST.day() - const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow - // 1주차: 시작일 당일에 (주간 - done) 적립 - const week1Cfg = sw[0]?.config || makeEmptyWeekly() - const w1Weekly = calcWeekPoints(week1Cfg) - const w1Done = calcDoneEarn(week1Cfg) - events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) }) - // 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립 - // 마지막 주차 이후로는 마지막 주차 설정을 반복 적용 - let nextThu = startKST.add(daysToNextThu, 'day') - for (let i = 1; i < 520; i++) { - const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly() - events.push({ date: nextThu, amount: calcWeekPoints(cfg) }) - nextThu = nextThu.add(1, 'week') - } - - // 검은 마법사: 슬롯 배정에 따라 해당 주차의 첫날(or 1주차이면 시작일)에 적립 - const claimed = {} // monthKey -> { weekIdx, earn, doneAlready } - sw.forEach((w, i) => { - const diff = w.config.blackMage?.difficulty - if (!diff || diff === 'none') return - const range = getSchedulerWeekRange(state.startDate, i + 1) - const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')] - for (const m of months) { - if (!(m in claimed)) { - claimed[m] = { - weekIdx: i, - earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage), - done: !!w.config.blackMage.done, - } - return - } - } - }) - Object.entries(claimed).forEach(([, info]) => { - if (info.done) return - const wIdx = info.weekIdx - // 1주차면 시작일, 그 외엔 해당 주차의 시작 목요일 - const date = wIdx === 0 - ? startKST - : startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day') - events.push({ date, amount: info.earn }) - }) - - // 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용 - const lastCfg = sw[sw.length - 1]?.config - const lastBmEarn = lastCfg ? bossEarn(MONTHLY_BOSSES[0], lastCfg.blackMage) : 0 - if (lastBmEarn > 0) { - const lastWeekStart = sw.length === 1 - ? startKST - : startKST.add(daysToNextThu + (sw.length - 2) * 7, 'day') - const claimedMonths = new Set(Object.keys(claimed)) - let cursor = lastWeekStart.add(1, 'month').startOf('month') - for (let i = 0; i < 120; i++) { - const m = cursor.format('YYYY-MM') - if (!claimedMonths.has(m)) { - events.push({ date: cursor, amount: lastBmEarn }) - } - cursor = cursor.add(1, 'month') - } - } - } else { - // 단순 계산 모드: 매주 동일 설정 - if (weeklyEarn === 0 && monthlyEarn === 0) return null - - // 시작일 당일: (주간 - 완료된 주간) + (이번 달 월간, 아직 안 잡았을 때) - const day0Weekly = Math.max(weeklyEarn - doneEarn, 0) - const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0 - events.push({ date: startKST, amount: day0Weekly + day0Monthly }) - - // 다음 목요일부터 매주 주간 적립 - const dow = startKST.day() - const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow - let nextThu = startKST.add(daysToNextThu, 'day') - for (let i = 0; i < 520; i++) { - events.push({ date: nextThu, amount: weeklyEarn }) - nextThu = nextThu.add(1, 'week') - } - - // 다음 달 1일부터 매월 월간 적립 - if (monthlyEarn > 0) { - let nextMonth = startKST.add(1, 'month').startOf('month') - for (let i = 0; i < 120; i++) { - events.push({ date: nextMonth, amount: monthlyEarn }) - nextMonth = nextMonth.add(1, 'month') - } - } - } - - events.sort((a, b) => a.date.diff(b.date)) - let cumulative = 0 - for (const e of events) { - cumulative += e.amount - if (cumulative >= remaining) return e.date.toDate() - } - return null - } - - function getSchedulerWeekRange(startDateStr, weekIdx) { - const start = dayjs(startDateStr).tz('Asia/Seoul').startOf('day') - const dow = start.day() - const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow - const nextThu = start.add(daysToNextThu, 'day') - if (weekIdx === 1) return { start, end: nextThu.subtract(1, 'day') } - const ws = nextThu.add((weekIdx - 2) * 7, 'day') - return { start: ws, end: ws.add(6, 'day') } - } - const completionDate = useMemo( - () => computeCompletionDate(), - // 의도적으로 state 전체 + calcMode 만 의존. 내부 함수는 클로저 안의 값만 읽음 - // eslint-disable-next-line react-hooks/exhaustive-deps - [state, calcMode, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth], + () => computeCompletionDate({ + calcMode, state, alreadyDone, remaining, + weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth, + }), + [calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth], ) const isDone = completionDate !== null @@ -322,131 +158,131 @@ export default function Liberation() {
데스티니 해방 계산기는 준비 중입니다.
) : (<> - {/* 계산 모드 탭 */} -
- {[ - { key: 'simple', label: '단순 계산' }, - { key: 'weekly', label: '주차별 계산' }, - ].map((t) => { - const active = calcMode === t.key - return ( - - ) - })} -
- - - - {/* 현재 진행 상태 입력 */} -
-
현재 진행 상태
- -
-
- - setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))} - /> -
- -
- - setState((prev) => ({ ...prev, startChapter: idx }))} - /> -
- -
- -
- setState((prev) => ({ ...prev, currentPoints: n }))} - className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none" - style={{ color: 'var(--text-strong)' }} - /> - + {[ + { key: 'simple', label: '단순 계산' }, + { key: 'weekly', label: '주차별 계산' }, + ].map((t) => { + const active = calcMode === t.key + return ( + + ) + })} +
+ + + + {/* 현재 진행 상태 입력 */} +
+
현재 진행 상태
+ +
+
+ + setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))} + /> +
+ +
+ + setState((prev) => ({ ...prev, startChapter: idx }))} + /> +
+ +
+ +
+ setState((prev) => ({ ...prev, currentPoints: n }))} + className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none" + style={{ color: 'var(--text-strong)' }} + /> + + / {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()} + +
-
- setState((prev) => ({ ...prev, weekly: w }))} - totalWeekly={headerWeekly} - totalMonthly={headerMonthly} - remaining={remaining} - mode={calcMode} - startDate={state.startDate} - weeks={state.schedulerWeeks} - onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))} - /> + setState((prev) => ({ ...prev, weekly: w }))} + totalWeekly={headerWeekly} + totalMonthly={headerMonthly} + remaining={remaining} + mode={calcMode} + startDate={state.startDate} + weeks={state.schedulerWeeks} + onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))} + /> -
- -
+
+ +
)} x.key === sel.difficulty) - if (!d) return 0 - return calcPoints(d.points, sel.party) -} - -function calcWeeklySum(config) { - let sum = 0 - WEEKLY_BOSSES.forEach((b) => { sum += bossEarn(b, config.bosses[b.key]) }) - return sum -} - -const KST = 'Asia/Seoul' - -// 주차별 날짜 범위 계산 (목요일 리셋, 1주차는 시작일부터 다음 목요일 전까지) -function getWeekRange(startDateStr, weekIdx) { - const start = dayjs(startDateStr).tz(KST).startOf('day') - const dow = start.day() - const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow - const nextThu = start.add(daysToNextThu, 'day') - if (weekIdx === 1) { - return { start, end: nextThu.subtract(1, 'day') } - } - const weekStart = nextThu.add((weekIdx - 2) * 7, 'day') - const weekEnd = weekStart.add(6, 'day') - return { start: weekStart, end: weekEnd } -} - function formatRange(r) { const fmt = (d) => `${d.month() + 1}/${d.date()}` return `${fmt(r.start)} ~ ${fmt(r.end)}` @@ -46,17 +18,6 @@ const DIFF_BADGE = { extreme: { label: 'X', color: '#f59e0b', border: 'rgba(245,158,11,0.5)', bg: 'rgba(245,158,11,0.2)' }, } -function makeEmptyWeek() { - const bosses = {} - WEEKLY_BOSSES.forEach((b) => { - bosses[b.key] = { difficulty: 'none', party: 1, done: false } - }) - return { - bosses, - blackMage: { difficulty: 'none', party: 1, done: false }, - } -} - function BossAvatar({ boss, difficulty, size = 40 }) { const badge = DIFF_BADGE[difficulty] const enabled = difficulty && difficulty !== 'none' @@ -141,7 +102,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) { export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) { const weeks = weeksProp && weeksProp.length > 0 ? weeksProp - : [{ id: 1, config: makeEmptyWeek() }] + : [{ id: 1, config: makeEmptyWeekly() }] const setWeeks = (updater) => { const next = typeof updater === 'function' ? updater(weeks) : updater onChangeWeeks?.(next) @@ -153,7 +114,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW const id = nextId() setWeeks((prev) => { const last = prev[prev.length - 1] - const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeek() + const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeekly() // done 상태는 복사하지 않음 Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false }) if (base.blackMage) base.blackMage.done = false diff --git a/frontend/src/features/liberation/utils.js b/frontend/src/features/liberation/utils.js new file mode 100644 index 0000000..b34eaca --- /dev/null +++ b/frontend/src/features/liberation/utils.js @@ -0,0 +1,174 @@ +import dayjs from 'dayjs' +import { WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints, todayKST } from './data' +import { makeEmptyWeekly } from './store' + +const KST = 'Asia/Seoul' + +export function bossEarn(boss, sel) { + if (!sel) return 0 + const d = boss.difficulties.find((x) => x.key === sel.difficulty) + if (!d) return 0 + return calcPoints(d.points, sel.party) +} + +export function calcWeekPoints(weekData) { + let points = 0 + WEEKLY_BOSSES.forEach((b) => { + points += bossEarn(b, weekData.bosses[b.key]) + }) + return points +} + +export function calcDoneEarn(weekData) { + let points = 0 + WEEKLY_BOSSES.forEach((b) => { + const sel = weekData.bosses[b.key] + if (sel?.done) points += bossEarn(b, sel) + }) + return points +} + +export function calcMonthlyEarn(weekData) { + return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage) +} + +/** + * 주차 번호(1-based)로 해당 주차의 날짜 범위 반환 + * 1주차: 시작일 ~ 다음 목요일 전날 + * 2주차+: 이전 주차 목요일부터 6일간 + */ +export function getSchedulerWeekRange(startDateStr, weekIdx) { + const start = dayjs(startDateStr).tz(KST).startOf('day') + const dow = start.day() + const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow + const nextThu = start.add(daysToNextThu, 'day') + if (weekIdx === 1) return { start, end: nextThu.subtract(1, 'day') } + const ws = nextThu.add((weekIdx - 2) * 7, 'day') + return { start: ws, end: ws.add(6, 'day') } +} + +/** + * 해방일 계산: 시작일부터 포인트 이벤트를 시뮬레이션하여 remaining 도달 시점 반환 + * + * @param {object} params + * @param {'simple'|'weekly'} params.calcMode + * @param {object} params.state - 현재 슬롯(startDate, schedulerWeeks, weekly 등) + * @param {boolean} params.alreadyDone + * @param {number} params.remaining + * @param {number} params.weeklyEarn + * @param {number} params.doneEarn + * @param {number} params.monthlyEarn + * @param {boolean} params.monthlyDoneThisMonth + * @returns {Date|null} + */ +export function computeCompletionDate({ + calcMode, state, alreadyDone, remaining, + weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth, +}) { + if (alreadyDone) return todayKST() + if (remaining <= 0) return dayjs(state.startDate).tz(KST).startOf('day').toDate() + + const startKST = dayjs(state.startDate).tz(KST).startOf('day') + const events = [] + + if (calcMode === 'weekly') { + const sw = state.schedulerWeeks || [] + if (sw.length === 0) return null + const dow = startKST.day() + const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow + + // 1주차: 시작일 당일에 (주간 - done) 적립 + const week1Cfg = sw[0]?.config || makeEmptyWeekly() + const w1Weekly = calcWeekPoints(week1Cfg) + const w1Done = calcDoneEarn(week1Cfg) + events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) }) + + // 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립 + // 마지막 주차 이후로는 마지막 주차 설정 반복 적용 + let nextThu = startKST.add(daysToNextThu, 'day') + for (let i = 1; i < 520; i++) { + const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly() + events.push({ date: nextThu, amount: calcWeekPoints(cfg) }) + nextThu = nextThu.add(1, 'week') + } + + // 검은 마법사: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립 + const claimed = {} + sw.forEach((w, i) => { + const diff = w.config.blackMage?.difficulty + if (!diff || diff === 'none') return + const range = getSchedulerWeekRange(state.startDate, i + 1) + const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')] + for (const m of months) { + if (!(m in claimed)) { + claimed[m] = { + weekIdx: i, + earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage), + done: !!w.config.blackMage.done, + } + return + } + } + }) + Object.entries(claimed).forEach(([, info]) => { + if (info.done) return + const wIdx = info.weekIdx + const date = wIdx === 0 + ? startKST + : startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day') + events.push({ date, amount: info.earn }) + }) + + // 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용 + const lastCfg = sw[sw.length - 1]?.config + const lastBmEarn = lastCfg ? bossEarn(MONTHLY_BOSSES[0], lastCfg.blackMage) : 0 + if (lastBmEarn > 0) { + const lastWeekStart = sw.length === 1 + ? startKST + : startKST.add(daysToNextThu + (sw.length - 2) * 7, 'day') + const claimedMonths = new Set(Object.keys(claimed)) + let cursor = lastWeekStart.add(1, 'month').startOf('month') + for (let i = 0; i < 120; i++) { + const m = cursor.format('YYYY-MM') + if (!claimedMonths.has(m)) { + events.push({ date: cursor, amount: lastBmEarn }) + } + cursor = cursor.add(1, 'month') + } + } + } else { + // 단순 계산 모드: 매주 동일 설정 + if (weeklyEarn === 0 && monthlyEarn === 0) return null + + // 시작일 당일: (주간 - 완료된 주간) + (이번 달 월간, 아직 안 잡았을 때) + const day0Weekly = Math.max(weeklyEarn - doneEarn, 0) + const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0 + events.push({ date: startKST, amount: day0Weekly + day0Monthly }) + + // 다음 목요일부터 매주 주간 적립 + const dow = startKST.day() + const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow + let nextThu = startKST.add(daysToNextThu, 'day') + for (let i = 0; i < 520; i++) { + events.push({ date: nextThu, amount: weeklyEarn }) + nextThu = nextThu.add(1, 'week') + } + + // 다음 달 1일부터 매월 월간 적립 + if (monthlyEarn > 0) { + let nextMonth = startKST.add(1, 'month').startOf('month') + for (let i = 0; i < 120; i++) { + events.push({ date: nextMonth, amount: monthlyEarn }) + nextMonth = nextMonth.add(1, 'month') + } + } + } + + events.sort((a, b) => a.date.diff(b.date)) + let cumulative = 0 + for (const e of events) { + cumulative += e.amount + if (cumulative >= remaining) return e.date.toDate() + } + return null +}