diff --git a/.storybook/main.ts b/.storybook/main.ts index 7ef96386..8eaad833 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,8 +1,19 @@ import type { StorybookConfig } from "@storybook/react-vite"; +import type { InlineConfig } from "vite"; const config: StorybookConfig = { stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], addons: ["@storybook/addon-essentials"], framework: "@storybook/react-vite", + async viteFinal(viteConfig: InlineConfig) { + viteConfig.define = { + ...(viteConfig.define ?? {}), + "import.meta.env.VITE_API_BASE_URL": JSON.stringify( + "http://localhost:8080", + ), + }; + return viteConfig; + }, }; + export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 86263cbb..34b14cd8 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,13 +2,23 @@ import "../src/index.css"; import { MemoryRouter } from "react-router-dom"; import type { Preview } from "@storybook/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}); const preview: Preview = { decorators: [ (Story) => ( - - - + + + + + ), ], parameters: { diff --git a/src/api/timeline/timeline.ts b/src/api/timeline/timeline.ts new file mode 100644 index 00000000..2fb93217 --- /dev/null +++ b/src/api/timeline/timeline.ts @@ -0,0 +1,53 @@ +import type { ICommonResponse } from "@/types/common/common"; +import type { + ITimelineDetail, + ITimelineListItem, + ITimelineMutationResponse, + ITimelineUpsertRequest, +} from "@/types/timeline/api"; + +import { axiosInstance } from "@/lib/axiosInstance"; + +//타임라인 상세 조회 API +export const getTimelineDetail = async ( + orgId: number, + timelineId: number, +): Promise => { + const { data } = await axiosInstance.get>( + `/api/org/${orgId}/timeline/${timelineId}`, + ); + return data.data; +}; + +//타임라인 목록 조회 API +export const getTimelineList = async ( + orgId: number, +): Promise => { + const { data } = await axiosInstance.get< + ICommonResponse + >(`/api/org/${orgId}/timeline`); + return data.data; +}; + +//타임라인 생성 API +export const createTimeline = async ( + orgId: number, + body: ITimelineUpsertRequest, +): Promise => { + const { data } = await axiosInstance.post< + ICommonResponse + >(`/api/org/${orgId}/timeline`, body, { + validateStatus: (status) => + status === 201 || (status >= 200 && status < 300), + }); + return data.data; +}; + +//타임라인 수정 API +//export const updateTimeline = async + +//타임라인 삭제 API +//export const deleteTimeline = async + +//타임라인 AI 요약 요청 API +//export const requestTimelineSummary = async diff --git a/src/components/timeline/TimelineCreateModal.tsx b/src/components/timeline/TimelineCreateModal.tsx index bd7b1a3f..9e17707f 100644 --- a/src/components/timeline/TimelineCreateModal.tsx +++ b/src/components/timeline/TimelineCreateModal.tsx @@ -1,7 +1,6 @@ -import { type MouseEvent, useEffect, useMemo, useState } from "react"; +import { type MouseEvent, useEffect, useMemo } from "react"; import { Controller, type SubmitHandler, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; import { twMerge } from "tailwind-merge"; import type { TTimelineMetric } from "@/types/timeline/api"; @@ -16,6 +15,8 @@ import { type TTimelineCreateFormValues, } from "@/utils/timeline/timeline"; +import { useCreateTimeline } from "@/hooks/timeline/useCreateTimeline"; + import Button from "../common/button/Button"; import { DropdownMenu, @@ -26,8 +27,6 @@ import Modal from "../common/modal/Modal"; import ChevronIcon from "@/assets/icon/chevron/chevron-up.svg?react"; -const MOCK_SUBMIT_DELAY_MS = 800; - function openDatePickerFromField(event: MouseEvent) { const input = event.currentTarget.querySelector('input[type="date"]'); @@ -53,7 +52,8 @@ export default function TimelineCreateModal({ isOpen, onClose, }: ITimelineCreateModalProps) { - const [isSubmitting, setIsSubmitting] = useState(false); + // const [isSubmitting, setIsSubmitting] = useState(false); + const { mutate, isPending } = useCreateTimeline(); const { register, @@ -86,24 +86,23 @@ export default function TimelineCreateModal({ label: option.label, active: option.value === comparisonPeriodType, onClick: () => { - if (isSubmitting) return; + if (isPending) return; setValue("comparisonPeriodType", option.value, { shouldValidate: true, }); }, })), - [comparisonPeriodType, isSubmitting, setValue], + [comparisonPeriodType, isPending, setValue], ); useEffect(() => { if (!isOpen) { reset(TIMELINE_FORM_DEFAULT_VALUES); - setIsSubmitting(false); } }, [isOpen, reset]); const handleClose = () => { - if (isSubmitting) return; + if (isPending) return; onClose(); }; @@ -116,23 +115,13 @@ export default function TimelineCreateModal({ setValue("metrics", next, { shouldValidate: true }); }; - const onSubmit: SubmitHandler = async (data) => { - setIsSubmitting(true); - - try { - await new Promise((resolve) => { - window.setTimeout(resolve, MOCK_SUBMIT_DELAY_MS); - }); - toast.success("타임라인이 생성되었습니다", { - description: `"${data.name}" 타임라인을 추가했습니다`, - }); - reset(TIMELINE_FORM_DEFAULT_VALUES); - onClose(); - } catch { - toast.error("타임라인 생성에 실패했습니다. 다시 시도해주세요"); - } finally { - setIsSubmitting(false); - } + const onSubmit: SubmitHandler = (data) => { + mutate(data, { + onSuccess: () => { + reset(TIMELINE_FORM_DEFAULT_VALUES); + onClose(); + }, + }); }; return ( @@ -142,7 +131,7 @@ export default function TimelineCreateModal({ size="lg" padding="lg" title="타임라인 생성" - disableOverlayClick={isSubmitting} + disableOverlayClick={isPending} >

타임라인 생성

@@ -159,7 +148,7 @@ export default function TimelineCreateModal({
toggleMetric(option.value)} className={twMerge( @@ -224,7 +213,7 @@ export default function TimelineCreateModal({ isSelected ? "border-info-blue/40 bg-info-blue/15 text-info-blue" : "border-text-placeholder/40 bg-surface-200 text-text-muted hover:bg-surface-300", - isSubmitting && "cursor-not-allowed opacity-60", + isPending && "cursor-not-allowed opacity-60", )} > {option.label} @@ -260,7 +249,7 @@ export default function TimelineCreateModal({ "flex h-14 w-full cursor-pointer items-center justify-between rounded-2xl bg-surface-100 px-5 text-left font-body1 ring-1 ring-surface-400 transition-colors duration-200 ease-out outline-none", "hover:bg-surface-200 hover:ring-surface-400", "focus-visible:ring-2 focus-visible:ring-surface-400", - isSubmitting && "cursor-not-allowed opacity-60", + isPending && "cursor-not-allowed opacity-60", errors.comparisonPeriodType ? "ring-2 ring-info-red bg-info-red/5" : "", @@ -294,10 +283,10 @@ export default function TimelineCreateModal({ size="big" variant="primary" fullWidth - isLoading={isSubmitting} - disabled={isSubmitting} + isLoading={isPending} + disabled={isPending} > - {isSubmitting ? "생성 중..." : "생성하기"} + {isPending ? "생성 중..." : "생성하기"}
diff --git a/src/components/timeline/skeleton/TimelineSkeleton.tsx b/src/components/timeline/skeleton/TimelineSkeleton.tsx new file mode 100644 index 00000000..6d38d5ca --- /dev/null +++ b/src/components/timeline/skeleton/TimelineSkeleton.tsx @@ -0,0 +1,24 @@ +import { TIMELINE_PAGE_HEIGHT } from "@/constants/timeline/layout"; + +import { Skeleton } from "@/components/common/skeleton/Skeleton"; + +export default function TimelineSkeleton() { + return ( +
+
+
+ + +
+
+ + + +
+
+
+ ); +} diff --git a/src/constants/timeline/formOptions.ts b/src/constants/timeline/formOptions.ts index 592e6ccd..18cbcef2 100644 --- a/src/constants/timeline/formOptions.ts +++ b/src/constants/timeline/formOptions.ts @@ -21,7 +21,7 @@ export const TIMELINE_COMPARISON_PERIOD_OPTIONS: ReadonlyArray<{ }> = [ { value: "LAST_WEEK", label: "지난주 대비" }, { value: "LAST_MONTH", label: "지난달 대비" }, - { value: "PREVIOUS_PERIOD", label: "이전 동일 기간 대비" }, + { value: "LAST_YEAR", label: "작년 동기간 대비" }, ] as const; /*zod enum 용 - 타입과 동기화*/ diff --git a/src/hooks/timeline/useCreateTimeline.ts b/src/hooks/timeline/useCreateTimeline.ts new file mode 100644 index 00000000..14ae8504 --- /dev/null +++ b/src/hooks/timeline/useCreateTimeline.ts @@ -0,0 +1,37 @@ +import { toast } from "sonner"; + +import type { IApiErrorResponse } from "@/types/common/common"; +import type { ITimelineUpsertRequest } from "@/types/timeline/api"; + +import { useCoreMutation } from "@/hooks/customQuery"; + +import { createTimeline } from "@/api/timeline/timeline"; +import { QUERY_KEYS } from "@/lib/queryKeys"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +export function useCreateTimeline() { + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + + return useCoreMutation( + (body: ITimelineUpsertRequest) => { + if (orgId == null) { + return Promise.reject(new Error("워크스페이스를 선택해주세요")); + } + return createTimeline(orgId, body); + }, + { + invalidateKeys: orgId != null ? [QUERY_KEYS.timeline.list(orgId)] : [], + userOnSuccess: (data) => { + toast.success("타임라인이 생성되었습니다", { + description: `"${data.name}" 타임라인을 추가했습니다`, + }); + }, + userOnError: (error) => { + const message = + (error as IApiErrorResponse).message ?? + "타임라인 생성에 실패했습니다. 다시 시도해주세요"; + toast.error(message); + }, + }, + ); +} diff --git a/src/hooks/timeline/useTimelineDetail.ts b/src/hooks/timeline/useTimelineDetail.ts new file mode 100644 index 00000000..7fb1b16c --- /dev/null +++ b/src/hooks/timeline/useTimelineDetail.ts @@ -0,0 +1,15 @@ +import { useCoreQuery } from "@/hooks/customQuery"; + +import { getTimelineDetail } from "@/api/timeline/timeline"; +import { QUERY_KEYS } from "@/lib/queryKeys"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +export function useTimelineDetail(timelineId: number | null) { + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + + return useCoreQuery( + QUERY_KEYS.timeline.detail(orgId, timelineId), + () => getTimelineDetail(orgId!, timelineId!), + { enabled: orgId != null && timelineId != null }, + ); +} diff --git a/src/hooks/timeline/useTimelineList.ts b/src/hooks/timeline/useTimelineList.ts new file mode 100644 index 00000000..dcdd50f5 --- /dev/null +++ b/src/hooks/timeline/useTimelineList.ts @@ -0,0 +1,16 @@ +import { useCoreQuery } from "@/hooks/customQuery"; + +import { getTimelineList } from "@/api/timeline/timeline"; +import { QUERY_KEYS } from "@/lib/queryKeys"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +export function useTimelineList() { + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + return useCoreQuery( + QUERY_KEYS.timeline.list(orgId), + () => getTimelineList(orgId!), + { + enabled: orgId != null, + }, + ); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 8b60c43d..4cc8475b 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -72,4 +72,10 @@ export const QUERY_KEYS = { report: (provider: string, orgId: number | null) => ["ai", "report", provider, orgId] as const, }, + + timeline: { + list: (orgId: number | null) => ["timeline", "list", orgId] as const, + detail: (orgId: number | null, timelineId: number | null) => + ["timeline", "detail", orgId, timelineId] as const, + }, } as const; diff --git a/src/pages/dashboard/timeline/Timeline.tsx b/src/pages/dashboard/timeline/Timeline.tsx index ca4dd4c1..d106caf6 100644 --- a/src/pages/dashboard/timeline/Timeline.tsx +++ b/src/pages/dashboard/timeline/Timeline.tsx @@ -3,11 +3,6 @@ import { toast } from "sonner"; import { twMerge } from "tailwind-merge"; import type { ITimelineSummaryPanelData } from "@/types/timeline/summary"; -import { - buildTimelineSummaryPanelDataForBar, - TIMELINE_GRID_MOCK_BY_VIEW_UNIT, - TIMELINE_GRID_MOCK_WEEK, -} from "@/types/timeline/timeline.mock"; import type { ITimelineCampaignBar, TTimelineViewUnit, @@ -17,7 +12,14 @@ import { TIMELINE_PAGE_HEIGHT, } from "@/constants/timeline/layout"; +import { buildTimelineGrid } from "@/utils/timeline/buildTimelineGrid"; +import { buildTimelineSummaryPanel } from "@/utils/timeline/buildTimelineSummaryPanel"; + +import { useTimelineDetail } from "@/hooks/timeline/useTimelineDetail"; +import { useTimelineList } from "@/hooks/timeline/useTimelineList"; + import Button from "@/components/common/button/Button"; +import TimelineSkeleton from "@/components/timeline/skeleton/TimelineSkeleton"; import TimelineAxis from "@/components/timeline/TimelineAxis"; import TimelineBar from "@/components/timeline/TimelineBar"; import TimelineCreateModal from "@/components/timeline/TimelineCreateModal"; @@ -34,21 +36,13 @@ import PlusIcon from "@/assets/icon/common/plus.svg?react"; import FilterIcon from "@/assets/icon/timeline/filter.svg?react"; import SortIcon from "@/assets/icon/timeline/sort.svg?react"; -const MOCK_PERIOD_LABELS: Record = { - DAY: ["오늘", "6월 23일", "6월 24일"], - WEEK: ["오늘", TIMELINE_GRID_MOCK_WEEK.periodLabel, "6월 28일 - 7월 4일"], - MONTH: ["오늘", "2026년 6월", "2026년 7월"], -}; - const TOOLBAR_ACTION_CLASS = "flex items-center gap-1.5 rounded-lg px-2 py-1.5 font-caption text-text-muted opacity-50 cursor-not-allowed"; export default function Timeline() { const scrollRef = useRef(null); - const [viewUnit, setViewUnit] = useState( - TIMELINE_GRID_MOCK_WEEK.viewUnit, - ); + const [viewUnit, setViewUnit] = useState("WEEK"); const [periodIndex, setPeriodIndex] = useState(0); const [isCreateOpen, setIsCreateOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false); @@ -57,9 +51,23 @@ export default function Timeline() { null, ); - const gridData = TIMELINE_GRID_MOCK_BY_VIEW_UNIT[viewUnit]; + const { + data: timelineList = [], + isLoading, + isError, + error, + } = useTimelineList(); + + const { data: detail } = useTimelineDetail(selectedBarId); + + const gridData = useMemo( + () => buildTimelineGrid({ items: timelineList, viewUnit, periodIndex }), + [timelineList, viewUnit, periodIndex], + ); const { columns, bars } = gridData; - const isEmpty = bars.length === 0; + const hasNoTimelines = timelineList.length === 0; + const hasNoVisibleBars = !hasNoTimelines && bars.length === 0; + const periodLabel = gridData.periodLabel; const maxRow = useMemo( () => (bars.length > 0 ? Math.max(...bars.map((bar) => bar.row)) : 0), @@ -68,31 +76,17 @@ export default function Timeline() { const totalWidth = columns.length * TIMELINE_COL_WIDTH; - const periodLabels = useMemo( - () => - MOCK_PERIOD_LABELS[viewUnit].map((label, index) => - index === 1 ? gridData.periodLabel : label, - ), - [gridData.periodLabel, viewUnit], - ); - const periodLabel = periodLabels[periodIndex] ?? periodLabels[0]; - - const selectedBar = useMemo( - () => bars.find((bar) => bar.id === selectedBarId) ?? null, - [bars, selectedBarId], - ); - useEffect(() => { - if (!selectedBar) return; - setPanelData(buildTimelineSummaryPanelDataForBar(selectedBar)); - }, [selectedBar]); + if (!detail) return; + setPanelData(buildTimelineSummaryPanel(detail)); + }, [detail]); useEffect(() => { - if (isEmpty) return; + if (hasNoTimelines) return; const el = scrollRef.current; if (!el) return; el.scrollLeft = el.scrollWidth - el.clientWidth; - }, [columns, isEmpty, viewUnit]); + }, [columns, hasNoTimelines, viewUnit]); useEffect(() => { if (selectedBarId === null) return; @@ -108,11 +102,11 @@ export default function Timeline() { }; const handlePrevPeriod = () => { - setPeriodIndex((prev) => (prev === 0 ? periodLabels.length - 1 : prev - 1)); + setPeriodIndex((prev) => prev + 1); //더 과거 }; const handleNextPeriod = () => { - setPeriodIndex((prev) => (prev === periodLabels.length - 1 ? 0 : prev + 1)); + setPeriodIndex((prev) => Math.max(0, prev - 1)); }; const handleGoToToday = () => { @@ -121,7 +115,7 @@ export default function Timeline() { const handleBarClick = (bar: ITimelineCampaignBar) => { setSelectedBarId(bar.id); - setPanelData(buildTimelineSummaryPanelDataForBar(bar)); + setPanelData(null); setIsPanelOpen(true); }; @@ -130,6 +124,30 @@ export default function Timeline() { setSelectedBarId(null); }; + if (isLoading) { + return ; + } + + if (isError) { + return ( +
+
+

+ {error?.message ?? + "타임라인을 불러오지 못했습니다. 잠시 후에 다시 시도해주세요"} +

+
+
+ ); + } + return (
- - {isEmpty ? ( + {hasNoTimelines ? ( setIsCreateOpen(true)} /> ) : (
- - {bars.map((bar) => ( - - toast.info("수정기능은 다음 이슈에서 연동됩니다") - } - onDelete={() => - toast.info("삭제기능은 다음 이슈에서 연동됩니다") - } - /> - ))} - + {hasNoVisibleBars ? ( +
+

+ 이 기간에 표시할 타임라인이 없어요 +

+

+ 다른 기간으로 이동하거나 보기 단위를 변경해 보세요 +

+
+ ) : ( + + {bars.map((bar) => ( + + toast.info("수정기능은 다음 이슈에서 연동됩니다") + } + onDelete={() => + toast.info("삭제기능은 다음 이슈에서 연동됩니다") + } + /> + ))} + + )}
)} diff --git a/src/types/timeline/api.ts b/src/types/timeline/api.ts index e4a39686..743e0009 100644 --- a/src/types/timeline/api.ts +++ b/src/types/timeline/api.ts @@ -26,7 +26,7 @@ export type TTimelinePerformanceStatus = export const TIMELINE_COMPARISON_PERIOD_TYPES = [ "LAST_WEEK", "LAST_MONTH", - "PREVIOUS_PERIOD", + "LAST_YEAR", ] as const; export type TTimelineComparisonPeriodType = (typeof TIMELINE_COMPARISON_PERIOD_TYPES)[number]; diff --git a/src/utils/timeline/buildTimelineGrid.ts b/src/utils/timeline/buildTimelineGrid.ts new file mode 100644 index 00000000..baa71038 --- /dev/null +++ b/src/utils/timeline/buildTimelineGrid.ts @@ -0,0 +1,136 @@ +import type { ITimelineListItem } from "@/types/timeline/api"; +import type { + ITimelineCampaignBar, + ITimelineGridColumn, + ITimelineGridData, + TTimelineViewUnit, +} from "@/types/timeline/ui"; + +import { + formatRange, + parseIsoDate, + resolveVisiblePeriod, + startOfDay, + toIsoDate, +} from "./period"; + +const WEEKDAY = ["일", "월", "화", "수", "목", "금", "토"] as const; + +interface IBuildTimelineGridParams { + items: ITimelineListItem[]; + viewUnit: TTimelineViewUnit; + periodIndex: number; +} + +/** visibleStart ~ visibleEnd 사이 하루마다 column 1칸 생성 */ +function buildColumns( + start: Date, + end: Date, + today: Date, +): ITimelineGridColumn[] { + const columns: ITimelineGridColumn[] = []; + const cursor = startOfDay(start); + const endDay = startOfDay(end); + const todayDay = startOfDay(today); + + while (cursor.getTime() <= endDay.getTime()) { + const dayIndex = cursor.getDay(); + + columns.push({ + day: WEEKDAY[dayIndex], + date: cursor.getDate(), + isWeekend: dayIndex === 0 || dayIndex === 6, + isToday: cursor.getTime() === todayDay.getTime(), + isoDate: toIsoDate(cursor), + }); + + cursor.setDate(cursor.getDate() + 1); + } + + return columns; +} + +/** date가 min~max 범위를 벗어나면 잘라냄 */ +function clampDate(date: Date, min: Date, max: Date): Date { + const time = startOfDay(date).getTime(); + const minTime = startOfDay(min).getTime(); + const maxTime = startOfDay(max).getTime(); + + if (time < minTime) return startOfDay(min); + if (time > maxTime) return startOfDay(max); + return startOfDay(date); +} + +/** columns에서 해당 날짜가 몇 번째 칸인지 (1-based) */ +function findColumnIndex(columns: ITimelineGridColumn[], date: Date): number { + const iso = toIsoDate(startOfDay(date)); + const index = columns.findIndex((column) => column.isoDate === iso); + + // 못 찾으면 1번 칸 (방어 코드) + return index >= 0 ? index + 1 : 1; +} + +/** 목록 item → 그리드 bar */ +function layoutBars( + items: ITimelineListItem[], + columns: ITimelineGridColumn[], + visibleStart: Date, + visibleEnd: Date, +): ITimelineCampaignBar[] { + const bars: ITimelineCampaignBar[] = []; + let row = 1; + + for (const item of items) { + const itemStart = parseIsoDate(item.startDate); + const itemEnd = parseIsoDate(item.endDate); + + // 화면 기간과 안 겹치면 그리지 않음 + if (itemEnd < visibleStart || itemStart > visibleEnd) { + continue; + } + + const barStart = clampDate(itemStart, visibleStart, visibleEnd); + const barEnd = clampDate(itemEnd, visibleStart, visibleEnd); + + const colStart = findColumnIndex(columns, barStart); + const colEnd = findColumnIndex(columns, barEnd) + 1; + + bars.push({ + id: item.timelineId, + title: item.name, + subtitle: formatRange(item.startDate, item.endDate), + performanceStatus: item.performanceStatus, + colStart, + colEnd, + row, + }); + + row += 1; + } + + return bars; +} + +/** 목록 API 결과 → Timeline.tsx가 쓰는 gridData */ +export function buildTimelineGrid({ + items, + viewUnit, + periodIndex, +}: IBuildTimelineGridParams): ITimelineGridData { + const today = new Date(); + const { start, end, periodLabel } = resolveVisiblePeriod( + viewUnit, + periodIndex, + today, + ); + + const columns = buildColumns(start, end, today); + const bars = layoutBars(items, columns, start, end); + + return { + viewUnit, + periodLabel, + columns, + bars, + }; +} diff --git a/src/utils/timeline/buildTimelineSummaryPanel.ts b/src/utils/timeline/buildTimelineSummaryPanel.ts new file mode 100644 index 00000000..061919eb --- /dev/null +++ b/src/utils/timeline/buildTimelineSummaryPanel.ts @@ -0,0 +1,65 @@ +import type { + ITimelineDailyTrend, + ITimelineDetail, + TTimelineMetric, +} from "@/types/timeline/api"; +import type { ITimelineSummaryPanelData } from "@/types/timeline/summary"; +import { TIMELINE_METRIC_OPTIONS } from "@/constants/timeline/formOptions"; + +import { formatDot } from "./period"; + +/** dailyTrend 배열에서 metric별 집계값 계산 */ +function aggregateMetric( + 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; + } +} + +/** 상세 API → 성과 패널 props */ +export function buildTimelineSummaryPanel( + detail: ITimelineDetail, +): ITimelineSummaryPanelData { + const metrics = detail.metrics.map((metricKey) => { + const label = + TIMELINE_METRIC_OPTIONS.find((option) => option.value === metricKey) + ?.label ?? metricKey; + + const value = aggregateMetric(detail.dailyTrend, metricKey); + + return { + metric: metricKey, + label, + value, + unit: metricKey === "ROAS" ? "배" : undefined, + // changeRate: API에 없음 → 패널에서 % 변화 없으면 숨김 처리됨 + }; + }); + + return { + timelineName: detail.name, + periodLabel: `${formatDot(detail.startDate)} ~ ${formatDot(detail.endDate)}`, + performanceStatus: detail.performanceStatus, + aiSummary: detail.summary ?? "", + metrics, + platformShare: detail.platformContributions.map((item) => ({ + provider: item.platform, + contributionRate: item.contributionRate, + })), + }; +} diff --git a/src/utils/timeline/period.ts b/src/utils/timeline/period.ts new file mode 100644 index 00000000..c7db89f6 --- /dev/null +++ b/src/utils/timeline/period.ts @@ -0,0 +1,135 @@ +import type { TTimelineViewUnit } from "@/types/timeline/ui"; + +const MONTH = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +] as const; + +export interface ITimelineVisiblePeriod { + start: Date; + end: Date; + periodLabel: string; +} + +export function parseIsoDate(iso: string): Date { + const [year, month, day] = iso.split("-").map(Number); + return new Date(year, month - 1, day); +} + +export function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +export function toIsoDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +export function formatShortDate(iso: string): string { + const [, month, day] = iso.split("-"); + return `${month}.${day}`; +} + +export function formatRange(startIso: string, endIso: string): string { + return `${formatShortDate(startIso)} - ${formatShortDate(endIso)}`; +} + +export function formatDot(iso: string): string { + const [year, month, day] = iso.split("-"); + return `${year}.${month}.${day}`; +} + +function getWeekStart(date: Date): Date { + const d = startOfDay(date); + d.setDate(d.getDate() - d.getDay()); + return d; +} + +function formatDayLabel(date: Date, today: Date): string { + if (startOfDay(date).getTime() === startOfDay(today).getTime()) { + return "오늘"; + } + return `${date.getMonth() + 1}월 ${date.getDate()}일`; +} + +function formatWeekLabel(start: Date, end: Date): string { + const startDay = start.getDate(); + const endDay = end.getDate(); + const startMonth = MONTH[start.getMonth()]; + const endMonth = MONTH[end.getMonth()]; + + if (start.getMonth() === end.getMonth()) { + return `${startDay} ${startMonth} - ${endDay} ${endMonth}`; + } + return `${startDay} ${startMonth} - ${endDay} ${endMonth}`; +} + +function formatMonthLabel(date: Date): string { + return `${date.getFullYear()}년 ${date.getMonth() + 1}월`; +} + +/*viewUnit + periodLabel이 이번이 화면에 보여줄 날짜 범위 */ +export function resolveVisiblePeriod( + viewUnit: TTimelineViewUnit, + periodIndex: number, + today = new Date(), +): ITimelineVisiblePeriod { + const normalizedToday = startOfDay(today); + + if (viewUnit === "DAY") { + const start = new Date(normalizedToday); + start.setDate(start.getDate() - periodIndex); + + return { + start, + end: new Date(start), + periodLabel: formatDayLabel(start, normalizedToday), + }; + } + if (viewUnit === "WEEK") { + const weekStart = getWeekStart(normalizedToday); + weekStart.setDate(weekStart.getDate() - periodIndex * 7); + + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 6); + + return { + start: weekStart, + end: weekEnd, + periodLabel: + periodIndex === 0 && + weekStart <= normalizedToday && + normalizedToday <= weekEnd + ? "오늘" + : formatWeekLabel(weekStart, weekEnd), + }; + } + //Month + const anchor = new Date( + normalizedToday.getFullYear(), + normalizedToday.getMonth() - periodIndex, + 1, + ); + + const start = new Date(anchor.getFullYear(), anchor.getMonth(), 1); + const end = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0); + + return { + start, + end, + periodLabel: + periodIndex === 0 ? formatMonthLabel(start) : formatMonthLabel(start), + }; +}