리팩토링 6단계: Liberation 비즈니스 로직 utils.js로 추출 (458 → 299)
- features/liberation/utils.js 신설 * bossEarn, calcWeekPoints, calcDoneEarn, calcMonthlyEarn * getSchedulerWeekRange * computeCompletionDate (파라미터로 state 주입받는 순수 함수) - Liberation.jsx는 상태/렌더링/뷰모델만 담당 (299줄) - WeeklyScheduler.jsx의 중복 bossEarn/calcWeeklySum/getWeekRange/makeEmptyWeek 모두 utils에서 import (alias 유지) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4be648c21c
commit
1646617069
3 changed files with 314 additions and 343 deletions
|
|
@ -1,17 +1,22 @@
|
||||||
import { useState, useEffect, useLayoutEffect, useMemo } from 'react'
|
import { useState, useLayoutEffect, useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../api/client'
|
||||||
import {
|
import {
|
||||||
GENESIS_CHAPTERS,
|
GENESIS_CHAPTERS,
|
||||||
GENESIS_TOTAL,
|
GENESIS_TOTAL,
|
||||||
WEEKLY_BOSSES,
|
|
||||||
MONTHLY_BOSSES,
|
MONTHLY_BOSSES,
|
||||||
calcPoints,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
todayKST,
|
|
||||||
} from '../data'
|
} from '../data'
|
||||||
import { useLiberationStore } from '../store'
|
import { useLiberationStore } from '../store'
|
||||||
|
import {
|
||||||
|
bossEarn,
|
||||||
|
calcWeekPoints,
|
||||||
|
calcDoneEarn,
|
||||||
|
calcMonthlyEarn,
|
||||||
|
getSchedulerWeekRange,
|
||||||
|
computeCompletionDate,
|
||||||
|
} from '../utils'
|
||||||
import QuestSelector from './components/QuestSelector'
|
import QuestSelector from './components/QuestSelector'
|
||||||
import PointsInput from './components/PointsInput'
|
import PointsInput from './components/PointsInput'
|
||||||
import ProgressBar from './components/ProgressBar'
|
import ProgressBar from './components/ProgressBar'
|
||||||
|
|
@ -20,46 +25,6 @@ import DatePicker from '../../../components/common/DatePicker'
|
||||||
import ConfirmDialog from '../../../components/common/ConfirmDialog'
|
import ConfirmDialog from '../../../components/common/ConfirmDialog'
|
||||||
import { useLayout } from '../../../components/pc/Layout'
|
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() {
|
export default function Liberation() {
|
||||||
const { setFullscreen } = useLayout()
|
const { setFullscreen } = useLayout()
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|
@ -87,7 +52,7 @@ export default function Liberation() {
|
||||||
const resetSlot = useLiberationStore((s) => s.resetSlot)
|
const resetSlot = useLiberationStore((s) => s.resetSlot)
|
||||||
const setState = (updater) => updateSlot(updater)
|
const setState = (updater) => updateSlot(updater)
|
||||||
|
|
||||||
// 포인트 이월 계산: 현재 퀘스트의 required를 초과하면 자동으로 다음 퀘스트로 넘어감
|
// 포인트 이월: 현재 퀘스트 required를 초과하면 자동으로 다음 퀘스트로 넘어감
|
||||||
const priorConsumed = GENESIS_CHAPTERS
|
const priorConsumed = GENESIS_CHAPTERS
|
||||||
.slice(0, state.startChapter)
|
.slice(0, state.startChapter)
|
||||||
.reduce((s, c) => s + c.required, 0)
|
.reduce((s, c) => s + c.required, 0)
|
||||||
|
|
@ -103,12 +68,11 @@ export default function Liberation() {
|
||||||
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
|
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
|
||||||
const weeklyEarn = calcWeekPoints(state.weekly)
|
const weeklyEarn = calcWeekPoints(state.weekly)
|
||||||
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
|
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
|
||||||
// 주간/월간 분리
|
|
||||||
const doneEarn = calcDoneEarn(state.weekly)
|
const doneEarn = calcDoneEarn(state.weekly)
|
||||||
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
||||||
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
||||||
|
|
||||||
// 주차별 모드에서는 모든 주차 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
|
// 주차별 모드 헤더 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
|
||||||
const headerWeekly = calcMode === 'weekly'
|
const headerWeekly = calcMode === 'weekly'
|
||||||
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
|
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
|
||||||
: weeklyEarn
|
: weeklyEarn
|
||||||
|
|
@ -120,15 +84,7 @@ export default function Liberation() {
|
||||||
sw.forEach((w, idx) => {
|
sw.forEach((w, idx) => {
|
||||||
const diff = w.config.blackMage?.difficulty
|
const diff = w.config.blackMage?.difficulty
|
||||||
if (!diff || diff === 'none') return
|
if (!diff || diff === 'none') return
|
||||||
const r = (() => {
|
const r = getSchedulerWeekRange(state.startDate, idx + 1)
|
||||||
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 months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
|
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
if (!(m in claimed)) {
|
if (!(m in claimed)) {
|
||||||
|
|
@ -140,132 +96,12 @@ export default function Liberation() {
|
||||||
return Object.values(claimed).reduce((s, v) => s + v, 0)
|
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(
|
const completionDate = useMemo(
|
||||||
() => computeCompletionDate(),
|
() => computeCompletionDate({
|
||||||
// 의도적으로 state 전체 + calcMode 만 의존. 내부 함수는 클로저 안의 값만 읽음
|
calcMode, state, alreadyDone, remaining,
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
|
||||||
[state, calcMode, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
|
}),
|
||||||
|
[calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
|
||||||
)
|
)
|
||||||
const isDone = completionDate !== null
|
const isDone = completionDate !== null
|
||||||
|
|
||||||
|
|
@ -322,131 +158,131 @@ export default function Liberation() {
|
||||||
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>데스티니 해방 계산기는 준비 중입니다.</div>
|
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>데스티니 해방 계산기는 준비 중입니다.</div>
|
||||||
</div>
|
</div>
|
||||||
) : (<>
|
) : (<>
|
||||||
{/* 계산 모드 탭 */}
|
{/* 계산 모드 탭 */}
|
||||||
<div
|
<div
|
||||||
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
|
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--surface-3)',
|
background: 'var(--surface-3)',
|
||||||
borderColor: 'var(--panel-border)',
|
borderColor: 'var(--panel-border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
{ key: 'simple', label: '단순 계산' },
|
{ key: 'simple', label: '단순 계산' },
|
||||||
{ key: 'weekly', label: '주차별 계산' },
|
{ key: 'weekly', label: '주차별 계산' },
|
||||||
].map((t) => {
|
].map((t) => {
|
||||||
const active = calcMode === t.key
|
const active = calcMode === t.key
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCalcMode(t.key)}
|
onClick={() => setCalcMode(t.key)}
|
||||||
className="flex-1 h-10 rounded-lg text-sm font-semibold"
|
className="flex-1 h-10 rounded-lg text-sm font-semibold"
|
||||||
style={active ? {
|
style={active ? {
|
||||||
background: 'var(--selected-bg)',
|
background: 'var(--selected-bg)',
|
||||||
color: 'var(--accent-bright)',
|
color: 'var(--accent-bright)',
|
||||||
} : {
|
} : {
|
||||||
color: 'var(--text-muted)',
|
color: 'var(--text-muted)',
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
startChapter={state.startChapter}
|
|
||||||
currentPoints={state.currentPoints}
|
|
||||||
completionDate={isDone ? formatDate(completionDate) : null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 현재 진행 상태 입력 */}
|
|
||||||
<div
|
|
||||||
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 grid-cols-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
|
|
||||||
<DatePicker
|
|
||||||
value={formatDate(state.startDate)}
|
|
||||||
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
|
|
||||||
<QuestSelector
|
|
||||||
value={state.startChapter}
|
|
||||||
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
|
|
||||||
<div
|
|
||||||
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PointsInput
|
|
||||||
value={state.currentPoints}
|
|
||||||
max={3000}
|
|
||||||
onChange={(n) => 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)' }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="flex items-center px-3 text-base border-l select-none tabular-nums"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
color: 'var(--text-dim)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
|
{t.label}
|
||||||
</span>
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
startChapter={state.startChapter}
|
||||||
|
currentPoints={state.currentPoints}
|
||||||
|
completionDate={isDone ? formatDate(completionDate) : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 현재 진행 상태 입력 */}
|
||||||
|
<div
|
||||||
|
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
|
||||||
|
style={{
|
||||||
|
background: 'var(--panel-bg)',
|
||||||
|
borderColor: 'var(--panel-border)',
|
||||||
|
boxShadow: 'var(--panel-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
|
||||||
|
<DatePicker
|
||||||
|
value={formatDate(state.startDate)}
|
||||||
|
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
|
||||||
|
<QuestSelector
|
||||||
|
value={state.startChapter}
|
||||||
|
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
|
||||||
|
<div
|
||||||
|
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--input-bg)',
|
||||||
|
borderColor: 'var(--input-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PointsInput
|
||||||
|
value={state.currentPoints}
|
||||||
|
max={3000}
|
||||||
|
onChange={(n) => 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)' }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="flex items-center px-3 text-base border-l select-none tabular-nums"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--input-border)',
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<WeeklyDefault
|
<WeeklyDefault
|
||||||
weekly={state.weekly}
|
weekly={state.weekly}
|
||||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||||
totalWeekly={headerWeekly}
|
totalWeekly={headerWeekly}
|
||||||
totalMonthly={headerMonthly}
|
totalMonthly={headerMonthly}
|
||||||
remaining={remaining}
|
remaining={remaining}
|
||||||
mode={calcMode}
|
mode={calcMode}
|
||||||
startDate={state.startDate}
|
startDate={state.startDate}
|
||||||
weeks={state.schedulerWeeks}
|
weeks={state.schedulerWeeks}
|
||||||
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
|
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto flex justify-end">
|
<div className="max-w-3xl mx-auto flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setResetOpen(true)}
|
onClick={() => setResetOpen(true)}
|
||||||
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
|
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
|
||||||
style={{
|
style={{
|
||||||
borderColor: 'var(--icon-danger-border)',
|
borderColor: 'var(--icon-danger-border)',
|
||||||
background: 'var(--icon-danger-bg)',
|
background: 'var(--icon-danger-bg)',
|
||||||
color: 'var(--danger-text)',
|
color: 'var(--danger-text)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
전체 초기화
|
전체 초기화
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,10 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import dayjs from 'dayjs'
|
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES } from '../../data'
|
||||||
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints } from '../../data'
|
import { makeEmptyWeekly } from '../../store'
|
||||||
|
import { bossEarn, calcWeekPoints as calcWeeklySum, getSchedulerWeekRange as getWeekRange } from '../../utils'
|
||||||
import { BossRow } from './WeeklyDefault'
|
import { BossRow } from './WeeklyDefault'
|
||||||
|
|
||||||
function bossEarn(boss, sel) {
|
|
||||||
if (!sel || !sel.difficulty || sel.difficulty === 'none') return 0
|
|
||||||
const d = boss.difficulties.find((x) => 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) {
|
function formatRange(r) {
|
||||||
const fmt = (d) => `${d.month() + 1}/${d.date()}`
|
const fmt = (d) => `${d.month() + 1}/${d.date()}`
|
||||||
return `${fmt(r.start)} ~ ${fmt(r.end)}`
|
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)' },
|
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 }) {
|
function BossAvatar({ boss, difficulty, size = 40 }) {
|
||||||
const badge = DIFF_BADGE[difficulty]
|
const badge = DIFF_BADGE[difficulty]
|
||||||
const enabled = difficulty && difficulty !== 'none'
|
const enabled = difficulty && difficulty !== 'none'
|
||||||
|
|
@ -141,7 +102,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
|
||||||
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
|
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
|
||||||
const weeks = weeksProp && weeksProp.length > 0
|
const weeks = weeksProp && weeksProp.length > 0
|
||||||
? weeksProp
|
? weeksProp
|
||||||
: [{ id: 1, config: makeEmptyWeek() }]
|
: [{ id: 1, config: makeEmptyWeekly() }]
|
||||||
const setWeeks = (updater) => {
|
const setWeeks = (updater) => {
|
||||||
const next = typeof updater === 'function' ? updater(weeks) : updater
|
const next = typeof updater === 'function' ? updater(weeks) : updater
|
||||||
onChangeWeeks?.(next)
|
onChangeWeeks?.(next)
|
||||||
|
|
@ -153,7 +114,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
||||||
const id = nextId()
|
const id = nextId()
|
||||||
setWeeks((prev) => {
|
setWeeks((prev) => {
|
||||||
const last = prev[prev.length - 1]
|
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 상태는 복사하지 않음
|
// done 상태는 복사하지 않음
|
||||||
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
|
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
|
||||||
if (base.blackMage) base.blackMage.done = false
|
if (base.blackMage) base.blackMage.done = false
|
||||||
|
|
|
||||||
174
frontend/src/features/liberation/utils.js
Normal file
174
frontend/src/features/liberation/utils.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue