3
This commit is contained in:
@@ -20,6 +20,8 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const startParam = searchParams.get('start')
|
const startParam = searchParams.get('start')
|
||||||
const endParam = searchParams.get('end')
|
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 start = parseIsoDate(startParam)
|
||||||
const end = parseIsoDate(endParam)
|
const end = parseIsoDate(endParam)
|
||||||
@@ -68,6 +70,94 @@ export async function GET(request: Request) {
|
|||||||
where.content = { handleId: { in: handleIds } }
|
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<string, { views: number, validViews: number, premiumViews: number, watchTime: number, expectedRevenue: number }>()
|
||||||
|
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({
|
const grouped = await prisma.contentDayView.groupBy({
|
||||||
by: ['contentId'],
|
by: ['contentId'],
|
||||||
where,
|
where,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import DayRange from "./svgs/dayRange";
|
|
||||||
import OneMonth from "./svgs/oneMonth";
|
import OneMonth from "./svgs/oneMonth";
|
||||||
import Realtime from "./svgs/realtime";
|
import Realtime from "./svgs/realtime";
|
||||||
import Arrow from "./svgs/arrow";
|
import Arrow from "./svgs/arrow";
|
||||||
@@ -32,12 +31,13 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{selected === DateRangeEnum.DAY_RANGE && <DayRange color="#848484" width={is_small ? 16 : 20} height={is_small ? 16 : 20} />}
|
{selected === DateRangeEnum.ALL ? (
|
||||||
{selected === DateRangeEnum.ONE_MONTH&& <OneMonth color="#848484" width={is_small ? 16 : 20} height={is_small ? 16 : 20} />}
|
<Realtime color="#848484" width={is_small ? 16 : 20} height={is_small ? 16 : 20} />
|
||||||
{selected === DateRangeEnum.ALL&& <Realtime color="#848484" width={is_small ? 16 : 20} height={is_small ? 16 : 20} />}
|
) : (
|
||||||
|
<OneMonth color="#848484" width={is_small ? 16 : 20} height={is_small ? 16 : 20} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex-1 min-w-0 font-semibold ${is_small ? 'text-xs':'text-sm'}`}>
|
<div className={`flex-1 min-w-0 font-semibold ${is_small ? 'text-xs':'text-sm'}`}>
|
||||||
{selected === DateRangeEnum.DAY_RANGE && `${rangeStart.toISOString().split('T')[0]} ~ ${rangeEnd.toISOString().split('T')[0]}`}
|
|
||||||
{selected === DateRangeEnum.ONE_MONTH&& '최근 1개월'}
|
{selected === DateRangeEnum.ONE_MONTH&& '최근 1개월'}
|
||||||
{selected === DateRangeEnum.ONE_WEEK&& '최근 1주일'}
|
{selected === DateRangeEnum.ONE_WEEK&& '최근 1주일'}
|
||||||
{selected === DateRangeEnum.TWO_MONTHS&& '최근 2개월'}
|
{selected === DateRangeEnum.TWO_MONTHS&& '최근 2개월'}
|
||||||
@@ -165,38 +165,7 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
|
|||||||
<OneMonth color={selected === DateRangeEnum.ONE_YEAR ? '#F94B37' : '#848484'} width={16} height={16} />
|
<OneMonth color={selected === DateRangeEnum.ONE_YEAR ? '#F94B37' : '#848484'} width={16} height={16} />
|
||||||
<span className="text-sm">최근 1년</span>
|
<span className="text-sm">최근 1년</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
|
||||||
className={`flex items-center gap-1 px-2 pr-0 h-[32px] rounded-md hover:bg-[#f6f6f6] text-left ${selected === DateRangeEnum.DAY_RANGE ? 'bg-[#FFF3F2] font-semibold' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
// setSelected(DateRangeEnum.DAY_RANGE);
|
|
||||||
// setIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DayRange color={selected === DateRangeEnum.DAY_RANGE ? '#F94B37' : '#848484'} width={16} height={16} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={rangeStart.toISOString().split('T')[0]}
|
|
||||||
onChange={(e) => setRangeStart(new Date(e.target.value))}
|
|
||||||
className="border-1 border-border-pale rounded-md p-0.5 text-xs w-[84px] text-center"
|
|
||||||
/>
|
|
||||||
<span className="mx-0">~</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={rangeEnd.toISOString().split('T')[0]}
|
|
||||||
onChange={(e) => setRangeEnd(new Date(e.target.value))}
|
|
||||||
className="border-1 border-border-pale rounded-md p-0.5 text-xs w-[84px] text-center"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button className="text-xs text-[#848484] border-1 border-border-pale rounded-md p-0.5 px-1 hover:text-[#F94B37] hover:border-[#F94B37]"
|
|
||||||
onClick={() => {
|
|
||||||
setSelected(DateRangeEnum.DAY_RANGE);
|
|
||||||
onRangeChange?.(rangeStart, rangeEnd);
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
선택
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -341,21 +341,20 @@ export default function Page({user}: {user: any}) {
|
|||||||
expectedRevenue: { border: '#16A34A', bg: 'rgba(22, 163, 74, 0.25)' },
|
expectedRevenue: { border: '#16A34A', bg: 'rgba(22, 163, 74, 0.25)' },
|
||||||
}
|
}
|
||||||
const selectedRows = useMemo(() => sortedVisibleRows.filter(r => checkedIds.has(r.id)), [sortedVisibleRows, checkedIds])
|
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<Array<{ period: string, views: number, validViews: number, premiumViews: number, expectedRevenue: number }>>([])
|
||||||
const chartData = useMemo(() => {
|
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 labels = selectedRows.map(r => r.subject)
|
||||||
const dataVals = selectedRows.map(r => (r as any)[selectedMetric] as number)
|
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 }] }
|
||||||
return {
|
}, [selectedRows, selectedMetric, seriesMode, seriesItems, seriesInterval])
|
||||||
labels,
|
|
||||||
datasets: [{
|
|
||||||
label: metricLabel[selectedMetric],
|
|
||||||
data: dataVals,
|
|
||||||
borderColor: col.border,
|
|
||||||
backgroundColor: col.bg,
|
|
||||||
tension: 0.35,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}, [selectedRows, selectedMetric])
|
|
||||||
const chartOptions = useMemo(() => ({
|
const chartOptions = useMemo(() => ({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: { legend: { display: false }, title: { display: false } },
|
plugins: { legend: { display: false }, title: { display: false } },
|
||||||
@@ -616,7 +615,28 @@ export default function Page({user}: {user: any}) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="border-1 hidden xl:col-[3/5] xl:row-[3/5] bg-white rounded-lg p-4 xl:flex xl:flex-col" style={{ borderColor: metricColor[selectedMetric].border }}>
|
<div className="border-1 hidden xl:col-[3/5] xl:row-[3/5] bg-white rounded-lg p-4 xl:flex xl:flex-col" style={{ borderColor: metricColor[selectedMetric].border }}>
|
||||||
<div className="text-xl font-bold"> {metricLabel[selectedMetric]} 추이 </div>
|
<div className="text-xl font-bold"> {metricLabel[selectedMetric]} 추이 </div>
|
||||||
<div className="text-normal text-gray-500"> 상위 테이블에서 설정한 데이터를 토대로 그래프가 표출됩니다.</div>
|
<div className="text-normal text-gray-500 flex flex-row gap-2 items-center">
|
||||||
|
<select className="border-1 border-[#e6e9ef] rounded px-2 py-1" value={seriesMode} onChange={e => setSeriesMode(e.target.value as any)}>
|
||||||
|
<option value="items">항목별</option>
|
||||||
|
<option value="series">기간별</option>
|
||||||
|
</select>
|
||||||
|
{seriesMode === 'series' && (
|
||||||
|
<>
|
||||||
|
<select className="border-1 border-[#e6e9ef] rounded px-2 py-1" value={seriesInterval} onChange={e => setSeriesInterval(e.target.value as any)}>
|
||||||
|
<option value="day">일</option>
|
||||||
|
<option value="week">주</option>
|
||||||
|
<option value="month">월</option>
|
||||||
|
</select>
|
||||||
|
<button className="border-1 border-[#e6e9ef] rounded px-2 py-1" onClick={async () => {
|
||||||
|
const s = startDate; const e = endDate;
|
||||||
|
const qs = new URLSearchParams({ start: s.toISOString(), end: e.toISOString(), mode: 'series', interval: seriesInterval })
|
||||||
|
const res = await fetch(`/api/contents/mycontent?${qs.toString()}`, { cache: 'no-store' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) setSeriesItems(data.items || [])
|
||||||
|
}}>불러오기</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex flex-row justify-start items-center">
|
<div className="flex flex-row justify-start items-center">
|
||||||
<div className="font-bold text-3xl my-4"> {formatNumberWithCommas(
|
<div className="font-bold text-3xl my-4"> {formatNumberWithCommas(
|
||||||
selectedMetric === 'views' ? totals.views :
|
selectedMetric === 'views' ? totals.views :
|
||||||
|
|||||||
Reference in New Issue
Block a user