심볼 계산기 계산 기능 + 체납 툴팁 + 탭 저장
- 완료일 계산: 매일 일퀘 + 매 목요일 주간퀘 n회분 일괄 지급으로 시뮬레이션 (extra는 즉시 적용, 금일 일퀘 완료면 오늘 제외) - 각 카드의 남은 일수/예상 완료일, 탭 전체의 완료 예상일 표시 - 주간퀘에 0회(0개) 옵션 추가 - 성장치 호버 시 현재 성장치로 올릴 수 있는 최대 레벨 툴팁 - 선택 탭(아케인/어센틱/그랜드 어센틱)을 캐릭터별로 persist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e01aa99069
commit
2f64941817
2 changed files with 112 additions and 22 deletions
|
|
@ -1,11 +1,48 @@
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import utc from 'dayjs/plugin/utc'
|
||||||
|
import timezone from 'dayjs/plugin/timezone'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import { useLayout } from '../../components/Layout'
|
import { useLayout } from '../../components/Layout'
|
||||||
import Select from '../../components/Select'
|
import Select from '../../components/Select'
|
||||||
import Tooltip from '../../components/Tooltip'
|
import Tooltip from '../../components/Tooltip'
|
||||||
import { useSymbolStore } from './store'
|
import { useSymbolStore } from './store'
|
||||||
|
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
const KST = 'Asia/Seoul'
|
||||||
|
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||||
|
|
||||||
|
function formatKoreanDate(d) {
|
||||||
|
const dj = dayjs(d).tz(KST)
|
||||||
|
return `${dj.year()}년 ${String(dj.month() + 1).padStart(2, '0')}월 ${String(dj.date()).padStart(2, '0')}일 (${DOW[dj.day()]})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 심볼 완료까지 남은 일수/예상 완료일 계산
|
||||||
|
* - 일퀘는 매일, 주간퀘는 매주 목요일 리셋 시 N회분을 한 번에 지급한다고 가정
|
||||||
|
* - extra(추가 심볼)는 즉시 적용
|
||||||
|
* - dailyDone이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급)
|
||||||
|
*/
|
||||||
|
function computeCompletion({ remainingSymbols, daily, weeklyPerWeek, extra, dailyDone }) {
|
||||||
|
const need = Math.max(remainingSymbols - extra, 0)
|
||||||
|
if (need === 0) return { days: 0, date: dayjs().tz(KST).startOf('day').toDate() }
|
||||||
|
if (daily <= 0 && weeklyPerWeek <= 0) return { days: null, date: null }
|
||||||
|
|
||||||
|
let acc = 0
|
||||||
|
let cursor = dayjs().tz(KST).startOf('day')
|
||||||
|
for (let day = 0; day < 3650; day++) {
|
||||||
|
// 오늘은 dailyDone이면 일퀘 없음, 그 외엔 daily
|
||||||
|
if (!(day === 0 && dailyDone)) acc += daily
|
||||||
|
// 목요일(day=4)에 주간퀘 전량 지급
|
||||||
|
if (cursor.day() === 4 && weeklyPerWeek > 0) acc += weeklyPerWeek
|
||||||
|
if (acc >= need) return { days: day, date: cursor.toDate() }
|
||||||
|
cursor = cursor.add(1, 'day')
|
||||||
|
}
|
||||||
|
return { days: null, date: null }
|
||||||
|
}
|
||||||
|
|
||||||
function formatMesoKorean(n) {
|
function formatMesoKorean(n) {
|
||||||
const v = Number(n) || 0
|
const v = Number(n) || 0
|
||||||
if (v <= 0) return '0'
|
if (v <= 0) return '0'
|
||||||
|
|
@ -105,8 +142,31 @@ function SymbolCard({ symbol, equipped, charId }) {
|
||||||
return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr }
|
return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr }
|
||||||
}, [equipped, level, growth, symbol.levels])
|
}, [equipped, level, growth, symbol.levels])
|
||||||
|
|
||||||
const daysLeft = '-'
|
// 현재 성장치로 도달 가능한 최대 레벨 (연속 체납 반영)
|
||||||
const completeDate = '-'
|
const reachableLevel = useMemo(() => {
|
||||||
|
if (!equipped || isMax) return level
|
||||||
|
let lv = level
|
||||||
|
let g = growth
|
||||||
|
while (lv < symbol.max_level) {
|
||||||
|
const req = symbol.levels?.find((l) => l.level === lv)?.required_count
|
||||||
|
if (!req || g < req) break
|
||||||
|
g -= req
|
||||||
|
lv += 1
|
||||||
|
}
|
||||||
|
return lv
|
||||||
|
}, [equipped, isMax, level, growth, symbol.levels, symbol.max_level])
|
||||||
|
|
||||||
|
// 남은 일수/예상 완료일
|
||||||
|
const { days: daysLeft, date: completeDate } = useMemo(() => {
|
||||||
|
if (!equipped || isMax) return { days: null, date: null }
|
||||||
|
return computeCompletion({
|
||||||
|
remainingSymbols,
|
||||||
|
daily,
|
||||||
|
weeklyPerWeek: (weeklyCount || 0) * (symbol.weekly_default || 0),
|
||||||
|
extra,
|
||||||
|
dailyDone,
|
||||||
|
})
|
||||||
|
}, [equipped, isMax, remainingSymbols, daily, weeklyCount, symbol.weekly_default, extra, dailyDone])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-2xl border p-5 transition ${
|
<div className={`rounded-2xl border p-5 transition ${
|
||||||
|
|
@ -152,13 +212,21 @@ function SymbolCard({ symbol, equipped, charId }) {
|
||||||
{/* 진행도 바 */}
|
{/* 진행도 바 */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex justify-between text-sm tabular-nums mb-1.5">
|
<div className="flex justify-between text-sm tabular-nums mb-1.5">
|
||||||
<span className="text-gray-400">
|
{reachableLevel > level ? (
|
||||||
성장치 {isMax ? (
|
<Tooltip text={`Lv.${reachableLevel}까지 상승 가능`}>
|
||||||
<span className="text-amber-300 font-bold">MAX</span>
|
<span className="text-gray-400">
|
||||||
) : (
|
성장치 {growth} / {requireGrowth}
|
||||||
<>{growth} / {requireGrowth}</>
|
</span>
|
||||||
)}
|
</Tooltip>
|
||||||
</span>
|
) : (
|
||||||
|
<span className="text-gray-400">
|
||||||
|
성장치 {isMax ? (
|
||||||
|
<span className="text-amber-300 font-bold">MAX</span>
|
||||||
|
) : (
|
||||||
|
<>{growth} / {requireGrowth}</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!isMax && (
|
{!isMax && (
|
||||||
<span className="text-gray-400">
|
<span className="text-gray-400">
|
||||||
{requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
|
{requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
|
||||||
|
|
@ -195,7 +263,7 @@ function SymbolCard({ symbol, equipped, charId }) {
|
||||||
<Select
|
<Select
|
||||||
value={weeklyCount}
|
value={weeklyCount}
|
||||||
onChange={(v) => patch({ weeklyCount: v })}
|
onChange={(v) => patch({ weeklyCount: v })}
|
||||||
options={[1, 2, 3].map((n) => ({
|
options={[0, 1, 2, 3].map((n) => ({
|
||||||
value: n,
|
value: n,
|
||||||
label: `${n * symbol.weekly_default}개`,
|
label: `${n * symbol.weekly_default}개`,
|
||||||
}))}
|
}))}
|
||||||
|
|
@ -250,12 +318,14 @@ function SymbolCard({ symbol, equipped, charId }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2">
|
<div className="flex justify-between py-2">
|
||||||
<span className="text-gray-400">남은 일수</span>
|
<span className="text-gray-400">남은 일수</span>
|
||||||
<span className="tabular-nums text-gray-200 font-medium">{typeof daysLeft === 'number' ? `${daysLeft}일` : daysLeft}</span>
|
<span className="tabular-nums text-gray-200 font-medium">
|
||||||
|
{equipped && !isMax && daysLeft != null ? `${daysLeft.toLocaleString()}일` : '-'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2">
|
<div className="flex justify-between py-2">
|
||||||
<span className="text-gray-400">예상 완료일</span>
|
<span className="text-gray-400">예상 완료일</span>
|
||||||
<span className={`tabular-nums font-semibold ${equipped ? 'text-emerald-300' : 'text-gray-600'}`}>
|
<span className={`tabular-nums font-semibold ${equipped && !isMax && completeDate ? 'text-emerald-300' : 'text-gray-600'}`}>
|
||||||
{completeDate}
|
{equipped && !isMax && completeDate ? formatKoreanDate(completeDate) : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,16 +357,17 @@ export default function Symbol() {
|
||||||
.map((t) => ({ key: t, label: `${t} 심볼`, image_url: groups[t].image_url }))
|
.map((t) => ({ key: t, label: `${t} 심볼`, image_url: groups[t].image_url }))
|
||||||
}, [allSymbols])
|
}, [allSymbols])
|
||||||
|
|
||||||
const [tab, setTab] = useState(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tab && tabs.length) setTab(tabs[0].key)
|
|
||||||
}, [tabs, tab])
|
|
||||||
const characters = useSymbolStore((s) => s.characters)
|
const characters = useSymbolStore((s) => s.characters)
|
||||||
const selectedCharId = useSymbolStore((s) => s.selectedCharId)
|
const selectedCharId = useSymbolStore((s) => s.selectedCharId)
|
||||||
const addCharacter = useSymbolStore((s) => s.addCharacter)
|
const addCharacter = useSymbolStore((s) => s.addCharacter)
|
||||||
const removeCharacter = useSymbolStore((s) => s.removeCharacter)
|
const removeCharacter = useSymbolStore((s) => s.removeCharacter)
|
||||||
const selectCharacter = useSymbolStore((s) => s.selectCharacter)
|
const selectCharacter = useSymbolStore((s) => s.selectCharacter)
|
||||||
const syncCharacterSymbols = useSymbolStore((s) => s.syncCharacterSymbols)
|
const syncCharacterSymbols = useSymbolStore((s) => s.syncCharacterSymbols)
|
||||||
|
const storedTab = useSymbolStore((s) => s.selectedTabs?.[selectedCharId])
|
||||||
|
const setTabStore = useSymbolStore((s) => s.setTab)
|
||||||
|
|
||||||
|
const tab = storedTab || tabs[0]?.key || null
|
||||||
|
const setTab = (t) => { if (selectedCharId) setTabStore(selectedCharId, t) }
|
||||||
|
|
||||||
// 각 캐릭터의 장착 심볼 fetch (새로고침마다 갱신)
|
// 각 캐릭터의 장착 심볼 fetch (새로고침마다 갱신)
|
||||||
const symbolQueries = useQueries({
|
const symbolQueries = useQueries({
|
||||||
|
|
@ -364,24 +435,35 @@ export default function Symbol() {
|
||||||
const progress = useSymbolStore((s) => s.progress[selectedCharId])
|
const progress = useSymbolStore((s) => s.progress[selectedCharId])
|
||||||
const isEquipped = (symbolId) => !!progress?.[symbolId]?.equipped
|
const isEquipped = (symbolId) => !!progress?.[symbolId]?.equipped
|
||||||
|
|
||||||
// 현재 탭의 누적 메소 계산
|
// 현재 탭의 누적 메소 + 최종 완료일 계산
|
||||||
const { totalRequiredMeso, totalArrearMeso } = useMemo(() => {
|
const { totalRequiredMeso, totalArrearMeso, overallDate } = useMemo(() => {
|
||||||
let req = 0, arr = 0
|
let req = 0, arr = 0, latest = null
|
||||||
for (const s of symbols) {
|
for (const s of symbols) {
|
||||||
const p = progress?.[s.id]
|
const p = progress?.[s.id]
|
||||||
if (!p?.equipped) continue
|
if (!p?.equipped) continue
|
||||||
if (p.level >= s.max_level) continue
|
if (p.level >= s.max_level) continue
|
||||||
|
let remaining = 0
|
||||||
for (const l of s.levels || []) {
|
for (const l of s.levels || []) {
|
||||||
if (l.level < p.level) continue
|
if (l.level < p.level) continue
|
||||||
if (l.level === p.level) {
|
if (l.level === p.level) {
|
||||||
req += l.meso_cost
|
req += l.meso_cost
|
||||||
if ((p.growth || 0) >= l.required_count) arr += l.meso_cost
|
if ((p.growth || 0) >= l.required_count) arr += l.meso_cost
|
||||||
|
remaining += Math.max(l.required_count - (p.growth || 0), 0)
|
||||||
} else {
|
} else {
|
||||||
req += l.meso_cost
|
req += l.meso_cost
|
||||||
|
remaining += l.required_count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const { date } = computeCompletion({
|
||||||
|
remainingSymbols: remaining,
|
||||||
|
daily: p.daily ?? s.daily_default ?? 0,
|
||||||
|
weeklyPerWeek: (p.weeklyCount ?? 3) * (s.weekly_default || 0),
|
||||||
|
extra: p.extra || 0,
|
||||||
|
dailyDone: !!p.dailyDone,
|
||||||
|
})
|
||||||
|
if (date && (!latest || date > latest)) latest = date
|
||||||
}
|
}
|
||||||
return { totalRequiredMeso: req, totalArrearMeso: arr }
|
return { totalRequiredMeso: req, totalArrearMeso: arr, overallDate: latest }
|
||||||
}, [symbols, progress])
|
}, [symbols, progress])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -464,7 +546,9 @@ export default function Symbol() {
|
||||||
<div className="rounded-2xl border border-white/10 bg-gradient-to-br from-emerald-500/10 to-emerald-500/[0.02] p-6 flex items-center justify-between gap-6 flex-wrap">
|
<div className="rounded-2xl border border-white/10 bg-gradient-to-br from-emerald-500/10 to-emerald-500/[0.02] p-6 flex items-center justify-between gap-6 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base text-gray-400">{tabInfo?.label} 전체 만렙 완료 예상일</div>
|
<div className="text-base text-gray-400">{tabInfo?.label} 전체 만렙 완료 예상일</div>
|
||||||
<div className="text-3xl font-bold text-emerald-300 tabular-nums mt-1.5">-</div>
|
<div className="text-3xl font-bold text-emerald-300 tabular-nums mt-1.5">
|
||||||
|
{overallDate ? formatKoreanDate(overallDate) : '-'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="text-right pr-10">
|
<div className="text-right pr-10">
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ export const useSymbolStore = create(persist(
|
||||||
characters: [],
|
characters: [],
|
||||||
selectedCharId: null,
|
selectedCharId: null,
|
||||||
progress: {},
|
progress: {},
|
||||||
|
selectedTabs: {}, // { [charId]: '아케인' | '어센틱' | '그랜드 어센틱' }
|
||||||
|
|
||||||
|
setTab: (charId, tabKey) => set((s) => ({
|
||||||
|
selectedTabs: { ...s.selectedTabs, [charId]: tabKey },
|
||||||
|
})),
|
||||||
|
|
||||||
setCharacters: (next) => set((s) => ({
|
setCharacters: (next) => set((s) => ({
|
||||||
characters: typeof next === 'function' ? next(s.characters) : next,
|
characters: typeof next === 'function' ? next(s.characters) : next,
|
||||||
|
|
@ -106,6 +111,7 @@ export const useSymbolStore = create(persist(
|
||||||
characters: persisted.characters || [],
|
characters: persisted.characters || [],
|
||||||
selectedCharId: persisted.selectedCharId ?? null,
|
selectedCharId: persisted.selectedCharId ?? null,
|
||||||
progress: persisted.progress || {},
|
progress: persisted.progress || {},
|
||||||
|
selectedTabs: persisted.selectedTabs || {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue