해방 계산기 현재 상태 입력 섹션 추가
- 시작 날짜 / 진행 중 퀘스트 / 현재 흔적 입력 카드 - 퀘스트 선택 드롭다운을 일반 보스 초상화 + 텍스트로 단순화 - 각 퀘스트별 최대 3000 흔적 누적 (다음 퀘스트로 자동 이월 안 함) - 날짜 유틸을 dayjs(KST) 기반으로 통일 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f27c46f68d
commit
f0c0ea3c1c
6 changed files with 121 additions and 57 deletions
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.91.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"framer-motion": "^12.23.22",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
|
@ -1522,6 +1523,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.20",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.91.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"framer-motion": "^12.23.22",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useMemo, useEffect } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
GENESIS_CHAPTERS,
|
||||
GENESIS_TOTAL,
|
||||
|
|
@ -7,6 +8,7 @@ import {
|
|||
calcPoints,
|
||||
addWeeks,
|
||||
formatDate,
|
||||
todayKST,
|
||||
} from './data'
|
||||
import WeekCard from './components/WeekCard'
|
||||
import QuestSelector from './components/QuestSelector'
|
||||
|
|
@ -26,7 +28,7 @@ function makeEmptyWeek(startDate) {
|
|||
}
|
||||
})
|
||||
return {
|
||||
startDate: startDate.toISOString(),
|
||||
startDate: dayjs(startDate).toISOString(),
|
||||
bosses,
|
||||
blackMage: {
|
||||
enabled: false,
|
||||
|
|
@ -62,7 +64,7 @@ export default function Liberation() {
|
|||
return {
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
weeks: [makeEmptyWeek(new Date())],
|
||||
weeks: [makeEmptyWeek(todayKST())],
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -76,7 +78,9 @@ export default function Liberation() {
|
|||
const startConsumedBefore = GENESIS_CHAPTERS
|
||||
.slice(0, state.startChapter)
|
||||
.reduce((s, c) => s + c.required, 0)
|
||||
let totalAccumulated = startConsumedBefore + state.currentPoints
|
||||
const currentChapterCap = GENESIS_CHAPTERS[state.startChapter]?.required ?? 0
|
||||
const clampedCurrent = Math.min(state.currentPoints, currentChapterCap)
|
||||
let totalAccumulated = startConsumedBefore + clampedCurrent
|
||||
|
||||
for (const week of state.weeks) {
|
||||
const earned = calcWeekPoints(week)
|
||||
|
|
@ -108,11 +112,19 @@ export default function Liberation() {
|
|||
return result
|
||||
}, [state])
|
||||
|
||||
const initialCap = GENESIS_CHAPTERS[state.startChapter]?.required ?? 0
|
||||
const initialClamped = Math.min(state.currentPoints, initialCap)
|
||||
const initialAccumulated = GENESIS_CHAPTERS
|
||||
.slice(0, state.startChapter)
|
||||
.reduce((s, c) => s + c.required, 0) + initialClamped
|
||||
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
|
||||
const completedWeekIdx = progressByWeek.findIndex((w) => w.completed)
|
||||
const isDone = completedWeekIdx >= 0
|
||||
const completionDate = isDone
|
||||
? addWeeks(new Date(state.weeks[completedWeekIdx].startDate), 1)
|
||||
: null
|
||||
const isDone = alreadyDone || completedWeekIdx >= 0
|
||||
const completionDate = alreadyDone
|
||||
? todayKST()
|
||||
: completedWeekIdx >= 0
|
||||
? addWeeks(state.weeks[completedWeekIdx].startDate, 1)
|
||||
: null
|
||||
|
||||
const updateWeek = (idx, newWeekData) => {
|
||||
setState((prev) => ({
|
||||
|
|
@ -124,10 +136,10 @@ export default function Liberation() {
|
|||
const addWeek = () => {
|
||||
setState((prev) => {
|
||||
const lastWeek = prev.weeks[prev.weeks.length - 1]
|
||||
const nextStart = addWeeks(new Date(lastWeek.startDate), 1)
|
||||
const nextStart = addWeeks(lastWeek.startDate, 1)
|
||||
return {
|
||||
...prev,
|
||||
weeks: [...prev.weeks, { ...lastWeek, startDate: nextStart.toISOString() }],
|
||||
weeks: [...prev.weeks, { ...lastWeek, startDate: dayjs(nextStart).toISOString() }],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -141,16 +153,15 @@ export default function Liberation() {
|
|||
setState({
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
weeks: [makeEmptyWeek(new Date())],
|
||||
weeks: [makeEmptyWeek(todayKST())],
|
||||
})
|
||||
}
|
||||
|
||||
const setFirstWeekDate = (dateStr) => {
|
||||
const d = new Date(dateStr)
|
||||
setState((prev) => {
|
||||
const weeks = prev.weeks.map((w, i) => ({
|
||||
...w,
|
||||
startDate: addWeeks(d, i).toISOString(),
|
||||
startDate: dayjs(addWeeks(dateStr, i)).toISOString(),
|
||||
}))
|
||||
return { ...prev, weeks }
|
||||
})
|
||||
|
|
@ -163,9 +174,43 @@ export default function Liberation() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<ProgressBar
|
||||
totalAccumulated={totalCumulative}
|
||||
startChapter={state.startChapter}
|
||||
currentPoints={state.currentPoints}
|
||||
completionDate={isDone ? formatDate(completionDate) : null}
|
||||
/>
|
||||
|
||||
{/* 현재 진행 상태 입력 */}
|
||||
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
|
||||
<div className="text-lg font-semibold text-emerald-300">현재 진행 상태</div>
|
||||
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: '1fr 1.2fr 1fr' }}>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">시작 날짜</label>
|
||||
<DatePicker
|
||||
value={formatDate(state.weeks[0].startDate)}
|
||||
onChange={setFirstWeekDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">진행 중인 퀘스트</label>
|
||||
<QuestSelector
|
||||
value={state.startChapter}
|
||||
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">현재 흔적</label>
|
||||
<PointsInput
|
||||
value={state.currentPoints}
|
||||
max={3000}
|
||||
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
|
||||
className="w-full h-12 rounded-lg border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,20 +5,15 @@ function formatKoreanDate(s) {
|
|||
return `${y}년 ${m}월 ${d}일`
|
||||
}
|
||||
|
||||
export default function ProgressBar({ totalAccumulated, completionDate }) {
|
||||
const chapterStates = []
|
||||
let remaining = totalAccumulated
|
||||
for (const c of GENESIS_CHAPTERS) {
|
||||
if (remaining >= c.required) {
|
||||
chapterStates.push({ chapter: c, status: 'done', current: c.required })
|
||||
remaining -= c.required
|
||||
} else if (remaining > 0) {
|
||||
chapterStates.push({ chapter: c, status: 'active', current: remaining })
|
||||
remaining = 0
|
||||
} else {
|
||||
chapterStates.push({ chapter: c, status: 'pending', current: 0 })
|
||||
export default function ProgressBar({ startChapter, currentPoints, completionDate }) {
|
||||
const chapterStates = GENESIS_CHAPTERS.map((c) => {
|
||||
if (c.idx < startChapter) return { chapter: c, status: 'done', current: c.required }
|
||||
if (c.idx === startChapter) {
|
||||
const filled = Math.min(currentPoints, c.required)
|
||||
return { chapter: c, status: filled > 0 ? 'active' : 'active', current: filled }
|
||||
}
|
||||
}
|
||||
return { chapter: c, status: 'pending', current: 0 }
|
||||
})
|
||||
|
||||
const renderSegment = ({ chapter, status, current }) => {
|
||||
const pct = (current / chapter.required) * 100
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { GENESIS_CHAPTERS, QUEST_BTBOSS_IMAGE_BASE } from '../data'
|
||||
import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../data'
|
||||
|
||||
/**
|
||||
* 진행 중인 퀘스트 드롭다운
|
||||
* - 선택된 옵션과 옵션 리스트 모두 btboss 이미지로 표시
|
||||
* - 보스 초상화 + 이름 텍스트
|
||||
*/
|
||||
export default function QuestSelector({ value, onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -25,25 +25,30 @@ export default function QuestSelector({ value, onChange }) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`relative w-full h-12 flex items-center justify-center rounded-lg border bg-gray-950 px-3 transition ${
|
||||
className={`w-full h-12 flex items-center gap-3 rounded-lg border bg-gray-950 pl-2 pr-3 transition ${
|
||||
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`${QUEST_BTBOSS_IMAGE_BASE}/${selected.boss}.png`}
|
||||
alt={selected.boss}
|
||||
className="h-8 block"
|
||||
/>
|
||||
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-900">
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.png`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm font-medium text-gray-100">
|
||||
{selected.boss}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 12 12" fill="none"
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-50 flex flex-col items-center gap-1">
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-white/10 bg-gray-900 shadow-2xl py-1 max-h-72 overflow-y-auto">
|
||||
{GENESIS_CHAPTERS.map((chapter) => {
|
||||
const isSelected = chapter.idx === value
|
||||
return (
|
||||
|
|
@ -51,15 +56,22 @@ export default function QuestSelector({ value, onChange }) {
|
|||
key={chapter.idx}
|
||||
type="button"
|
||||
onClick={() => { onChange(chapter.idx); setOpen(false) }}
|
||||
className={`relative transition ${
|
||||
isSelected ? 'scale-105' : 'opacity-60 hover:opacity-100'
|
||||
className={`w-full flex items-center gap-3 px-2 py-1.5 transition ${
|
||||
isSelected ? 'bg-emerald-500/10' : 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`${QUEST_BTBOSS_IMAGE_BASE}/${chapter.boss}.png`}
|
||||
alt={chapter.boss}
|
||||
className="h-10 block drop-shadow-lg"
|
||||
/>
|
||||
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-950">
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.png`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className={`flex-1 text-left text-sm font-medium ${
|
||||
isSelected ? 'text-emerald-300' : 'text-gray-200'
|
||||
}`}>
|
||||
{chapter.boss}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -96,25 +96,29 @@ export function calcPoints(basePoints, partySize) {
|
|||
// 목요일 기준 주차 계산 (KST)
|
||||
// 이번 주 목요일 자정 = 이번 주의 시작
|
||||
export function getThursdayOfWeek(date) {
|
||||
const d = new Date(date)
|
||||
const day = d.getDay() // 0=일, 4=목
|
||||
// 직전 목요일 찾기 (오늘이 목요일이면 오늘)
|
||||
const d = dayjs(date).tz(KST)
|
||||
const day = d.day() // 0=일, 4=목
|
||||
const diff = (day - 4 + 7) % 7
|
||||
d.setDate(d.getDate() - diff)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
return d.subtract(diff, 'day').startOf('day').toDate()
|
||||
}
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
const KST = 'Asia/Seoul'
|
||||
|
||||
export function formatDate(date) {
|
||||
const d = new Date(date)
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
return dayjs(date).tz(KST).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export function addWeeks(date, weeks) {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + weeks * 7)
|
||||
return d
|
||||
return dayjs(date).tz(KST).add(weeks, 'week').toDate()
|
||||
}
|
||||
|
||||
export function todayKST() {
|
||||
return dayjs().tz(KST).startOf('day').toDate()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue