refactor: API 응답에서 datetime을 date와 time으로 분리
- datetime 필드를 date와 time 필드로 분리하여 00:00 시간도 정상 표시되도록 수정 - 백엔드: formatSchedule, Meilisearch 검색 결과, 스키마 업데이트 - 프론트엔드: datetime 파싱 로직 제거, date/time 직접 사용 - 문서: API 응답 예시 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5fa9c2a9d0
commit
87a69c0cbd
17 changed files with 98 additions and 92 deletions
|
|
@ -25,7 +25,8 @@ export const scheduleResponse = {
|
|||
properties: {
|
||||
id: { type: 'integer' },
|
||||
title: { type: 'string' },
|
||||
datetime: { type: 'string' },
|
||||
date: { type: 'string', format: 'date' },
|
||||
time: { type: 'string', nullable: true },
|
||||
category: scheduleCategory,
|
||||
members: { type: 'array', items: scheduleMember },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
import Inko from 'inko';
|
||||
import config, { CATEGORY_IDS } from '../../config/index.js';
|
||||
import { createLogger } from '../../utils/logger.js';
|
||||
import { buildDatetime } from '../schedule.js';
|
||||
|
||||
const inko = new Inko();
|
||||
const logger = createLogger('Meilisearch');
|
||||
|
|
@ -166,7 +165,8 @@ function formatScheduleResponse(hit) {
|
|||
return {
|
||||
id: hit.id,
|
||||
title: hit.title,
|
||||
datetime: buildDatetime(hit.date, hit.time),
|
||||
date: hit.date,
|
||||
time: hit.time || null,
|
||||
category: {
|
||||
id: hit.category_id,
|
||||
name: hit.category_name,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ export function formatSchedule(rawSchedule, members = []) {
|
|||
return {
|
||||
id: rawSchedule.id,
|
||||
title: rawSchedule.title,
|
||||
datetime: buildDatetime(rawSchedule.date, rawSchedule.time),
|
||||
date: normalizeDate(rawSchedule.date),
|
||||
time: rawSchedule.time || null,
|
||||
category: {
|
||||
id: rawSchedule.category_id,
|
||||
name: rawSchedule.category_name,
|
||||
|
|
@ -220,7 +221,8 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
|||
const result = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
datetime: buildDatetime(s.date, s.time),
|
||||
date: normalizeDate(s.date),
|
||||
time: s.time || null,
|
||||
category: {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
|
|
@ -324,7 +326,8 @@ export async function getMonthlySchedules(db, year, month) {
|
|||
schedules.push({
|
||||
id: `birthday-${member.id}`,
|
||||
title: `HAPPY ${member.name_en} DAY`,
|
||||
datetime: birthdayDate.toISOString().split('T')[0],
|
||||
date: birthdayDate.toISOString().split('T')[0],
|
||||
time: null,
|
||||
category: {
|
||||
id: CATEGORY_IDS.BIRTHDAY,
|
||||
name: '생일',
|
||||
|
|
@ -338,7 +341,7 @@ export async function getMonthlySchedules(db, year, month) {
|
|||
}
|
||||
|
||||
// 날짜순 정렬
|
||||
schedules.sort((a, b) => a.datetime.localeCompare(b.datetime));
|
||||
schedules.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
return { schedules };
|
||||
}
|
||||
|
|
|
|||
32
docs/api.md
32
docs/api.md
|
|
@ -50,25 +50,24 @@ Base URL: `/api`
|
|||
**월별 조회 응답:**
|
||||
```json
|
||||
{
|
||||
"2026-01-18": {
|
||||
"categories": [
|
||||
{ "id": 2, "name": "유튜브", "color": "#ff0033", "count": 3 }
|
||||
],
|
||||
"schedules": [
|
||||
{
|
||||
"id": 123,
|
||||
"title": "...",
|
||||
"date": "2026-01-18",
|
||||
"time": "19:00:00",
|
||||
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
||||
"source": {
|
||||
"name": "fromis_9",
|
||||
"url": "https://www.youtube.com/watch?v=VIDEO_ID"
|
||||
}
|
||||
},
|
||||
"members": ["송하영"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
|
||||
```
|
||||
|
||||
**source 객체 (카테고리별):**
|
||||
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
|
||||
|
|
@ -77,20 +76,22 @@ Base URL: `/api`
|
|||
|
||||
**다가오는 일정 응답 (startDate):**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"schedules": [
|
||||
{
|
||||
"id": 123,
|
||||
"title": "...",
|
||||
"date": "2026-01-18",
|
||||
"time": "19:00:00",
|
||||
"category_id": 2,
|
||||
"category_name": "유튜브",
|
||||
"category_color": "#ff0033",
|
||||
"members": [{ "name": "송하영" }]
|
||||
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
||||
"source": { "name": "fromis_9", "url": "https://..." },
|
||||
"members": ["송하영"]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
※ 현재 활동 멤버 전원인 경우 `[{ "name": "프로미스나인" }]` 반환 (탈퇴 멤버 제외)
|
||||
※ 현재 활동 멤버 전원인 경우 `["프로미스나인"]` 반환 (탈퇴 멤버 제외)
|
||||
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
|
||||
|
||||
**검색 응답:**
|
||||
```json
|
||||
|
|
@ -99,7 +100,8 @@ Base URL: `/api`
|
|||
{
|
||||
"id": 123,
|
||||
"title": "...",
|
||||
"datetime": "2026-01-18T19:00:00",
|
||||
"date": "2026-01-18",
|
||||
"time": "19:00:00",
|
||||
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
||||
"source": { "name": "fromis_9", "url": "https://..." },
|
||||
"members": ["송하영"],
|
||||
|
|
@ -112,6 +114,8 @@ Base URL: `/api`
|
|||
"hasMore": true
|
||||
}
|
||||
```
|
||||
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
|
||||
```
|
||||
|
||||
### GET /schedules/categories
|
||||
카테고리 목록 조회
|
||||
|
|
|
|||
|
|
@ -5,29 +5,17 @@ import { fetchAuthApi, fetchFormData } from '@/api/client';
|
|||
|
||||
/**
|
||||
* API 응답을 프론트엔드 형식으로 변환
|
||||
* - datetime → date, time 분리
|
||||
* - category 객체 → category_id, category_name, category_color 플랫화
|
||||
* - members 배열 → member_names 문자열
|
||||
*/
|
||||
function transformSchedule(schedule) {
|
||||
const category = schedule.category || {};
|
||||
|
||||
// datetime에서 date와 time 분리
|
||||
let date = '';
|
||||
let time = null;
|
||||
if (schedule.datetime) {
|
||||
const parts = schedule.datetime.split('T');
|
||||
date = parts[0];
|
||||
time = parts[1] || null;
|
||||
}
|
||||
|
||||
// members 배열을 문자열로 (기존 코드 호환성)
|
||||
const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : '';
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
date,
|
||||
time,
|
||||
category_id: category.id,
|
||||
category_name: category.name,
|
||||
category_color: category.color,
|
||||
|
|
|
|||
|
|
@ -2,27 +2,16 @@
|
|||
* 스케줄 API
|
||||
*/
|
||||
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||
import { getTodayKST, dayjs } from '@/utils';
|
||||
import { getTodayKST } from '@/utils';
|
||||
|
||||
/**
|
||||
* API 응답을 프론트엔드 형식으로 변환
|
||||
* - datetime → date, time 분리
|
||||
* - category 객체 → category_id, category_name, category_color 플랫화
|
||||
* - members 배열 → member_names 문자열
|
||||
*/
|
||||
function transformSchedule(schedule) {
|
||||
const category = schedule.category || {};
|
||||
|
||||
// datetime에서 date와 time 분리
|
||||
let date = '';
|
||||
let time = null;
|
||||
if (schedule.datetime) {
|
||||
const dt = dayjs(schedule.datetime);
|
||||
date = dt.format('YYYY-MM-DD');
|
||||
// datetime에 T가 포함되어 있으면 시간이 있는 것
|
||||
time = schedule.datetime.includes('T') ? dt.format('HH:mm:ss') : null;
|
||||
}
|
||||
|
||||
// members 배열을 문자열로 (기존 코드 호환성)
|
||||
const memberNames = Array.isArray(schedule.members)
|
||||
? schedule.members.join(',')
|
||||
|
|
@ -30,8 +19,6 @@ function transformSchedule(schedule) {
|
|||
|
||||
return {
|
||||
...schedule,
|
||||
date,
|
||||
time,
|
||||
category_id: category.id,
|
||||
category_name: category.name,
|
||||
category_color: category.color,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const ScheduleSearchCard = memo(function ScheduleSearchCard({
|
|||
delay = 0,
|
||||
className = '',
|
||||
}) {
|
||||
const scheduleDate = new Date(schedule.date || schedule.datetime);
|
||||
const scheduleDate = new Date(schedule.date);
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
const timeStr = getScheduleTime(schedule);
|
||||
const displayMembers = getDisplayMembers(schedule);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ function AdminScheduleCard({
|
|||
onDelete,
|
||||
className = '',
|
||||
}) {
|
||||
const scheduleDate = new Date(schedule.date || schedule.datetime);
|
||||
const scheduleDate = new Date(schedule.date);
|
||||
const today = new Date();
|
||||
const currentYear = today.getFullYear();
|
||||
const currentMonth = today.getMonth();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
|
||||
import Linkify from 'react-linkify';
|
||||
import { getSchedule } from '@/api';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTime } from '@/utils';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||
|
||||
/**
|
||||
* 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만)
|
||||
|
|
@ -88,7 +88,7 @@ function MobileYoutubeSection({ schedule }) {
|
|||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{formatXDateTime(schedule.datetime)}</span>
|
||||
<span>{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
||||
</div>
|
||||
{schedule.channelName && (
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -274,7 +274,7 @@ function MobileXSection({ schedule }) {
|
|||
|
||||
{/* 날짜/시간 */}
|
||||
<div className="px-4 py-3 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-sm">{formatXDateTime(schedule.datetime)}</span>
|
||||
<span className="text-gray-500 text-sm">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
||||
</div>
|
||||
|
||||
{/* X에서 보기 버튼 */}
|
||||
|
|
@ -371,12 +371,12 @@ function MobileDefaultSection({ schedule }) {
|
|||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{formatFullDate(schedule.datetime)}</span>
|
||||
<span>{formatFullDate(schedule.date)}</span>
|
||||
</div>
|
||||
{schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && (
|
||||
{schedule.time && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
<span>{formatTime(schedule.datetime?.split('T')[1])}</span>
|
||||
<span>{formatTime(schedule.time)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -235,19 +235,17 @@ function YouTubeEditForm() {
|
|||
? `https://www.youtube.com/shorts/${schedule.videoId}`
|
||||
: `https://www.youtube.com/watch?v=${schedule.videoId}`;
|
||||
|
||||
// 날짜 포맷팅 함수 (datetime 문자열 파싱)
|
||||
const formatDatetime = (datetime) => {
|
||||
if (!datetime) return "";
|
||||
// datetime: "2025-01-20T14:00:00" 또는 "2025-01-20"
|
||||
const dateStr = datetime.split("T")[0];
|
||||
const timeStr = datetime.includes("T") ? datetime.split("T")[1]?.slice(0, 5) : "";
|
||||
// 날짜 포맷팅 함수
|
||||
const formatDatetime = (dateStr, timeStr) => {
|
||||
if (!dateStr) return "";
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const dayNames = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
const dayName = dayNames[date.getDay()];
|
||||
return `${year}년 ${month}월 ${day}일 (${dayName}) ${timeStr}`;
|
||||
const time = timeStr ? timeStr.slice(0, 5) : "";
|
||||
return `${year}년 ${month}월 ${day}일 (${dayName}) ${time}`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -324,7 +322,7 @@ function YouTubeEditForm() {
|
|||
</span>
|
||||
<span>
|
||||
<span className="text-gray-400">업로드:</span>{" "}
|
||||
{formatDatetime(schedule.datetime)}
|
||||
{formatDatetime(schedule.date, schedule.time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -456,7 +454,7 @@ function YouTubeEditForm() {
|
|||
</span>
|
||||
<span>
|
||||
<span className="text-gray-400">업로드:</span>{" "}
|
||||
{formatDatetime(schedule.datetime)}
|
||||
{formatDatetime(schedule.date, schedule.time)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400">유형:</span>
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ function DefaultSection({ schedule }) {
|
|||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={16} />
|
||||
<span>{formatFullDate(schedule.datetime)}</span>
|
||||
<span>{formatFullDate(schedule.date)}</span>
|
||||
</div>
|
||||
{schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && (
|
||||
{schedule.time && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} />
|
||||
<span>{formatTime(schedule.datetime?.split('T')[1])}</span>
|
||||
<span>{formatTime(schedule.time)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Linkify from 'react-linkify';
|
||||
import { decodeHtmlEntities, formatXDateTime } from './utils';
|
||||
import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils';
|
||||
import { Lightbox } from '@/components/common';
|
||||
|
||||
/**
|
||||
|
|
@ -130,7 +130,7 @@ function XSection({ schedule }) {
|
|||
|
||||
{/* 날짜/시간 */}
|
||||
<div className="px-5 py-4 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-[15px]">{formatXDateTime(schedule.datetime)}</span>
|
||||
<span className="text-gray-500 text-[15px]">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
||||
</div>
|
||||
|
||||
{/* X에서 보기 버튼 */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Link2 } from 'lucide-react';
|
||||
import { decodeHtmlEntities, formatXDateTime } from './utils';
|
||||
import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils';
|
||||
|
||||
/**
|
||||
* 영상 정보 컴포넌트 (공통)
|
||||
|
|
@ -21,7 +21,7 @@ function VideoInfo({ schedule, isShorts }) {
|
|||
{/* 날짜/시간 */}
|
||||
<div className="flex items-center gap-1.5 text-gray-500">
|
||||
<Calendar size={14} />
|
||||
<span>{formatXDateTime(schedule.datetime)}</span>
|
||||
<span>{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
||||
</div>
|
||||
|
||||
{/* 채널명 */}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@
|
|||
|
||||
// @/utils에서 re-export
|
||||
export { decodeHtmlEntities, formatTime } from '@/utils';
|
||||
export { formatFullDate, formatXDateTime } from '@/utils';
|
||||
export { formatFullDate, formatXDateTime, formatXDateTimeWithTime } from '@/utils';
|
||||
|
|
|
|||
|
|
@ -89,6 +89,31 @@ export const formatXDateTime = (datetime) => {
|
|||
return datePart;
|
||||
};
|
||||
|
||||
/**
|
||||
* X(트위터) 스타일 날짜/시간 포맷팅 (time 필드 분리 버전)
|
||||
* @param {string} date - 날짜 문자열 (YYYY-MM-DD)
|
||||
* @param {string|null} time - 시간 문자열 (HH:mm:ss) 또는 null
|
||||
* @returns {string} "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
|
||||
*/
|
||||
export const formatXDateTimeWithTime = (date, time) => {
|
||||
if (!date) return '';
|
||||
|
||||
const d = dayjs(date).tz(TIMEZONE);
|
||||
const datePart = `${d.year()}년 ${d.month() + 1}월 ${d.date()}일`;
|
||||
|
||||
// time이 있으면 시간 표시
|
||||
if (time) {
|
||||
const [hourStr, minuteStr] = time.split(':');
|
||||
const hours = parseInt(hourStr, 10);
|
||||
const minutes = parseInt(minuteStr, 10);
|
||||
const period = hours < 12 ? '오전' : '오후';
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`;
|
||||
}
|
||||
|
||||
return datePart;
|
||||
};
|
||||
|
||||
/**
|
||||
* datetime 문자열에서 date 추출
|
||||
* @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DD"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export {
|
|||
isToday,
|
||||
formatFullDate,
|
||||
formatXDateTime,
|
||||
formatXDateTimeWithTime,
|
||||
extractDate,
|
||||
extractTime,
|
||||
dayjs,
|
||||
|
|
|
|||
|
|
@ -28,12 +28,11 @@ export function getCategoryInfo(schedule) {
|
|||
|
||||
/**
|
||||
* 스케줄에서 날짜 추출
|
||||
* datetime 형식과 date 형식 모두 처리
|
||||
* @param {object} schedule - 스케줄 객체
|
||||
* @returns {string} YYYY-MM-DD 형식 날짜
|
||||
*/
|
||||
export function getScheduleDate(schedule) {
|
||||
return schedule.date || extractDate(schedule.datetime);
|
||||
return schedule.date || '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -45,7 +44,7 @@ export function getScheduleTime(schedule) {
|
|||
if (schedule.time) {
|
||||
return schedule.time.slice(0, 5);
|
||||
}
|
||||
return extractTime(schedule.datetime);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue