Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions src/components/timeline/TimelinePerformancePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FC, SVGProps } from "react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { twMerge } from "tailwind-merge";

import type { TProviderType } from "@/types/dashboard/provider";
Expand All @@ -8,6 +8,7 @@ import type { TTimelineViewUnit } from "@/types/timeline/ui";
import { resolveTimelinePerformanceStatusStyle } from "@/constants/timeline/statusStyle";

import { getTimelineMetricLabel } from "@/utils/timeline/buildTimelineChartSeries";
import { sliceDailyTrendByPeriod } from "@/utils/timeline/sliceDailyTrendByPeriod";

import Badge from "@/components/common/badge/Badge";
import Button from "@/components/common/button/Button";
Expand All @@ -28,8 +29,6 @@ import NaverWordmark from "@/assets/logo/social-logo/wordmark/naver-wordmark.svg

type TAiSummaryUiState = "idle" | "loading" | "done";

const CHART_PERIOD_LABELS = ["오늘", "1월 21일 → 25일", "1월 14일 → 20일"];

const SECTION_SHELL_CLASS =
"rounded-3xl border border-surface-300/70 bg-surface-100";

Expand Down Expand Up @@ -141,22 +140,38 @@ export default function TimelinePerformancePanel({
const statusStyle = resolveTimelinePerformanceStatusStyle(
data.performanceStatus,
);
const chartPeriodLabel =
CHART_PERIOD_LABELS[chartPeriodIndex] ?? CHART_PERIOD_LABELS[0];

const chartMetric = data.metrics[0]?.metric ?? "CLICK";
const chartMetricLabel = getTimelineMetricLabel(chartMetric);

const {
periodLabel: chartPeriodLabel,
slicedTrend,
rangeStart: chartRangeStart,
rangeEnd: chartRangeEnd,
} = useMemo(
() =>
sliceDailyTrendByPeriod({
dailyTrend: data.dailyTrend,
viewUnit,
periodIndex: chartPeriodIndex,
timelineStartDate: data.startDate,
timelineEndDate: data.endDate,
}),
[data.dailyTrend, data.startDate, data.endDate, viewUnit, chartPeriodIndex],
);

const handleViewUnitChange = (unit: TTimelineViewUnit) => {
setViewUnit(unit);
setChartPeriodIndex(0); //단위 바꾸면 현재로 리셋되도록
};

const handlePrevChartPeriod = () => {
setChartPeriodIndex((prev) =>
prev === 0 ? CHART_PERIOD_LABELS.length - 1 : prev - 1,
);
setChartPeriodIndex((prev) => prev + 1);
};

const handleNextChartPeriod = () => {
setChartPeriodIndex((prev) =>
prev === CHART_PERIOD_LABELS.length - 1 ? 0 : prev + 1,
);
setChartPeriodIndex((prev) => Math.max(0, prev - 1));
};

const menuItems = [
Expand Down Expand Up @@ -333,14 +348,16 @@ export default function TimelinePerformancePanel({
<TimelinePeriodSelector
viewUnit={viewUnit}
periodLabel={chartPeriodLabel}
onViewUnitChange={setViewUnit}
onViewUnitChange={handleViewUnitChange}
onPrevPeriod={handlePrevChartPeriod}
onNextPeriod={handleNextChartPeriod}
/>
</div>
<TimelineDailyTrendChart
dailyTrend={data.dailyTrend}
dailyTrend={slicedTrend}
metric={chartMetric}
rangeStart={chartRangeStart}
rangeEnd={chartRangeEnd}
/>
</div>
</section>
Expand Down
32 changes: 26 additions & 6 deletions src/components/timeline/charts/TimelineDailyTrendChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
} from "@/types/timeline/api";

import { buildTimelineChartSeries } from "@/utils/timeline/buildTimelineChartSeries";
import { fillDailyTrendRange } from "@/utils/timeline/fillDailyTrendRange";

import { Skeleton } from "@/components/common/skeleton/Skeleton";

Expand All @@ -16,29 +17,48 @@ const ReactApexChart = lazy(() => import("react-apexcharts"));
interface ITimelineDailyTrendChartProps {
dailyTrend: ITimelineDailyTrend[];
metric: TTimelineMetric;
/** 선택 구간 시작 — 있으면 구간 전체 날짜를 채움 */
rangeStart?: Date | null;
/** 선택 구간 끝 */
rangeEnd?: Date | null;
isLoading?: boolean;
}

const TimelineDailyTrendChart = memo(function TimelineDailyTrendChart({
dailyTrend,
metric,
rangeStart = null,
rangeEnd = null,
isLoading = false,
}: ITimelineDailyTrendChartProps) {
const { series, yMax, xMin, xMax } = useMemo(
() => buildTimelineChartSeries(dailyTrend, metric),
[dailyTrend, metric],
const filledRows = useMemo(() => {
if (rangeStart && rangeEnd) {
return fillDailyTrendRange(dailyTrend, rangeStart, rangeEnd);
}
return dailyTrend;
}, [dailyTrend, rangeStart, rangeEnd]);

const { series, categories, yMax, pointCount } = useMemo(
() => buildTimelineChartSeries(filledRows, metric),
[filledRows, metric],
);

const chartOptions = useMemo(
() => buildTimelineDailyTrendChartOptions({ metric, yMax, xMin, xMax }),
[metric, yMax, xMin, xMax],
() =>
buildTimelineDailyTrendChartOptions({
metric,
yMax,
categories,
pointCount,
}),
[metric, yMax, categories, pointCount],
);

if (isLoading) {
return <Skeleton className="h-48 w-full rounded-2xl" />;
}

if (dailyTrend.length === 0) {
if (dailyTrend.length === 0 || pointCount === 0) {
return (
<div className="flex h-48 items-center justify-center rounded-2xl border border-dashed border-surface-300/70 bg-surface-200/30 px-4 py-6">
<span className="font-caption text-text-muted">
Expand Down
39 changes: 17 additions & 22 deletions src/components/timeline/charts/timelineDailyTrendChart.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,25 @@ import {
// 차트 고유 ID
export const TIMELINE_DAILY_TREND_CHART_ID = "timeline-daily-trend-chart";

function formatDateAxisLabel(ts: number): string {
const d = new Date(ts);
const month = d.getMonth() + 1;
const day = d.getDate();
return `${month}/${day}`;
}

function formatRoasAxis(val: number): string {
if (val <= 0) return "";
return `${val.toFixed(1)}`;
}

function formatRoasTooltop(val: number): string {
function formatRoasTooltip(val: number): string {
return `${val.toFixed(2)}배`;
}

export function buildTimelineDailyTrendChartOptions(params: {
metric: TTimelineMetric;
yMax: number;
xMin?: number;
xMax?: number;
categories: string[];
/** 1점이면 선이 없으므로 마커를 항상 표시 */
pointCount?: number;
}): ApexOptions {
const { metric, yMax, xMin, xMax } = params;
const { metric, yMax, categories, pointCount = 0 } = params;
const isRoas = metric === "ROAS";
const isSinglePoint = pointCount === 1;

return {
chart: {
Expand Down Expand Up @@ -62,19 +57,17 @@ export function buildTimelineDailyTrendChartOptions(params: {
colors: ["var(--color-primary-400)"],

markers: {
size: 0,
hover: { size: 5 },
size: isSinglePoint ? 5 : 0,
hover: { size: isSinglePoint ? 7 : 5 },
},

xaxis: {
type: "numeric",
min: xMin,
max: xMax,
tickAmount: Math.min(6, 4),
type: "category",
categories,
labels: {
formatter: (val: string | number) => formatDateAxisLabel(Number(val)),
style: { colors: "var(--color-text-muted)", fontSize: "12px" },
rotate: 0,
hideOverlappingLabels: true,
},
axisBorder: { show: false },
axisTicks: { show: false },
Expand All @@ -94,16 +87,18 @@ export function buildTimelineDailyTrendChartOptions(params: {
borderColor: "var(--color-surface-200)",
xaxis: { lines: { show: false } },
yaxis: { lines: { show: true } },
padding: { left: 8, right: 16 },
padding: { left: 20, right: 16 },
},

tooltip: {
x: {
formatter: (val: number) => formatDateAxisLabel(val),
formatter: (_val, opts) => categories[opts?.dataPointIndex ?? 0] ?? "",
},
y: {
formatter: (val: number) =>
isRoas ? formatRoasTooltop(val) : formatCountChartTooltip(val),
formatter: (val: number) => {
if (val == null || Number.isNaN(val)) return "데이터 없음";
return isRoas ? formatRoasTooltip(val) : formatCountChartTooltip(val);
},
},
style: { fontFamily: "Pretendard" },
},
Expand Down
43 changes: 43 additions & 0 deletions src/utils/timeline/aggregateTimelineMetric.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type {
ITimelineDailyTrend,
TTimelineMetric,
} from "@/types/timeline/api";

/*dailyTrend 행들을 metric별로 하나의 숫자로 합치기
- 카운트(CLICK, CONVERSION, IMPRESSION)는 평균
- ROAS는 일별 roas 산술 평균*/

export function aggregateTimelineMetric(
dailyTrend: ITimelineDailyTrend[],
metric: TTimelineMetric,
): number {
if (dailyTrend.length === 0) return 0;
switch (metric) {
case "CLICK":
return dailyTrend.reduce((sum, row) => sum + row.clicks, 0);
case "CONVERSION":
return dailyTrend.reduce((sum, row) => sum + row.conversions, 0);
case "IMPRESSION":
return dailyTrend.reduce((sum, row) => sum + row.impressions, 0);
case "ROAS": {
const total = dailyTrend.reduce((sum, row) => sum + row.roas, 0);
return total / dailyTrend.length;
}
default:
return 0;
}
}

export function mergeDailyTrendRows(
rows: ITimelineDailyTrend[],
bucketDateIso: string,
): ITimelineDailyTrend | null {
if (rows.length === 0) return null;
return {
date: bucketDateIso,
clicks: aggregateTimelineMetric(rows, "CLICK"),
conversions: aggregateTimelineMetric(rows, "CONVERSION"),
impressions: aggregateTimelineMetric(rows, "IMPRESSION"),
roas: aggregateTimelineMetric(rows, "ROAS"),
};
}
47 changes: 25 additions & 22 deletions src/utils/timeline/buildTimelineChartSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import type {
} from "@/types/timeline/api";
import { TIMELINE_METRIC_OPTIONS } from "@/constants/timeline/formOptions";

import { parseIsoDate } from "./period";
import {
isMissingDailyTrendRow,
type TFilledDailyTrendRow,
} from "./fillDailyTrendRange";

const METRIC_FIELD_MAP: Record<
TTimelineMetric,
Expand All @@ -26,10 +29,6 @@ export function getTimelineMetricLabel(metric: TTimelineMetric): string {
);
}

function toChartTimestamp(isoDate: string): number {
return parseIsoDate(isoDate).getTime();
}

export function getMetricValueFromTrend(
row: ITimelineDailyTrend,
metric: TTimelineMetric,
Expand All @@ -53,46 +52,50 @@ export function calcChartYMax(values: number[], isRoas: boolean): number {
return Math.ceil((max * 1.2) / unit) * unit;
}

export interface ITimelineChartPoint {
x: number;
y: number;
function formatCategoryLabel(isoDate: string): string {
const [, month, day] = isoDate.split("-");
return `${Number(month)}/${Number(day)}`;
}

export interface ITimelineChartSeriesItem {
name: string;
data: ITimelineChartPoint[];
data: (number | null)[];
}

export interface ITimelineChartSeriesResult {
series: ITimelineChartSeriesItem[];
categories: string[];
yMax: number;
xMin: number | undefined;
xMax: number | undefined;
metricLabel: string;
/** null이 아닌 실제 값 개수 (단일 점 마커 표시용) */
pointCount: number;
}

/*missing 날짜는 null로 해서 선이 끊기고 ROAS에 가짜 0을 넣지 않음 */
export function buildTimelineChartSeries(
dailyTrend: ITimelineDailyTrend[],
filledRows: readonly TFilledDailyTrendRow[],
metric: TTimelineMetric,
): ITimelineChartSeriesResult {
const metricLabel = getTimelineMetricLabel(metric);
const points = dailyTrend.map((row) => ({
x: toChartTimestamp(row.date),
y: getMetricValueFromTrend(row, metric),
}));
const ys = points.map((p) => p.y);
const xs = points.map((p) => p.x);

const categories = filledRows.map((row) => formatCategoryLabel(row.date));
const data = filledRows.map((row) => {
if (isMissingDailyTrendRow(row)) return null;
return getMetricValueFromTrend(row, metric);
});

const numericValues = data.filter((value): value is number => value != null);

return {
series: [
{
name: metricLabel,
data: points,
data,
},
],
yMax: calcChartYMax(ys, metric === "ROAS"),
xMin: xs.length > 0 ? Math.min(...xs) : undefined,
xMax: xs.length > 0 ? Math.max(...xs) : undefined,
categories,
yMax: calcChartYMax(numericValues, metric === "ROAS"),
metricLabel,
pointCount: numericValues.length,
};
}
Loading
Loading