데스티니 주차별 계산 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,
formatDate,
} from '../data'
import { useLiberationStore } from '../store'
import { useLiberationStore, makeEmptyDestinyWeekly } from '../store'
import { calcWeekPoints } from '../utils'
import ProgressBar from './components/ProgressBar'
import QuestSelector from './components/QuestSelector'
@ -22,6 +22,9 @@ export default function Destiny() {
const setState = (updater) => updateSlot(updater)
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 (
<>
@ -34,8 +37,8 @@ export default function Destiny() {
}}
>
{[
{ key: 'simple', label: '단순 계산' },
{ key: 'weekly', label: '주차별 계산' },
{ key: 'simple', label: '일반' },
{ key: 'weekly', label: '주차별' },
].map((t) => {
const active = calcMode === t.key
return (
@ -126,30 +129,19 @@ export default function Destiny() {
</div>
</div>
{calcMode === 'simple' ? (
<WeeklyDefault
bosses={DESTINY_BOSSES}
imageBase={DESTINY_BOSS_IMAGE_BASE}
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={weeklyEarn}
remaining={0}
mode="simple"
hasScheduler={false}
/>
) : (
<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>
)}
<WeeklyDefault
bosses={DESTINY_BOSSES}
imageBase={DESTINY_BOSS_IMAGE_BASE}
makeEmptyConfig={makeEmptyDestinyWeekly}
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={headerWeekly}
remaining={0}
mode={calcMode}
startDate={state.startDate}
weeks={state.schedulerWeeks}
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
/>
</>
)
}

View file

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

View file

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

View file

@ -1,7 +1,5 @@
import { useState } from 'react'
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 { 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)' },
}
function BossAvatar({ boss, difficulty, size = 40 }) {
function BossAvatar({ boss, imageBase, difficulty, size = 40 }) {
const badge = DIFF_BADGE[difficulty]
const enabled = difficulty && difficulty !== 'none'
return (
@ -32,7 +30,7 @@ function BossAvatar({ boss, difficulty, size = 40 }) {
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
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) => {
onChange({ ...config, bosses: { ...config.bosses, [key]: { ...config.bosses[key], ...patch } } })
}
@ -61,7 +59,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
return (
<div>
{WEEKLY_BOSSES.map((boss, i) => (
{bosses.map((boss, i) => (
<div
key={boss.key}
className={i > 0 ? 'border-t' : ''}
@ -71,23 +69,27 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
boss={boss}
sel={config.bosses[boss.key]}
onChange={(patch) => updateBoss(boss.key, patch)}
imageBase={imageBase}
showDone={isCurrent}
/>
</div>
))}
<div
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
style={{ borderColor: 'var(--row-divider)' }}
>
<BossRow
boss={MONTHLY_BOSSES[0]}
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
onChange={updateBlackMage}
monthly
showDone={isCurrent}
/>
</div>
{blackmageLocked && (
{monthlyBoss && (
<div
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
style={{ borderColor: 'var(--row-divider)' }}
>
<BossRow
boss={monthlyBoss}
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
onChange={updateBlackMage}
imageBase={imageBase}
monthly
showDone={isCurrent}
/>
</div>
)}
{monthlyBoss && blackmageLocked && (
<div
className="text-[11px] px-3 py-2"
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
? weeksProp
: [{ id: 1, config: makeEmptyWeekly() }]
: [{ id: 1, config: makeEmptyConfig() }]
const setWeeks = (updater) => {
const next = typeof updater === 'function' ? updater(weeks) : updater
onChangeWeeks?.(next)
@ -114,13 +124,13 @@ 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)) : makeEmptyWeekly()
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyConfig()
// done
Object.keys(base.bosses).forEach((k) => { base.bosses[k].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 newMonth = getWeekRange(startDate, newIdx).start.format('YYYY-MM')
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)))
}
// :
// :
const monthlyLocks = (() => {
if (!startDate) return {}
if (!monthlyBoss || !startDate) return {}
const claimed = {} // month -> weekNum (1-based)
weeks.forEach((w, idx) => {
const diff = w.config.blackMage?.difficulty
@ -166,9 +176,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
weeks.forEach((w, idx) => {
const r = getWeekRange(startDate, idx + 1)
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
//
if (months.some((m) => claimed[m] === idx + 1)) return
//
if (months.every((m) => m in claimed)) {
locks[idx] = claimed[months[0]] ?? claimed[months[1]]
}
@ -181,8 +189,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
{weeks.map((w, idx) => {
const n = idx + 1
const isOpen = expanded === w.id
const isCurrent = idx === 0 // : ( )
// monthlyLocks
const isCurrent = idx === 0
const monthlyLockedByWeek = monthlyLocks[idx] ?? null
return (
<div
@ -218,15 +225,24 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
)}
<div className="flex-1 flex items-center gap-2">
{WEEKLY_BOSSES.map((b) => (
<BossAvatar key={b.key} boss={b} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
{bosses.map((b) => (
<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>
{(() => {
const weeklySum = calcWeeklySum(w.config)
const monthlySum = monthlyLockedByWeek != null ? 0 : bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
const weeklySum = calcWeeklySum(w.config, bosses)
const monthlySum = !monthlyBoss || monthlyLockedByWeek != null
? 0
: bossEarn(monthlyBoss, w.config.blackMage)
return (
<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>
@ -284,6 +300,9 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
onChange={(c) => updateWeek(w.id, c)}
isCurrent={isCurrent}
monthlyLockedByWeek={monthlyLockedByWeek}
bosses={bosses}
monthlyBoss={monthlyBoss}
imageBase={imageBase}
/>
</div>
</motion.div>

View file

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