diff --git a/frontend/src/features/liberation/pc/Liberation.jsx b/frontend/src/features/liberation/pc/Liberation.jsx
index f6032c4..d696af3 100644
--- a/frontend/src/features/liberation/pc/Liberation.jsx
+++ b/frontend/src/features/liberation/pc/Liberation.jsx
@@ -1,17 +1,22 @@
-import { useState, useEffect, useLayoutEffect, useMemo } from 'react'
+import { useState, useLayoutEffect, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { api } from '../../../api/client'
import {
GENESIS_CHAPTERS,
GENESIS_TOTAL,
- WEEKLY_BOSSES,
MONTHLY_BOSSES,
- calcPoints,
formatDate,
- todayKST,
} from '../data'
import { useLiberationStore } from '../store'
+import {
+ bossEarn,
+ calcWeekPoints,
+ calcDoneEarn,
+ calcMonthlyEarn,
+ getSchedulerWeekRange,
+ computeCompletionDate,
+} from '../utils'
import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput'
import ProgressBar from './components/ProgressBar'
@@ -20,46 +25,6 @@ import DatePicker from '../../../components/common/DatePicker'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
import { useLayout } from '../../../components/pc/Layout'
-function makeEmptyWeekly() {
- const bosses = {}
- WEEKLY_BOSSES.forEach((b) => {
- bosses[b.key] = { difficulty: 'none', party: 1, done: false }
- })
- return {
- bosses,
- blackMage: { difficulty: 'none', party: 1, done: false },
- }
-}
-
-function bossEarn(boss, sel) {
- if (!sel) return 0
- const d = boss.difficulties.find((x) => x.key === sel.difficulty)
- if (!d) return 0
- return calcPoints(d.points, sel.party)
-}
-
-function calcWeekPoints(weekData) {
- let points = 0
- WEEKLY_BOSSES.forEach((b) => {
- points += bossEarn(b, weekData.bosses[b.key])
- })
- return points
-}
-
-function calcDoneEarn(weekData) {
- let points = 0
- WEEKLY_BOSSES.forEach((b) => {
- const sel = weekData.bosses[b.key]
- if (sel?.done) points += bossEarn(b, sel)
- })
- return points
-}
-
-function calcMonthlyEarn(weekData) {
- return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage)
-}
-
-
export default function Liberation() {
const { setFullscreen } = useLayout()
useLayoutEffect(() => {
@@ -87,7 +52,7 @@ export default function Liberation() {
const resetSlot = useLiberationStore((s) => s.resetSlot)
const setState = (updater) => updateSlot(updater)
- // 포인트 이월 계산: 현재 퀘스트의 required를 초과하면 자동으로 다음 퀘스트로 넘어감
+ // 포인트 이월: 현재 퀘스트 required를 초과하면 자동으로 다음 퀘스트로 넘어감
const priorConsumed = GENESIS_CHAPTERS
.slice(0, state.startChapter)
.reduce((s, c) => s + c.required, 0)
@@ -103,12 +68,11 @@ export default function Liberation() {
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
const weeklyEarn = calcWeekPoints(state.weekly)
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
- // 주간/월간 분리
const doneEarn = calcDoneEarn(state.weekly)
const monthlyEarn = calcMonthlyEarn(state.weekly)
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
- // 주차별 모드에서는 모든 주차 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
+ // 주차별 모드 헤더 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
const headerWeekly = calcMode === 'weekly'
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
: weeklyEarn
@@ -120,15 +84,7 @@ export default function Liberation() {
sw.forEach((w, idx) => {
const diff = w.config.blackMage?.difficulty
if (!diff || diff === 'none') return
- const r = (() => {
- const start = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
- const dow = start.day()
- const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
- const nextThu = start.add(daysToNextThu, 'day')
- if (idx + 1 === 1) return { start, end: nextThu.subtract(1, 'day') }
- const ws = nextThu.add((idx + 1 - 2) * 7, 'day')
- return { start: ws, end: ws.add(6, 'day') }
- })()
+ const r = getSchedulerWeekRange(state.startDate, idx + 1)
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
for (const m of months) {
if (!(m in claimed)) {
@@ -140,132 +96,12 @@ export default function Liberation() {
return Object.values(claimed).reduce((s, v) => s + v, 0)
})()
- // 날짜 이벤트 시뮬레이션으로 해방일 계산
- function computeCompletionDate() {
- if (alreadyDone) return todayKST()
- if (remaining <= 0) return dayjs(state.startDate).tz('Asia/Seoul').startOf('day').toDate()
-
- const startKST = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
- const events = []
-
- if (calcMode === 'weekly') {
- // 주차별 모드: 각 주차의 설정에 따라 적립
- const sw = state.schedulerWeeks || []
- if (sw.length === 0) return null
- // 주간 보스: 시작일 당일 = 1주차 설정, 이후 매 목요일 = 2주차/3주차 설정
- const dow = startKST.day()
- const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
- // 1주차: 시작일 당일에 (주간 - done) 적립
- const week1Cfg = sw[0]?.config || makeEmptyWeekly()
- const w1Weekly = calcWeekPoints(week1Cfg)
- const w1Done = calcDoneEarn(week1Cfg)
- events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) })
- // 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립
- // 마지막 주차 이후로는 마지막 주차 설정을 반복 적용
- let nextThu = startKST.add(daysToNextThu, 'day')
- for (let i = 1; i < 520; i++) {
- const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly()
- events.push({ date: nextThu, amount: calcWeekPoints(cfg) })
- nextThu = nextThu.add(1, 'week')
- }
-
- // 검은 마법사: 슬롯 배정에 따라 해당 주차의 첫날(or 1주차이면 시작일)에 적립
- const claimed = {} // monthKey -> { weekIdx, earn, doneAlready }
- sw.forEach((w, i) => {
- const diff = w.config.blackMage?.difficulty
- if (!diff || diff === 'none') return
- const range = getSchedulerWeekRange(state.startDate, i + 1)
- const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')]
- for (const m of months) {
- if (!(m in claimed)) {
- claimed[m] = {
- weekIdx: i,
- earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage),
- done: !!w.config.blackMage.done,
- }
- return
- }
- }
- })
- Object.entries(claimed).forEach(([, info]) => {
- if (info.done) return
- const wIdx = info.weekIdx
- // 1주차면 시작일, 그 외엔 해당 주차의 시작 목요일
- const date = wIdx === 0
- ? startKST
- : startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
- events.push({ date, amount: info.earn })
- })
-
- // 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용
- const lastCfg = sw[sw.length - 1]?.config
- const lastBmEarn = lastCfg ? bossEarn(MONTHLY_BOSSES[0], lastCfg.blackMage) : 0
- if (lastBmEarn > 0) {
- const lastWeekStart = sw.length === 1
- ? startKST
- : startKST.add(daysToNextThu + (sw.length - 2) * 7, 'day')
- const claimedMonths = new Set(Object.keys(claimed))
- let cursor = lastWeekStart.add(1, 'month').startOf('month')
- for (let i = 0; i < 120; i++) {
- const m = cursor.format('YYYY-MM')
- if (!claimedMonths.has(m)) {
- events.push({ date: cursor, amount: lastBmEarn })
- }
- cursor = cursor.add(1, 'month')
- }
- }
- } else {
- // 단순 계산 모드: 매주 동일 설정
- if (weeklyEarn === 0 && monthlyEarn === 0) return null
-
- // 시작일 당일: (주간 - 완료된 주간) + (이번 달 월간, 아직 안 잡았을 때)
- const day0Weekly = Math.max(weeklyEarn - doneEarn, 0)
- const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0
- events.push({ date: startKST, amount: day0Weekly + day0Monthly })
-
- // 다음 목요일부터 매주 주간 적립
- const dow = startKST.day()
- const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
- let nextThu = startKST.add(daysToNextThu, 'day')
- for (let i = 0; i < 520; i++) {
- events.push({ date: nextThu, amount: weeklyEarn })
- nextThu = nextThu.add(1, 'week')
- }
-
- // 다음 달 1일부터 매월 월간 적립
- if (monthlyEarn > 0) {
- let nextMonth = startKST.add(1, 'month').startOf('month')
- for (let i = 0; i < 120; i++) {
- events.push({ date: nextMonth, amount: monthlyEarn })
- nextMonth = nextMonth.add(1, 'month')
- }
- }
- }
-
- events.sort((a, b) => a.date.diff(b.date))
- let cumulative = 0
- for (const e of events) {
- cumulative += e.amount
- if (cumulative >= remaining) return e.date.toDate()
- }
- return null
- }
-
- function getSchedulerWeekRange(startDateStr, weekIdx) {
- const start = dayjs(startDateStr).tz('Asia/Seoul').startOf('day')
- const dow = start.day()
- const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
- const nextThu = start.add(daysToNextThu, 'day')
- if (weekIdx === 1) return { start, end: nextThu.subtract(1, 'day') }
- const ws = nextThu.add((weekIdx - 2) * 7, 'day')
- return { start: ws, end: ws.add(6, 'day') }
- }
-
const completionDate = useMemo(
- () => computeCompletionDate(),
- // 의도적으로 state 전체 + calcMode 만 의존. 내부 함수는 클로저 안의 값만 읽음
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [state, calcMode, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
+ () => computeCompletionDate({
+ calcMode, state, alreadyDone, remaining,
+ weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
+ }),
+ [calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
)
const isDone = completionDate !== null
@@ -322,131 +158,131 @@ export default function Liberation() {
데스티니 해방 계산기는 준비 중입니다.
) : (<>
- {/* 계산 모드 탭 */}
-
- {[
- { key: 'simple', label: '단순 계산' },
- { key: 'weekly', label: '주차별 계산' },
- ].map((t) => {
- const active = calcMode === t.key
- return (
-
- )
- })}
-
-
-
-
- {/* 현재 진행 상태 입력 */}
-
-
현재 진행 상태
-
-
-
-
- setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
- />
-
-
-
-
- setState((prev) => ({ ...prev, startChapter: idx }))}
- />
-
-
-
-
-
-
setState((prev) => ({ ...prev, currentPoints: n }))}
- className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
- style={{ color: 'var(--text-strong)' }}
- />
-
+ {[
+ { key: 'simple', label: '단순 계산' },
+ { key: 'weekly', label: '주차별 계산' },
+ ].map((t) => {
+ const active = calcMode === t.key
+ return (
+
+ {t.label}
+
+ )
+ })}
+
+
+
+
+ {/* 현재 진행 상태 입력 */}
+
+
현재 진행 상태
+
+
+
+
+ setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
+ />
+
+
+
+
+ setState((prev) => ({ ...prev, startChapter: idx }))}
+ />
+
+
+
+
+
+
setState((prev) => ({ ...prev, currentPoints: n }))}
+ className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
+ style={{ color: 'var(--text-strong)' }}
+ />
+
+ / {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
+
+
-
-
setState((prev) => ({ ...prev, weekly: w }))}
- totalWeekly={headerWeekly}
- totalMonthly={headerMonthly}
- remaining={remaining}
- mode={calcMode}
- startDate={state.startDate}
- weeks={state.schedulerWeeks}
- onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
- />
+ setState((prev) => ({ ...prev, weekly: w }))}
+ totalWeekly={headerWeekly}
+ totalMonthly={headerMonthly}
+ remaining={remaining}
+ mode={calcMode}
+ startDate={state.startDate}
+ weeks={state.schedulerWeeks}
+ onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
+ />
-
-
-
+
+
+
>)}
x.key === sel.difficulty)
- if (!d) return 0
- return calcPoints(d.points, sel.party)
-}
-
-function calcWeeklySum(config) {
- let sum = 0
- WEEKLY_BOSSES.forEach((b) => { sum += bossEarn(b, config.bosses[b.key]) })
- return sum
-}
-
-const KST = 'Asia/Seoul'
-
-// 주차별 날짜 범위 계산 (목요일 리셋, 1주차는 시작일부터 다음 목요일 전까지)
-function getWeekRange(startDateStr, weekIdx) {
- const start = dayjs(startDateStr).tz(KST).startOf('day')
- const dow = start.day()
- const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
- const nextThu = start.add(daysToNextThu, 'day')
- if (weekIdx === 1) {
- return { start, end: nextThu.subtract(1, 'day') }
- }
- const weekStart = nextThu.add((weekIdx - 2) * 7, 'day')
- const weekEnd = weekStart.add(6, 'day')
- return { start: weekStart, end: weekEnd }
-}
-
function formatRange(r) {
const fmt = (d) => `${d.month() + 1}/${d.date()}`
return `${fmt(r.start)} ~ ${fmt(r.end)}`
@@ -46,17 +18,6 @@ const DIFF_BADGE = {
extreme: { label: 'X', color: '#f59e0b', border: 'rgba(245,158,11,0.5)', bg: 'rgba(245,158,11,0.2)' },
}
-function makeEmptyWeek() {
- const bosses = {}
- WEEKLY_BOSSES.forEach((b) => {
- bosses[b.key] = { difficulty: 'none', party: 1, done: false }
- })
- return {
- bosses,
- blackMage: { difficulty: 'none', party: 1, done: false },
- }
-}
-
function BossAvatar({ boss, difficulty, size = 40 }) {
const badge = DIFF_BADGE[difficulty]
const enabled = difficulty && difficulty !== 'none'
@@ -141,7 +102,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
const weeks = weeksProp && weeksProp.length > 0
? weeksProp
- : [{ id: 1, config: makeEmptyWeek() }]
+ : [{ id: 1, config: makeEmptyWeekly() }]
const setWeeks = (updater) => {
const next = typeof updater === 'function' ? updater(weeks) : updater
onChangeWeeks?.(next)
@@ -153,7 +114,7 @@ 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)) : makeEmptyWeek()
+ const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeekly()
// done 상태는 복사하지 않음
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
if (base.blackMage) base.blackMage.done = false
diff --git a/frontend/src/features/liberation/utils.js b/frontend/src/features/liberation/utils.js
new file mode 100644
index 0000000..b34eaca
--- /dev/null
+++ b/frontend/src/features/liberation/utils.js
@@ -0,0 +1,174 @@
+import dayjs from 'dayjs'
+import { WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints, todayKST } from './data'
+import { makeEmptyWeekly } from './store'
+
+const KST = 'Asia/Seoul'
+
+export function bossEarn(boss, sel) {
+ if (!sel) return 0
+ const d = boss.difficulties.find((x) => x.key === sel.difficulty)
+ if (!d) return 0
+ return calcPoints(d.points, sel.party)
+}
+
+export function calcWeekPoints(weekData) {
+ let points = 0
+ WEEKLY_BOSSES.forEach((b) => {
+ points += bossEarn(b, weekData.bosses[b.key])
+ })
+ return points
+}
+
+export function calcDoneEarn(weekData) {
+ let points = 0
+ WEEKLY_BOSSES.forEach((b) => {
+ const sel = weekData.bosses[b.key]
+ if (sel?.done) points += bossEarn(b, sel)
+ })
+ return points
+}
+
+export function calcMonthlyEarn(weekData) {
+ return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage)
+}
+
+/**
+ * 주차 번호(1-based)로 해당 주차의 날짜 범위 반환
+ * 1주차: 시작일 ~ 다음 목요일 전날
+ * 2주차+: 이전 주차 목요일부터 6일간
+ */
+export function getSchedulerWeekRange(startDateStr, weekIdx) {
+ const start = dayjs(startDateStr).tz(KST).startOf('day')
+ const dow = start.day()
+ const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
+ const nextThu = start.add(daysToNextThu, 'day')
+ if (weekIdx === 1) return { start, end: nextThu.subtract(1, 'day') }
+ const ws = nextThu.add((weekIdx - 2) * 7, 'day')
+ return { start: ws, end: ws.add(6, 'day') }
+}
+
+/**
+ * 해방일 계산: 시작일부터 포인트 이벤트를 시뮬레이션하여 remaining 도달 시점 반환
+ *
+ * @param {object} params
+ * @param {'simple'|'weekly'} params.calcMode
+ * @param {object} params.state - 현재 슬롯(startDate, schedulerWeeks, weekly 등)
+ * @param {boolean} params.alreadyDone
+ * @param {number} params.remaining
+ * @param {number} params.weeklyEarn
+ * @param {number} params.doneEarn
+ * @param {number} params.monthlyEarn
+ * @param {boolean} params.monthlyDoneThisMonth
+ * @returns {Date|null}
+ */
+export function computeCompletionDate({
+ calcMode, state, alreadyDone, remaining,
+ weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
+}) {
+ if (alreadyDone) return todayKST()
+ if (remaining <= 0) return dayjs(state.startDate).tz(KST).startOf('day').toDate()
+
+ const startKST = dayjs(state.startDate).tz(KST).startOf('day')
+ const events = []
+
+ if (calcMode === 'weekly') {
+ const sw = state.schedulerWeeks || []
+ if (sw.length === 0) return null
+ const dow = startKST.day()
+ const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
+
+ // 1주차: 시작일 당일에 (주간 - done) 적립
+ const week1Cfg = sw[0]?.config || makeEmptyWeekly()
+ const w1Weekly = calcWeekPoints(week1Cfg)
+ const w1Done = calcDoneEarn(week1Cfg)
+ events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) })
+
+ // 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립
+ // 마지막 주차 이후로는 마지막 주차 설정 반복 적용
+ let nextThu = startKST.add(daysToNextThu, 'day')
+ for (let i = 1; i < 520; i++) {
+ const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly()
+ events.push({ date: nextThu, amount: calcWeekPoints(cfg) })
+ nextThu = nextThu.add(1, 'week')
+ }
+
+ // 검은 마법사: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
+ const claimed = {}
+ sw.forEach((w, i) => {
+ const diff = w.config.blackMage?.difficulty
+ if (!diff || diff === 'none') return
+ const range = getSchedulerWeekRange(state.startDate, i + 1)
+ const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')]
+ for (const m of months) {
+ if (!(m in claimed)) {
+ claimed[m] = {
+ weekIdx: i,
+ earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage),
+ done: !!w.config.blackMage.done,
+ }
+ return
+ }
+ }
+ })
+ Object.entries(claimed).forEach(([, info]) => {
+ if (info.done) return
+ const wIdx = info.weekIdx
+ const date = wIdx === 0
+ ? startKST
+ : startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
+ events.push({ date, amount: info.earn })
+ })
+
+ // 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용
+ const lastCfg = sw[sw.length - 1]?.config
+ const lastBmEarn = lastCfg ? bossEarn(MONTHLY_BOSSES[0], lastCfg.blackMage) : 0
+ if (lastBmEarn > 0) {
+ const lastWeekStart = sw.length === 1
+ ? startKST
+ : startKST.add(daysToNextThu + (sw.length - 2) * 7, 'day')
+ const claimedMonths = new Set(Object.keys(claimed))
+ let cursor = lastWeekStart.add(1, 'month').startOf('month')
+ for (let i = 0; i < 120; i++) {
+ const m = cursor.format('YYYY-MM')
+ if (!claimedMonths.has(m)) {
+ events.push({ date: cursor, amount: lastBmEarn })
+ }
+ cursor = cursor.add(1, 'month')
+ }
+ }
+ } else {
+ // 단순 계산 모드: 매주 동일 설정
+ if (weeklyEarn === 0 && monthlyEarn === 0) return null
+
+ // 시작일 당일: (주간 - 완료된 주간) + (이번 달 월간, 아직 안 잡았을 때)
+ const day0Weekly = Math.max(weeklyEarn - doneEarn, 0)
+ const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0
+ events.push({ date: startKST, amount: day0Weekly + day0Monthly })
+
+ // 다음 목요일부터 매주 주간 적립
+ const dow = startKST.day()
+ const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
+ let nextThu = startKST.add(daysToNextThu, 'day')
+ for (let i = 0; i < 520; i++) {
+ events.push({ date: nextThu, amount: weeklyEarn })
+ nextThu = nextThu.add(1, 'week')
+ }
+
+ // 다음 달 1일부터 매월 월간 적립
+ if (monthlyEarn > 0) {
+ let nextMonth = startKST.add(1, 'month').startOf('month')
+ for (let i = 0; i < 120; i++) {
+ events.push({ date: nextMonth, amount: monthlyEarn })
+ nextMonth = nextMonth.add(1, 'month')
+ }
+ }
+ }
+
+ events.sort((a, b) => a.date.diff(b.date))
+ let cumulative = 0
+ for (const e of events) {
+ cumulative += e.amount
+ if (cumulative >= remaining) return e.date.toDate()
+ }
+ return null
+}