해방 계산기 현재 상태 입력 섹션 추가

- 시작 날짜 / 진행 중 퀘스트 / 현재 흔적 입력 카드
- 퀘스트 선택 드롭다운을 일반 보스 초상화 + 텍스트로 단순화
- 각 퀘스트별 최대 3000 흔적 누적 (다음 퀘스트로 자동 이월 안 함)
- 날짜 유틸을 dayjs(KST) 기반으로 통일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-14 08:55:39 +09:00
parent f27c46f68d
commit f0c0ea3c1c
6 changed files with 121 additions and 57 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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,10 +112,18 @@ 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)
const isDone = alreadyDone || completedWeekIdx >= 0
const completionDate = alreadyDone
? todayKST()
: completedWeekIdx >= 0
? addWeeks(state.weeks[completedWeekIdx].startDate, 1)
: null
const updateWeek = (idx, newWeekData) => {
@ -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>
)
}

View file

@ -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

View file

@ -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'
}`}
>
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-900">
<img
src={`${QUEST_BTBOSS_IMAGE_BASE}/${selected.boss}.png`}
alt={selected.boss}
className="h-8 block"
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'
}`}
>
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-950">
<img
src={`${QUEST_BTBOSS_IMAGE_BASE}/${chapter.boss}.png`}
alt={chapter.boss}
className="h-10 block drop-shadow-lg"
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>
)
})}

View file

@ -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()
}