From 2171d5e744ae3e36ffa860b24db6ee791df5e0d6 Mon Sep 17 00:00:00 2001 From: motaju Date: Tue, 9 Sep 2025 07:31:48 +0000 Subject: [PATCH] 3 --- app/api/contents/mycontent/route.ts | 90 +++++++++++++++++++++++++++++ app/components/CalenderSelector.tsx | 43 ++------------ app/components/Dashboard.tsx | 46 ++++++++++----- 3 files changed, 129 insertions(+), 50 deletions(-) diff --git a/app/api/contents/mycontent/route.ts b/app/api/contents/mycontent/route.ts index 8a4b656..fd11d5c 100644 --- a/app/api/contents/mycontent/route.ts +++ b/app/api/contents/mycontent/route.ts @@ -20,6 +20,8 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const startParam = searchParams.get('start') const endParam = searchParams.get('end') + const mode = (searchParams.get('mode') || 'items').toLowerCase() + const interval = (searchParams.get('interval') || 'day').toLowerCase() as 'day' | 'week' | 'month' const start = parseIsoDate(startParam) const end = parseIsoDate(endParam) @@ -68,6 +70,94 @@ export async function GET(request: Request) { where.content = { handleId: { in: handleIds } } } + // 시계열(기간별) 모드: day/week/month로 버킷팅해 합계 반환 + if (mode === 'series') { + const contentIdsParam = searchParams.get('contentIds') + let contentIdsFilter: string[] | null = null + if (contentIdsParam) contentIdsFilter = contentIdsParam.split(',').map(s => s.trim()).filter(Boolean) + + const seriesRows = await prisma.contentDayView.findMany({ + where: { + ...where, + ...(contentIdsFilter && contentIdsFilter.length > 0 ? { contentId: { in: contentIdsFilter } } : {}), + }, + select: { + date: true, + views: true, + validViews: true, + premiumViews: true, + watchTime: true, + content: { select: { handle: { select: { costPerView: true } } } }, + }, + orderBy: { date: 'asc' }, + }) + + // 버킷 키 생성기 + const makeKey = (d: Date): string => { + const y = d.getUTCFullYear() + const m = String(d.getUTCMonth() + 1).padStart(2, '0') + const dd = String(d.getUTCDate()).padStart(2, '0') + if (interval === 'day') return `${y}-${m}-${dd}` + if (interval === 'month') return `${y}-${m}` + // week: ISO 주 시작(월요일)로 버킷팅 + const date = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate())) + const day = date.getUTCDay() || 7 // 1..7, 월=1, 일=7 + const monday = new Date(date) + monday.setUTCDate(date.getUTCDate() - (day - 1)) + const my = String(monday.getUTCMonth() + 1).padStart(2, '0') + const md = String(monday.getUTCDate()).padStart(2, '0') + return `${monday.getUTCFullYear()}-${my}-${md}` + } + + const bucket = new Map() + for (const r of seriesRows) { + const key = makeKey(new Date(r.date)) + const cpv = Number(r.content?.handle?.costPerView ?? 0) || 0 + const cur = bucket.get(key) || { views: 0, validViews: 0, premiumViews: 0, watchTime: 0, expectedRevenue: 0 } + cur.views += r.views || 0 + cur.validViews += r.validViews || 0 + cur.premiumViews += r.premiumViews || 0 + cur.watchTime += r.watchTime || 0 + cur.expectedRevenue += Math.max(0, Math.round((r.validViews || 0) * cpv)) + bucket.set(key, cur) + } + // 빈 버킷 채우기 (연속 타임라인) + const addZeroIfMissing = (d: Date) => { + const k = makeKey(d) + if (!bucket.has(k)) bucket.set(k, { views: 0, validViews: 0, premiumViews: 0, watchTime: 0, expectedRevenue: 0 }) + } + if (interval === 'day') { + let d = new Date(Date.UTC(startDay.getUTCFullYear(), startDay.getUTCMonth(), startDay.getUTCDate())) + const end = new Date(Date.UTC(endDay.getUTCFullYear(), endDay.getUTCMonth(), endDay.getUTCDate())) + while (d.getTime() <= end.getTime()) { + addZeroIfMissing(d) + d = new Date(d.getTime() + 24*60*60*1000) + } + } else if (interval === 'week') { + const mkMonday = (d0: Date) => { + const t = new Date(Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth(), d0.getUTCDate())) + const wd = t.getUTCDay() || 7 + t.setUTCDate(t.getUTCDate() - (wd - 1)) + return t + } + let d = mkMonday(startDay) + const end = new Date(Date.UTC(endDay.getUTCFullYear(), endDay.getUTCMonth(), endDay.getUTCDate())) + while (d.getTime() <= end.getTime()) { + addZeroIfMissing(d) + d = new Date(d.getTime() + 7*24*60*60*1000) + } + } else if (interval === 'month') { + let d = new Date(Date.UTC(startDay.getUTCFullYear(), startDay.getUTCMonth(), 1)) + const end = new Date(Date.UTC(endDay.getUTCFullYear(), endDay.getUTCMonth(), 1)) + while (d.getTime() <= end.getTime()) { + addZeroIfMissing(d) + d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)) + } + } + const items = Array.from(bucket.entries()).sort((a,b) => a[0] < b[0] ? -1 : 1).map(([period, agg]) => ({ period, ...agg })) + return NextResponse.json({ success: true, mode: 'series', interval, start: startDay.toISOString(), end: endDay.toISOString(), count: items.length, items }) + } + const grouped = await prisma.contentDayView.groupBy({ by: ['contentId'], where, diff --git a/app/components/CalenderSelector.tsx b/app/components/CalenderSelector.tsx index 7a119ca..b964e6a 100644 --- a/app/components/CalenderSelector.tsx +++ b/app/components/CalenderSelector.tsx @@ -1,4 +1,3 @@ -import DayRange from "./svgs/dayRange"; import OneMonth from "./svgs/oneMonth"; import Realtime from "./svgs/realtime"; import Arrow from "./svgs/arrow"; @@ -32,12 +31,13 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}: }} >
- {selected === DateRangeEnum.DAY_RANGE && } - {selected === DateRangeEnum.ONE_MONTH&& } - {selected === DateRangeEnum.ALL&& } + {selected === DateRangeEnum.ALL ? ( + + ) : ( + + )}
- {selected === DateRangeEnum.DAY_RANGE && `${rangeStart.toISOString().split('T')[0]} ~ ${rangeEnd.toISOString().split('T')[0]}`} {selected === DateRangeEnum.ONE_MONTH&& '최근 1개월'} {selected === DateRangeEnum.ONE_WEEK&& '최근 1주일'} {selected === DateRangeEnum.TWO_MONTHS&& '최근 2개월'} @@ -165,38 +165,7 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}: 최근 1년 -
{ - // setSelected(DateRangeEnum.DAY_RANGE); - // setIsOpen(false); - }} - > - - setRangeStart(new Date(e.target.value))} - className="border-1 border-border-pale rounded-md p-0.5 text-xs w-[84px] text-center" - /> - ~ - setRangeEnd(new Date(e.target.value))} - className="border-1 border-border-pale rounded-md p-0.5 text-xs w-[84px] text-center" - /> - - -
+
)} diff --git a/app/components/Dashboard.tsx b/app/components/Dashboard.tsx index 1fe99f5..5e59bdf 100644 --- a/app/components/Dashboard.tsx +++ b/app/components/Dashboard.tsx @@ -341,21 +341,20 @@ export default function Page({user}: {user: any}) { expectedRevenue: { border: '#16A34A', bg: 'rgba(22, 163, 74, 0.25)' }, } const selectedRows = useMemo(() => sortedVisibleRows.filter(r => checkedIds.has(r.id)), [sortedVisibleRows, checkedIds]) + const [seriesMode, setSeriesMode] = useState<'items'|'series'>('items') + const [seriesInterval, setSeriesInterval] = useState<'day'|'week'|'month'>('day') + const [seriesItems, setSeriesItems] = useState>([]) const chartData = useMemo(() => { + const col = metricColor[selectedMetric] + if (seriesMode === 'series') { + const labels = seriesItems.map(it => it.period) + const dataVals = seriesItems.map(it => (it as any)[selectedMetric] as number) + return { labels, datasets: [{ label: `${metricLabel[selectedMetric]} (${seriesInterval})`, data: dataVals, borderColor: col.border, backgroundColor: col.bg, tension: 0.35 }] } + } const labels = selectedRows.map(r => r.subject) const dataVals = selectedRows.map(r => (r as any)[selectedMetric] as number) - const col = metricColor[selectedMetric] - return { - labels, - datasets: [{ - label: metricLabel[selectedMetric], - data: dataVals, - borderColor: col.border, - backgroundColor: col.bg, - tension: 0.35, - }] - } - }, [selectedRows, selectedMetric]) + return { labels, datasets: [{ label: metricLabel[selectedMetric], data: dataVals, borderColor: col.border, backgroundColor: col.bg, tension: 0.35 }] } + }, [selectedRows, selectedMetric, seriesMode, seriesItems, seriesInterval]) const chartOptions = useMemo(() => ({ responsive: true, plugins: { legend: { display: false }, title: { display: false } }, @@ -616,7 +615,28 @@ export default function Page({user}: {user: any}) {
{metricLabel[selectedMetric]} 추이
-
상위 테이블에서 설정한 데이터를 토대로 그래프가 표출됩니다.
+
+ + {seriesMode === 'series' && ( + <> + + + + )} +
{formatNumberWithCommas( selectedMetric === 'views' ? totals.views :