해방 계산기 현재 상태 입력 섹션 추가
- 시작 날짜 / 진행 중 퀘스트 / 현재 흔적 입력 카드 - 퀘스트 선택 드롭다운을 일반 보스 초상화 + 텍스트로 단순화 - 각 퀘스트별 최대 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/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.91.0",
|
"@tanstack/react-query": "^5.91.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"framer-motion": "^12.23.22",
|
"framer-motion": "^12.23.22",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|
@ -1522,6 +1523,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.91.0",
|
"@tanstack/react-query": "^5.91.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"framer-motion": "^12.23.22",
|
"framer-motion": "^12.23.22",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
GENESIS_CHAPTERS,
|
GENESIS_CHAPTERS,
|
||||||
GENESIS_TOTAL,
|
GENESIS_TOTAL,
|
||||||
|
|
@ -7,6 +8,7 @@ import {
|
||||||
calcPoints,
|
calcPoints,
|
||||||
addWeeks,
|
addWeeks,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
todayKST,
|
||||||
} from './data'
|
} from './data'
|
||||||
import WeekCard from './components/WeekCard'
|
import WeekCard from './components/WeekCard'
|
||||||
import QuestSelector from './components/QuestSelector'
|
import QuestSelector from './components/QuestSelector'
|
||||||
|
|
@ -26,7 +28,7 @@ function makeEmptyWeek(startDate) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
startDate: startDate.toISOString(),
|
startDate: dayjs(startDate).toISOString(),
|
||||||
bosses,
|
bosses,
|
||||||
blackMage: {
|
blackMage: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|
@ -62,7 +64,7 @@ export default function Liberation() {
|
||||||
return {
|
return {
|
||||||
startChapter: 0,
|
startChapter: 0,
|
||||||
currentPoints: 0,
|
currentPoints: 0,
|
||||||
weeks: [makeEmptyWeek(new Date())],
|
weeks: [makeEmptyWeek(todayKST())],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -76,7 +78,9 @@ export default function Liberation() {
|
||||||
const startConsumedBefore = GENESIS_CHAPTERS
|
const startConsumedBefore = GENESIS_CHAPTERS
|
||||||
.slice(0, state.startChapter)
|
.slice(0, state.startChapter)
|
||||||
.reduce((s, c) => s + c.required, 0)
|
.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) {
|
for (const week of state.weeks) {
|
||||||
const earned = calcWeekPoints(week)
|
const earned = calcWeekPoints(week)
|
||||||
|
|
@ -108,11 +112,19 @@ export default function Liberation() {
|
||||||
return result
|
return result
|
||||||
}, [state])
|
}, [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 completedWeekIdx = progressByWeek.findIndex((w) => w.completed)
|
||||||
const isDone = completedWeekIdx >= 0
|
const isDone = alreadyDone || completedWeekIdx >= 0
|
||||||
const completionDate = isDone
|
const completionDate = alreadyDone
|
||||||
? addWeeks(new Date(state.weeks[completedWeekIdx].startDate), 1)
|
? todayKST()
|
||||||
: null
|
: completedWeekIdx >= 0
|
||||||
|
? addWeeks(state.weeks[completedWeekIdx].startDate, 1)
|
||||||
|
: null
|
||||||
|
|
||||||
const updateWeek = (idx, newWeekData) => {
|
const updateWeek = (idx, newWeekData) => {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
|
|
@ -124,10 +136,10 @@ export default function Liberation() {
|
||||||
const addWeek = () => {
|
const addWeek = () => {
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
const lastWeek = prev.weeks[prev.weeks.length - 1]
|
const lastWeek = prev.weeks[prev.weeks.length - 1]
|
||||||
const nextStart = addWeeks(new Date(lastWeek.startDate), 1)
|
const nextStart = addWeeks(lastWeek.startDate, 1)
|
||||||
return {
|
return {
|
||||||
...prev,
|
...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({
|
setState({
|
||||||
startChapter: 0,
|
startChapter: 0,
|
||||||
currentPoints: 0,
|
currentPoints: 0,
|
||||||
weeks: [makeEmptyWeek(new Date())],
|
weeks: [makeEmptyWeek(todayKST())],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const setFirstWeekDate = (dateStr) => {
|
const setFirstWeekDate = (dateStr) => {
|
||||||
const d = new Date(dateStr)
|
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
const weeks = prev.weeks.map((w, i) => ({
|
const weeks = prev.weeks.map((w, i) => ({
|
||||||
...w,
|
...w,
|
||||||
startDate: addWeeks(d, i).toISOString(),
|
startDate: dayjs(addWeeks(dateStr, i)).toISOString(),
|
||||||
}))
|
}))
|
||||||
return { ...prev, weeks }
|
return { ...prev, weeks }
|
||||||
})
|
})
|
||||||
|
|
@ -163,9 +174,43 @@ export default function Liberation() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
totalAccumulated={totalCumulative}
|
startChapter={state.startChapter}
|
||||||
|
currentPoints={state.currentPoints}
|
||||||
completionDate={isDone ? formatDate(completionDate) : null}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,15 @@ function formatKoreanDate(s) {
|
||||||
return `${y}년 ${m}월 ${d}일`
|
return `${y}년 ${m}월 ${d}일`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar({ totalAccumulated, completionDate }) {
|
export default function ProgressBar({ startChapter, currentPoints, completionDate }) {
|
||||||
const chapterStates = []
|
const chapterStates = GENESIS_CHAPTERS.map((c) => {
|
||||||
let remaining = totalAccumulated
|
if (c.idx < startChapter) return { chapter: c, status: 'done', current: c.required }
|
||||||
for (const c of GENESIS_CHAPTERS) {
|
if (c.idx === startChapter) {
|
||||||
if (remaining >= c.required) {
|
const filled = Math.min(currentPoints, c.required)
|
||||||
chapterStates.push({ chapter: c, status: 'done', current: c.required })
|
return { chapter: c, status: filled > 0 ? 'active' : 'active', current: filled }
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
}
|
return { chapter: c, status: 'pending', current: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
const renderSegment = ({ chapter, status, current }) => {
|
const renderSegment = ({ chapter, status, current }) => {
|
||||||
const pct = (current / chapter.required) * 100
|
const pct = (current / chapter.required) * 100
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
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 }) {
|
export default function QuestSelector({ value, onChange }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
@ -25,25 +25,30 @@ export default function QuestSelector({ value, onChange }) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
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'
|
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-900">
|
||||||
src={`${QUEST_BTBOSS_IMAGE_BASE}/${selected.boss}.png`}
|
<img
|
||||||
alt={selected.boss}
|
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.png`}
|
||||||
className="h-8 block"
|
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
|
<svg
|
||||||
width="14" height="14" viewBox="0 0 12 12" fill="none"
|
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" />
|
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{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) => {
|
{GENESIS_CHAPTERS.map((chapter) => {
|
||||||
const isSelected = chapter.idx === value
|
const isSelected = chapter.idx === value
|
||||||
return (
|
return (
|
||||||
|
|
@ -51,15 +56,22 @@ export default function QuestSelector({ value, onChange }) {
|
||||||
key={chapter.idx}
|
key={chapter.idx}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { onChange(chapter.idx); setOpen(false) }}
|
onClick={() => { onChange(chapter.idx); setOpen(false) }}
|
||||||
className={`relative transition ${
|
className={`w-full flex items-center gap-3 px-2 py-1.5 transition ${
|
||||||
isSelected ? 'scale-105' : 'opacity-60 hover:opacity-100'
|
isSelected ? 'bg-emerald-500/10' : 'hover:bg-white/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-950">
|
||||||
src={`${QUEST_BTBOSS_IMAGE_BASE}/${chapter.boss}.png`}
|
<img
|
||||||
alt={chapter.boss}
|
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.png`}
|
||||||
className="h-10 block drop-shadow-lg"
|
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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -96,25 +96,29 @@ export function calcPoints(basePoints, partySize) {
|
||||||
// 목요일 기준 주차 계산 (KST)
|
// 목요일 기준 주차 계산 (KST)
|
||||||
// 이번 주 목요일 자정 = 이번 주의 시작
|
// 이번 주 목요일 자정 = 이번 주의 시작
|
||||||
export function getThursdayOfWeek(date) {
|
export function getThursdayOfWeek(date) {
|
||||||
const d = new Date(date)
|
const d = dayjs(date).tz(KST)
|
||||||
const day = d.getDay() // 0=일, 4=목
|
const day = d.day() // 0=일, 4=목
|
||||||
// 직전 목요일 찾기 (오늘이 목요일이면 오늘)
|
|
||||||
const diff = (day - 4 + 7) % 7
|
const diff = (day - 4 + 7) % 7
|
||||||
d.setDate(d.getDate() - diff)
|
return d.subtract(diff, 'day').startOf('day').toDate()
|
||||||
d.setHours(0, 0, 0, 0)
|
|
||||||
return d
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export function formatDate(date) {
|
||||||
const d = new Date(date)
|
return dayjs(date).tz(KST).format('YYYY-MM-DD')
|
||||||
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}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addWeeks(date, weeks) {
|
export function addWeeks(date, weeks) {
|
||||||
const d = new Date(date)
|
return dayjs(date).tz(KST).add(weeks, 'week').toDate()
|
||||||
d.setDate(d.getDate() + weeks * 7)
|
}
|
||||||
return d
|
|
||||||
|
export function todayKST() {
|
||||||
|
return dayjs().tz(KST).startOf('day').toDate()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue