diff --git a/src/pages/stock/watchlistUtils.js b/src/pages/stock/watchlistUtils.js new file mode 100644 index 0000000..3afa0d6 --- /dev/null +++ b/src/pages/stock/watchlistUtils.js @@ -0,0 +1,47 @@ +/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */ + +export const KIND_META = { + buy: { label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }, + sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }, +}; + +const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' }; + +export const kindMeta = (kind) => { + const meta = KIND_META[kind]; + if (meta) return meta; + return { ...FALLBACK_KIND, label: kind ?? '' }; +}; + +export const CONDITION_LABEL = { + buy_ma20_pullback: 'MA20 눌림 반등', + buy_breakout: '박스 상단 돌파', + buy_rsi_bounce: 'RSI 과매도 반등', + sell_stop_loss: '손절 라인', + sell_ma_break: '이평선 이탈', + sell_take_profit: '목표가 도달', + sell_climax: '과열 소진', + sell_trailing_stop: '트레일링 스톱', +}; + +export const conditionLabel = (cond) => CONDITION_LABEL[cond] ?? cond ?? ''; + +export const normalizeTicker = (str) => String(str ?? '').trim(); + +export const relativeTime = (iso, now = Date.now()) => { + if (!iso) return ''; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const diffMs = now - then; + if (diffMs < 0) return '방금'; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return '방금'; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}분 전`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}시간 전`; + const day = Math.floor(hr / 24); + if (day === 1) return '어제'; + if (day < 7) return `${day}일 전`; + return new Date(iso).toLocaleDateString('ko-KR'); +}; diff --git a/src/pages/stock/watchlistUtils.test.js b/src/pages/stock/watchlistUtils.test.js new file mode 100644 index 0000000..4e694cf --- /dev/null +++ b/src/pages/stock/watchlistUtils.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js'; + +describe('kindMeta', () => { + it('buy/sell 라벨과 색을 반환', () => { + expect(kindMeta('buy').label).toBe('매수'); + expect(kindMeta('buy').color).toBe('#22c55e'); + expect(kindMeta('sell').label).toBe('매도'); + expect(kindMeta('sell').color).toBe('#ef4444'); + }); + it('미정의 kind는 회색 폴백 + 원문 label', () => { + const m = kindMeta('weird'); + expect(m.label).toBe('weird'); + expect(m.color).toBe('#94a3b8'); + }); +}); + +describe('conditionLabel', () => { + it('정의된 8종을 한글로 매핑', () => { + expect(conditionLabel('buy_ma20_pullback')).toBe('MA20 눌림 반등'); + expect(conditionLabel('buy_breakout')).toBe('박스 상단 돌파'); + expect(conditionLabel('buy_rsi_bounce')).toBe('RSI 과매도 반등'); + expect(conditionLabel('sell_stop_loss')).toBe('손절 라인'); + expect(conditionLabel('sell_ma_break')).toBe('이평선 이탈'); + expect(conditionLabel('sell_take_profit')).toBe('목표가 도달'); + expect(conditionLabel('sell_climax')).toBe('과열 소진'); + expect(conditionLabel('sell_trailing_stop')).toBe('트레일링 스톱'); + }); + it('미정의 condition은 원문 폴백', () => { + expect(conditionLabel('buy_unknown')).toBe('buy_unknown'); + expect(conditionLabel(undefined)).toBe(''); + }); +}); + +describe('normalizeTicker', () => { + it('공백 trim', () => { + expect(normalizeTicker(' 005930 ')).toBe('005930'); + expect(normalizeTicker(undefined)).toBe(''); + }); +}); + +describe('relativeTime', () => { + const now = new Date('2026-07-03T12:00:00Z').getTime(); + it('60초 미만은 방금', () => { + expect(relativeTime('2026-07-03T11:59:30Z', now)).toBe('방금'); + }); + it('분/시간/어제/일 경계', () => { + expect(relativeTime('2026-07-03T11:55:00Z', now)).toBe('5분 전'); + expect(relativeTime('2026-07-03T09:00:00Z', now)).toBe('3시간 전'); + expect(relativeTime('2026-07-02T10:00:00Z', now)).toBe('어제'); + expect(relativeTime('2026-06-30T12:00:00Z', now)).toBe('3일 전'); + }); + it('잘못된/빈 값은 빈 문자열', () => { + expect(relativeTime('', now)).toBe(''); + expect(relativeTime('not-a-date', now)).toBe(''); + }); +});