주차별 모드 해방일 계산 + 헤더/주차 행 표시 정리
- weekly 모드 시뮬레이션: 1주차는 시작일 당일에 (주간-완료) 적립, 2주차 이후 매 목요일에 해당 주차 설정의 주간 합 적립 - 검은 마법사: 슬롯 배정에 따라 1회씩 적립(이미 done이면 제외) - 마지막 주차 이후로는 마지막 주차 설정을 매주/매월 반복 적용 - 헤더: 주간(초록) + 월간(노랑) / 6500 형식, 모드별 합산 - 주차 행 우측: 주간/월간을 두 줄로 색상 분리 표시 (월간은 있을 때만) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef8f7d5ea4
commit
6243dea01e
3 changed files with 167 additions and 30 deletions
|
|
@ -203,15 +203,116 @@ export default function Liberation() {
|
||||||
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
||||||
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
||||||
|
|
||||||
|
// 주차별 모드에서는 모든 주차 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
|
||||||
|
const headerWeekly = calcMode === 'weekly'
|
||||||
|
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
|
||||||
|
: weeklyEarn
|
||||||
|
const headerMonthly = (() => {
|
||||||
|
if (calcMode !== 'weekly') return monthlyEarn
|
||||||
|
const sw = state.schedulerWeeks || []
|
||||||
|
if (!state.startDate) return 0
|
||||||
|
const claimed = {}
|
||||||
|
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 months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
|
||||||
|
for (const m of months) {
|
||||||
|
if (!(m in claimed)) {
|
||||||
|
claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Object.values(claimed).reduce((s, v) => s + v, 0)
|
||||||
|
})()
|
||||||
|
|
||||||
// 날짜 이벤트 시뮬레이션으로 해방일 계산
|
// 날짜 이벤트 시뮬레이션으로 해방일 계산
|
||||||
function computeCompletionDate() {
|
function computeCompletionDate() {
|
||||||
if (alreadyDone) return todayKST()
|
if (alreadyDone) return todayKST()
|
||||||
if (weeklyEarn === 0 && monthlyEarn === 0) return null
|
|
||||||
if (remaining <= 0) return dayjs(state.startDate).tz('Asia/Seoul').startOf('day').toDate()
|
if (remaining <= 0) return dayjs(state.startDate).tz('Asia/Seoul').startOf('day').toDate()
|
||||||
|
|
||||||
const startKST = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
|
const startKST = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
|
||||||
const events = []
|
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 day0Weekly = Math.max(weeklyEarn - doneEarn, 0)
|
||||||
const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0
|
const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0
|
||||||
|
|
@ -234,6 +335,7 @@ export default function Liberation() {
|
||||||
nextMonth = nextMonth.add(1, 'month')
|
nextMonth = nextMonth.add(1, 'month')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
events.sort((a, b) => a.date.diff(b.date))
|
events.sort((a, b) => a.date.diff(b.date))
|
||||||
let cumulative = 0
|
let cumulative = 0
|
||||||
|
|
@ -244,6 +346,16 @@ export default function Liberation() {
|
||||||
return null
|
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 = computeCompletionDate()
|
const completionDate = computeCompletionDate()
|
||||||
const isDone = completionDate !== null
|
const isDone = completionDate !== null
|
||||||
|
|
||||||
|
|
@ -382,8 +494,8 @@ export default function Liberation() {
|
||||||
<WeeklyDefault
|
<WeeklyDefault
|
||||||
weekly={state.weekly}
|
weekly={state.weekly}
|
||||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||||
totalWeekly={weeklyEarn}
|
totalWeekly={headerWeekly}
|
||||||
totalMonthly={monthlyEarn}
|
totalMonthly={headerMonthly}
|
||||||
mode={calcMode}
|
mode={calcMode}
|
||||||
startDate={state.startDate}
|
startDate={state.startDate}
|
||||||
weeks={state.schedulerWeeks}
|
weeks={state.schedulerWeeks}
|
||||||
|
|
|
||||||
|
|
@ -82,13 +82,12 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
||||||
<div className="max-w-3xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
|
<div className="max-w-3xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-lg font-semibold text-emerald-300">주간 보스 설정</div>
|
<div className="text-lg font-semibold text-emerald-300">주간 보스 설정</div>
|
||||||
<div className="flex items-baseline text-sm text-gray-400 gap-3">
|
<div className="text-sm tabular-nums">
|
||||||
<span>
|
<span className="text-emerald-300 font-semibold">{totalWeekly}</span>
|
||||||
주간 획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalWeekly}</span>
|
<span className="text-gray-500 mx-1">+</span>
|
||||||
</span>
|
<span className="text-amber-300 font-semibold">{totalMonthly}</span>
|
||||||
<span>
|
<span className="text-gray-500 mx-1">/</span>
|
||||||
월간 획득 <span className="text-amber-300 font-semibold tabular-nums">+{totalMonthly}</span>
|
<span className="text-gray-300 font-semibold">6500</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,22 @@
|
||||||
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 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 { 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'
|
const KST = 'Asia/Seoul'
|
||||||
|
|
||||||
// 주차별 날짜 범위 계산 (목요일 리셋, 1주차는 시작일부터 다음 목요일 전까지)
|
// 주차별 날짜 범위 계산 (목요일 리셋, 1주차는 시작일부터 다음 목요일 전까지)
|
||||||
|
|
@ -219,9 +232,22 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
||||||
{WEEKLY_BOSSES.map((b) => (
|
{WEEKLY_BOSSES.map((b) => (
|
||||||
<BossAvatar key={b.key} boss={b} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
|
<BossAvatar key={b.key} boss={b} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
|
||||||
))}
|
))}
|
||||||
<BossAvatar boss={MONTHLY_BOSSES[0]} difficulty={w.config.blackMage?.difficulty} size={40} />
|
<BossAvatar boss={MONTHLY_BOSSES[0]} difficulty={monthlyLockedByWeek != null ? 'none' : w.config.blackMage?.difficulty} size={40} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const weeklySum = calcWeeklySum(w.config)
|
||||||
|
const monthlySum = monthlyLockedByWeek != null ? 0 : bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
|
||||||
|
return (
|
||||||
|
<div className="text-right shrink-0 pr-1 tabular-nums leading-tight">
|
||||||
|
<div className="text-base font-bold text-emerald-300">+{weeklySum}</div>
|
||||||
|
{monthlySum > 0 && (
|
||||||
|
<div className="text-sm font-semibold text-amber-300">+{monthlySum}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
width="16" height="16" viewBox="0 0 12 12" fill="none"
|
width="16" height="16" viewBox="0 0 12 12" fill="none"
|
||||||
className={`text-gray-500 transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`}
|
className={`text-gray-500 transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue