데스티니 주차별 계산 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:
parent
99500d91af
commit
ee30c87518
5 changed files with 95 additions and 69 deletions
|
|
@ -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 }))}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue