주차별 모드 해방일 계산 + 헤더/주차 행 표시 정리

- weekly 모드 시뮬레이션: 1주차는 시작일 당일에 (주간-완료) 적립,
  2주차 이후 매 목요일에 해당 주차 설정의 주간 합 적립
- 검은 마법사: 슬롯 배정에 따라 1회씩 적립(이미 done이면 제외)
- 마지막 주차 이후로는 마지막 주차 설정을 매주/매월 반복 적용
- 헤더: 주간(초록) + 월간(노랑) / 6500 형식, 모드별 합산
- 주차 행 우측: 주간/월간을 두 줄로 색상 분리 표시 (월간은 있을 때만)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-14 19:33:31 +09:00
parent ef8f7d5ea4
commit 6243dea01e
3 changed files with 167 additions and 30 deletions

View file

@ -203,15 +203,116 @@ export default function Liberation() {
const monthlyEarn = calcMonthlyEarn(state.weekly)
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() {
if (alreadyDone) return todayKST()
if (weeklyEarn === 0 && monthlyEarn === 0) return null
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
@ -234,6 +335,7 @@ export default function Liberation() {
nextMonth = nextMonth.add(1, 'month')
}
}
}
events.sort((a, b) => a.date.diff(b.date))
let cumulative = 0
@ -244,6 +346,16 @@ export default function Liberation() {
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 isDone = completionDate !== null
@ -382,8 +494,8 @@ export default function Liberation() {
<WeeklyDefault
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={weeklyEarn}
totalMonthly={monthlyEarn}
totalWeekly={headerWeekly}
totalMonthly={headerMonthly}
mode={calcMode}
startDate={state.startDate}
weeks={state.schedulerWeeks}

View file

@ -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="flex items-center justify-between">
<div className="text-lg font-semibold text-emerald-300">주간 보스 설정</div>
<div className="flex items-baseline text-sm text-gray-400 gap-3">
<span>
주간 획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalWeekly}</span>
</span>
<span>
월간 획득 <span className="text-amber-300 font-semibold tabular-nums">+{totalMonthly}</span>
</span>
<div className="text-sm tabular-nums">
<span className="text-emerald-300 font-semibold">{totalWeekly}</span>
<span className="text-gray-500 mx-1">+</span>
<span className="text-amber-300 font-semibold">{totalMonthly}</span>
<span className="text-gray-500 mx-1">/</span>
<span className="text-gray-300 font-semibold">6500</span>
</div>
</div>

View file

@ -1,9 +1,22 @@
import { useState } from 'react'
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 { 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 )
@ -219,9 +232,22 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
{WEEKLY_BOSSES.map((b) => (
<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>
{(() => {
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
width="16" height="16" viewBox="0 0 12 12" fill="none"
className={`text-gray-500 transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`}