데스티니 주차별 계산 UI + 탭 라벨 변경

- WeeklyScheduler를 bosses/monthlyBoss/imageBase/makeEmptyConfig prop
  받도록 일반화 (월간 보스 없으면 관련 UI/락 전부 스킵)
- WeeklyDefault가 WeeklyScheduler에 props 전달, Destiny에서 주차별 모드
  주차 카드 확장/추가/삭제 + 보스 아바타 뱃지 표시 동작
- 탭 '단순 계산/주차별 계산' → '일반/주차별' (ConfirmDialog 문구 포함)
- 주간 보스 완료 토글 버튼에 title 툴팁 추가
- store: destiny 슬롯에 schedulerWeeks 추가, migrate v2로 기존 사용자 backfill

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-21 20:04:46 +09:00
parent 99500d91af
commit ee30c87518
5 changed files with 95 additions and 69 deletions

View file

@ -6,7 +6,7 @@ import {
DESTINY_BOSS_IMAGE_BASE, DESTINY_BOSS_IMAGE_BASE,
formatDate, formatDate,
} from '../data' } from '../data'
import { useLiberationStore } from '../store' import { useLiberationStore, makeEmptyDestinyWeekly } from '../store'
import { calcWeekPoints } from '../utils' import { calcWeekPoints } from '../utils'
import ProgressBar from './components/ProgressBar' import ProgressBar from './components/ProgressBar'
import QuestSelector from './components/QuestSelector' import QuestSelector from './components/QuestSelector'
@ -22,6 +22,9 @@ export default function Destiny() {
const setState = (updater) => updateSlot(updater) const setState = (updater) => updateSlot(updater)
const weeklyEarn = calcWeekPoints(state.weekly, DESTINY_BOSSES) const weeklyEarn = calcWeekPoints(state.weekly, DESTINY_BOSSES)
const headerWeekly = calcMode === 'weekly'
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config, DESTINY_BOSSES), 0)
: weeklyEarn
return ( return (
<> <>
@ -34,8 +37,8 @@ export default function Destiny() {
}} }}
> >
{[ {[
{ 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 (
@ -126,30 +129,19 @@ export default function Destiny() {
</div> </div>
</div> </div>
{calcMode === 'simple' ? ( <WeeklyDefault
<WeeklyDefault bosses={DESTINY_BOSSES}
bosses={DESTINY_BOSSES} imageBase={DESTINY_BOSS_IMAGE_BASE}
imageBase={DESTINY_BOSS_IMAGE_BASE} makeEmptyConfig={makeEmptyDestinyWeekly}
weekly={state.weekly} weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))} onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={weeklyEarn} totalWeekly={headerWeekly}
remaining={0} remaining={0}
mode="simple" mode={calcMode}
hasScheduler={false} startDate={state.startDate}
/> weeks={state.schedulerWeeks}
) : ( onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
<div />
className="max-w-3xl mx-auto rounded-2xl border p-16 text-center"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="text-xl font-bold" style={{ color: 'var(--text-emphasis)' }}>주차별 계산 준비 </div>
<div className="text-sm mt-2" style={{ color: 'var(--text-dim)' }}>단순 계산 탭을 이용해주세요.</div>
</div>
)}
</> </>
) )
} }

View file

@ -9,7 +9,7 @@ import {
LIBERATION_BOSS_IMAGE_BASE, LIBERATION_BOSS_IMAGE_BASE,
formatDate, formatDate,
} from '../data' } from '../data'
import { useLiberationStore } from '../store' import { useLiberationStore, makeEmptyWeekly } from '../store'
import { import {
bossEarn, bossEarn,
calcWeekPoints, calcWeekPoints,
@ -103,8 +103,8 @@ export default function Genesis() {
}} }}
> >
{[ {[
{ 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 (
@ -198,6 +198,7 @@ export default function Genesis() {
bosses={WEEKLY_BOSSES} bosses={WEEKLY_BOSSES}
monthlyBosses={MONTHLY_BOSSES} monthlyBosses={MONTHLY_BOSSES}
imageBase={LIBERATION_BOSS_IMAGE_BASE} imageBase={LIBERATION_BOSS_IMAGE_BASE}
makeEmptyConfig={makeEmptyWeekly}
weekly={state.weekly} weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))} onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={headerWeekly} totalWeekly={headerWeekly}
@ -232,7 +233,7 @@ export default function Genesis() {
onClose={() => setResetOpen(false)} onClose={() => setResetOpen(false)}
onConfirm={doReset} onConfirm={doReset}
title="전체 초기화" title="전체 초기화"
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`} description={`${calcMode === 'simple' ? '일반' : '주차별'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
confirmText="초기화" confirmText="초기화"
destructive destructive
/> />

View file

@ -61,6 +61,7 @@ export function BossRow({ boss, sel, onChange, imageBase, monthly = false, showD
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => onChange({ done: !sel.done })} onClick={() => onChange({ done: !sel.done })}
title="이번 주 해당 난이도를 이미 클리어했는지 여부"
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border" className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border"
style={disabled ? { style={disabled ? {
borderColor: 'var(--panel-border)', borderColor: 'var(--panel-border)',
@ -85,6 +86,7 @@ export default function WeeklyDefault({
bosses, bosses,
monthlyBosses = [], monthlyBosses = [],
imageBase, imageBase,
makeEmptyConfig,
weekly, weekly,
onChange, onChange,
totalWeekly, totalWeekly,
@ -168,6 +170,10 @@ export default function WeeklyDefault({
</div> </div>
) : ( ) : (
<WeeklyScheduler <WeeklyScheduler
bosses={bosses}
monthlyBoss={monthlyBosses[0] ?? null}
imageBase={imageBase}
makeEmptyConfig={makeEmptyConfig}
startDate={startDate} startDate={startDate}
weeks={weeks} weeks={weeks}
onChangeWeeks={onChangeWeeks} onChangeWeeks={onChangeWeeks}

View file

@ -1,7 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES } from '../../data'
import { makeEmptyWeekly } from '../../store'
import { bossEarn, calcWeekPoints as calcWeeklySum, getSchedulerWeekRange as getWeekRange } from '../../utils' import { bossEarn, calcWeekPoints as calcWeeklySum, getSchedulerWeekRange as getWeekRange } from '../../utils'
import { BossRow } from './WeeklyDefault' import { BossRow } from './WeeklyDefault'
@ -18,7 +16,7 @@ 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 BossAvatar({ boss, difficulty, size = 40 }) { function BossAvatar({ boss, imageBase, difficulty, size = 40 }) {
const badge = DIFF_BADGE[difficulty] const badge = DIFF_BADGE[difficulty]
const enabled = difficulty && difficulty !== 'none' const enabled = difficulty && difficulty !== 'none'
return ( return (
@ -32,7 +30,7 @@ function BossAvatar({ boss, difficulty, size = 40 }) {
borderColor: 'var(--panel-border)', borderColor: 'var(--panel-border)',
}} }}
> >
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-full h-full object-cover" /> <img src={`${imageBase}/${boss.image}`} alt={boss.name} className="w-full h-full object-cover" />
</div> </div>
<div <div
className="text-[10px] font-bold leading-none rounded flex items-center justify-center border" className="text-[10px] font-bold leading-none rounded flex items-center justify-center border"
@ -49,7 +47,7 @@ function BossAvatar({ boss, difficulty, size = 40 }) {
) )
} }
function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) { function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek, bosses, monthlyBoss, imageBase }) {
const updateBoss = (key, patch) => { const updateBoss = (key, patch) => {
onChange({ ...config, bosses: { ...config.bosses, [key]: { ...config.bosses[key], ...patch } } }) onChange({ ...config, bosses: { ...config.bosses, [key]: { ...config.bosses[key], ...patch } } })
} }
@ -61,7 +59,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
return ( return (
<div> <div>
{WEEKLY_BOSSES.map((boss, i) => ( {bosses.map((boss, i) => (
<div <div
key={boss.key} key={boss.key}
className={i > 0 ? 'border-t' : ''} className={i > 0 ? 'border-t' : ''}
@ -71,23 +69,27 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
boss={boss} boss={boss}
sel={config.bosses[boss.key]} sel={config.bosses[boss.key]}
onChange={(patch) => updateBoss(boss.key, patch)} onChange={(patch) => updateBoss(boss.key, patch)}
imageBase={imageBase}
showDone={isCurrent} showDone={isCurrent}
/> />
</div> </div>
))} ))}
<div {monthlyBoss && (
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`} <div
style={{ borderColor: 'var(--row-divider)' }} className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
> style={{ borderColor: 'var(--row-divider)' }}
<BossRow >
boss={MONTHLY_BOSSES[0]} <BossRow
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage} boss={monthlyBoss}
onChange={updateBlackMage} sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
monthly onChange={updateBlackMage}
showDone={isCurrent} imageBase={imageBase}
/> monthly
</div> showDone={isCurrent}
{blackmageLocked && ( />
</div>
)}
{monthlyBoss && blackmageLocked && (
<div <div
className="text-[11px] px-3 py-2" className="text-[11px] px-3 py-2"
style={{ color: 'var(--warning-text)' }} style={{ color: 'var(--warning-text)' }}
@ -99,10 +101,18 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
) )
} }
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) { export default function WeeklyScheduler({
bosses,
monthlyBoss = null,
imageBase,
makeEmptyConfig,
startDate,
weeks: weeksProp,
onChangeWeeks,
}) {
const weeks = weeksProp && weeksProp.length > 0 const weeks = weeksProp && weeksProp.length > 0
? weeksProp ? weeksProp
: [{ id: 1, config: makeEmptyWeekly() }] : [{ id: 1, config: makeEmptyConfig() }]
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)
@ -114,13 +124,13 @@ 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)) : makeEmptyWeekly() const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyConfig()
// 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
// //
if (startDate && base.blackMage?.difficulty && base.blackMage.difficulty !== 'none') { if (monthlyBoss && startDate && base.blackMage?.difficulty && base.blackMage.difficulty !== 'none') {
const newIdx = prev.length + 1 const newIdx = prev.length + 1
const newMonth = getWeekRange(startDate, newIdx).start.format('YYYY-MM') const newMonth = getWeekRange(startDate, newIdx).start.format('YYYY-MM')
const existsInSameMonth = prev.some((p, i) => { const existsInSameMonth = prev.some((p, i) => {
@ -146,9 +156,9 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
setWeeks((prev) => prev.map((w) => (w.id === id ? { ...w, config } : w))) setWeeks((prev) => prev.map((w) => (w.id === id ? { ...w, config } : w)))
} }
// : // :
const monthlyLocks = (() => { const monthlyLocks = (() => {
if (!startDate) return {} if (!monthlyBoss || !startDate) return {}
const claimed = {} // month -> weekNum (1-based) const claimed = {} // month -> weekNum (1-based)
weeks.forEach((w, idx) => { weeks.forEach((w, idx) => {
const diff = w.config.blackMage?.difficulty const diff = w.config.blackMage?.difficulty
@ -166,9 +176,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
weeks.forEach((w, idx) => { weeks.forEach((w, idx) => {
const r = getWeekRange(startDate, idx + 1) const r = getWeekRange(startDate, idx + 1)
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')] const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
//
if (months.some((m) => claimed[m] === idx + 1)) return if (months.some((m) => claimed[m] === idx + 1)) return
//
if (months.every((m) => m in claimed)) { if (months.every((m) => m in claimed)) {
locks[idx] = claimed[months[0]] ?? claimed[months[1]] locks[idx] = claimed[months[0]] ?? claimed[months[1]]
} }
@ -181,8 +189,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
{weeks.map((w, idx) => { {weeks.map((w, idx) => {
const n = idx + 1 const n = idx + 1
const isOpen = expanded === w.id const isOpen = expanded === w.id
const isCurrent = idx === 0 // : ( ) const isCurrent = idx === 0
// monthlyLocks
const monthlyLockedByWeek = monthlyLocks[idx] ?? null const monthlyLockedByWeek = monthlyLocks[idx] ?? null
return ( return (
<div <div
@ -218,15 +225,24 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
)} )}
<div className="flex-1 flex items-center gap-2"> <div className="flex-1 flex items-center gap-2">
{WEEKLY_BOSSES.map((b) => ( {bosses.map((b) => (
<BossAvatar key={b.key} boss={b} difficulty={w.config.bosses[b.key]?.difficulty} size={40} /> <BossAvatar key={b.key} boss={b} imageBase={imageBase} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
))} ))}
<BossAvatar boss={MONTHLY_BOSSES[0]} difficulty={monthlyLockedByWeek != null ? 'none' : w.config.blackMage?.difficulty} size={40} /> {monthlyBoss && (
<BossAvatar
boss={monthlyBoss}
imageBase={imageBase}
difficulty={monthlyLockedByWeek != null ? 'none' : w.config.blackMage?.difficulty}
size={40}
/>
)}
</div> </div>
{(() => { {(() => {
const weeklySum = calcWeeklySum(w.config) const weeklySum = calcWeeklySum(w.config, bosses)
const monthlySum = monthlyLockedByWeek != null ? 0 : bossEarn(MONTHLY_BOSSES[0], w.config.blackMage) const monthlySum = !monthlyBoss || monthlyLockedByWeek != null
? 0
: bossEarn(monthlyBoss, w.config.blackMage)
return ( return (
<div className="text-right shrink-0 pr-1 tabular-nums leading-tight"> <div className="text-right shrink-0 pr-1 tabular-nums leading-tight">
<div className="text-base font-bold" style={{ color: 'var(--accent-bright)' }}>+{weeklySum}</div> <div className="text-base font-bold" style={{ color: 'var(--accent-bright)' }}>+{weeklySum}</div>
@ -284,6 +300,9 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
onChange={(c) => updateWeek(w.id, c)} onChange={(c) => updateWeek(w.id, c)}
isCurrent={isCurrent} isCurrent={isCurrent}
monthlyLockedByWeek={monthlyLockedByWeek} monthlyLockedByWeek={monthlyLockedByWeek}
bosses={bosses}
monthlyBoss={monthlyBoss}
imageBase={imageBase}
/> />
</div> </div>
</motion.div> </motion.div>

View file

@ -38,6 +38,7 @@ function makeInitialDestinySlot() {
currentPoints: 0, currentPoints: 0,
startDate: dayjs(todayKST()).toISOString(), startDate: dayjs(todayKST()).toISOString(),
weekly: makeEmptyDestinyWeekly(), weekly: makeEmptyDestinyWeekly(),
schedulerWeeks: [{ id: 1, config: makeEmptyDestinyWeekly() }],
} }
} }
@ -83,11 +84,18 @@ export const useLiberationStore = create(persist(
}), }),
{ {
name: 'maple-liberation', name: 'maple-liberation',
version: 1, version: 2,
migrate: (persisted) => { migrate: (persisted) => {
if (!persisted) return persisted if (!persisted) return persisted
// v0→v1: 데스티니 슬롯에 weekly 필드가 없으면 빈 값으로 채움 // 데스티니 슬롯에 weekly/schedulerWeeks 필드가 없으면 빈 값으로 채움
const fill = (slot) => slot ? { ...slot, weekly: slot.weekly || makeEmptyDestinyWeekly() } : slot const fill = (slot) => {
if (!slot) return slot
return {
...slot,
weekly: slot.weekly || makeEmptyDestinyWeekly(),
schedulerWeeks: slot.schedulerWeeks || [{ id: 1, config: makeEmptyDestinyWeekly() }],
}
}
return { return {
...persisted, ...persisted,
destinySimple: fill(persisted.destinySimple), destinySimple: fill(persisted.destinySimple),