From 8ccfe4249b784cb3400915510f8706979f41e346 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Thu, 27 Aug 2026 22:33:48 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs:=20Phase=201=20=EC=9B=B9=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=C2=B7=EC=83=81=EC=84=B8=20=EC=B0=A9=EC=88=98=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=EB=A5=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auth는 Phase 0 완료로 표기, 검색·종목 상세만 Phase 1로 고정 Co-authored-by: Cursor --- docs/architecture.md | 12 ++++++------ docs/portfolio.md | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 48fb618..494b0e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,14 +120,13 @@ shared는 `main`/`types`가 `dist/`를 가리키므로 **앱 실행 전 `pnpm -- ### 4.2 Web (`apps/web`) — Phase 1 -| 화면 | Phase | -|------|-------| -| 랜딩 / 아카이브 안내 | 스캐폴드 (현재) | -| 매직링크 로그인 | Phase 1 (앱과 동일 Supabase 프로젝트) | +| 화면 | 상태 | +|------|------| +| 홈(관심종목 목록) · 로그인/회원가입 · 콜백 | Phase 0 완료 | | entries 검색 (`q`, page size 20) | Phase 1 | -| 종목 상세 + TradingView | Phase 1 | +| 종목 상세 + TradingView (타임라인 조회 · 엔트리 CRUD) | Phase 1 | -Phase 0 동안 웹은 **스키마·디자인 계약만 맞춤**. 필수는 아님. +검색 범위 (`docs/design.md`): `entries.body|note|reason|title|url` 및 ticker `symbol`/`name`에 ILIKE. 결과 = entry 리스트 → 종목 상세 링크. --- @@ -378,3 +377,4 @@ Phase 0 추가 권장: | 2026-08-15 | 모바일 매직링크 콜백·웹 세션 캐시 헤더 반영 | | 2026-08-19 | 이메일/비번 회원가입·로그인 + Google OAuth 추가 | | 2026-08-20 | 콜백 open-redirect·세션 캐시 헤더·entries UPDATE ticker 소유권·모바일 Google WebBrowser | +| 2026-08-25 | Phase 0 머지 후 Phase 1(웹 검색·상세) 착수 준비 — Auth는 Phase 0에서 완료로 표기 | diff --git a/docs/portfolio.md b/docs/portfolio.md index 389382f..3489730 100644 --- a/docs/portfolio.md +++ b/docs/portfolio.md @@ -274,3 +274,4 @@ Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임 | 2026-08-24 | `pnpm test:coverage`로 단위·컴포넌트 Lines % 측정 (shared/mobile 100%, web ~81%) | | 2026-08-25 | 중복 종목 Alert 문구, Sonda 번들 분석 스크립트 | | 2026-08-25 | Phase 0 실계정 스모크 완료로 마감 | +| 2026-08-27 | `main` 최신화 후 `feat/phase-1-web-search-detail` 착수 | From 3b1f7e354929b70e738d05fd2bf46e1c5922c6a6 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 29 Aug 2026 20:12:19 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor(shared):=20buildChartHtml?= =?UTF-8?q?=EC=9D=84=20shared=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - US TradingView / KR fallback HTML을 packages/shared로 공유 - 모바일은 @ticker-journal/shared import로 전환 - chart 단위 테스트를 shared Vitest로 이전 Co-authored-by: Cursor --- apps/mobile/app/ticker/[id].tsx | 3 +-- apps/mobile/jest.config.js | 4 +--- {apps/mobile/__tests__ => packages/shared/src}/chart.test.ts | 4 +++- {apps/mobile/lib => packages/shared/src}/chart.ts | 4 +--- packages/shared/src/index.ts | 1 + 5 files changed, 7 insertions(+), 9 deletions(-) rename {apps/mobile/__tests__ => packages/shared/src}/chart.test.ts (89%) rename {apps/mobile/lib => packages/shared/src}/chart.ts (93%) diff --git a/apps/mobile/app/ticker/[id].tsx b/apps/mobile/app/ticker/[id].tsx index e19ff9e..4f1d19d 100644 --- a/apps/mobile/app/ticker/[id].tsx +++ b/apps/mobile/app/ticker/[id].tsx @@ -1,4 +1,5 @@ import { + buildChartHtml, type CreateEntryInput, CreateEntrySchema, type Entry, @@ -10,10 +11,8 @@ import { Redirect, useFocusEffect, useLocalSearchParams } from 'expo-router'; import { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { WebView } from 'react-native-webview'; - import { createEntry, deleteEntry, getTicker, listEntries } from '../../lib/api'; import { useAuth } from '../../lib/auth'; -import { buildChartHtml } from '../../lib/chart'; const FILTERS = TimelineFilterSchema.options; diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index a0dff79..c1fb3c0 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -1,5 +1,6 @@ module.exports = { preset: 'jest-expo', + passWithNoTests: true, setupFilesAfterEnv: ['/jest.setup.ts'], testMatch: ['**/__tests__/**/*.(spec|test).[jt]s?(x)'], moduleNameMapper: { @@ -8,7 +9,4 @@ module.exports = { transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-native-webview|@ticker-journal/shared)', ], - collectCoverageFrom: ['lib/chart.ts'], - coverageDirectory: 'coverage', - coverageReporters: ['text', 'text-summary', 'json-summary', 'html'], }; diff --git a/apps/mobile/__tests__/chart.test.ts b/packages/shared/src/chart.test.ts similarity index 89% rename from apps/mobile/__tests__/chart.test.ts rename to packages/shared/src/chart.test.ts index ad9a212..8ec7145 100644 --- a/apps/mobile/__tests__/chart.test.ts +++ b/packages/shared/src/chart.test.ts @@ -1,4 +1,6 @@ -import { buildChartHtml } from '../lib/chart'; +import { describe, expect, it } from 'vitest'; + +import { buildChartHtml } from './chart'; describe('buildChartHtml', () => { it('US 심볼에 TradingView 위젯을 넣는다', () => { diff --git a/apps/mobile/lib/chart.ts b/packages/shared/src/chart.ts similarity index 93% rename from apps/mobile/lib/chart.ts rename to packages/shared/src/chart.ts index fd777ae..1ef6165 100644 --- a/apps/mobile/lib/chart.ts +++ b/packages/shared/src/chart.ts @@ -1,6 +1,4 @@ -import type { Market } from '@ticker-journal/shared'; - -export const buildChartHtml = (market: Market, symbol: string): string => { +export const buildChartHtml = (market: 'US' | 'KR', symbol: string): string => { if (market === 'KR') { return ` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 055f2d0..6d8f047 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -98,4 +98,5 @@ export type TimelineFilter = z.infer; export const APP_NAME = 'Ticker Journal'; +export { buildChartHtml } from './chart'; export type { Database, EntryInsert, EntryRow, TickerInsert, TickerRow } from './database-exports'; From f9369008bb6387b0b41993a15b258c8566b67000 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 29 Aug 2026 20:12:27 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(web):=20entries=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=C2=B7=EC=A2=85=EB=AA=A9=20=EC=83=81=EC=84=B8=C2=B7=EC=97=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /search?q= ILIKE 검색(페이지 20)과 종목 상세 링크 - /ticker/[id] TradingView iframe·타임라인 필터·memo/link/trade CRUD - 홈에 검색 폼과 관심종목 상세 Link 추가 Co-authored-by: Cursor --- apps/web/src/app/search/page.tsx | 41 +++ apps/web/src/app/ticker/[id]/actions.ts | 76 ++++++ apps/web/src/app/ticker/[id]/page.tsx | 40 +++ apps/web/src/components/home-view.tsx | 83 +++--- apps/web/src/components/search-view.tsx | 108 ++++++++ .../web/src/components/ticker-detail-view.tsx | 219 ++++++++++++++++ apps/web/src/lib/entries.ts | 240 ++++++++++++++++++ apps/web/src/lib/entry-format.ts | 13 + apps/web/src/lib/search-query.ts | 59 +++++ apps/web/src/lib/timeline-filter.ts | 6 + 10 files changed, 856 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/search/page.tsx create mode 100644 apps/web/src/app/ticker/[id]/actions.ts create mode 100644 apps/web/src/app/ticker/[id]/page.tsx create mode 100644 apps/web/src/components/search-view.tsx create mode 100644 apps/web/src/components/ticker-detail-view.tsx create mode 100644 apps/web/src/lib/entries.ts create mode 100644 apps/web/src/lib/entry-format.ts create mode 100644 apps/web/src/lib/search-query.ts create mode 100644 apps/web/src/lib/timeline-filter.ts diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/search/page.tsx new file mode 100644 index 0000000..ae2478c --- /dev/null +++ b/apps/web/src/app/search/page.tsx @@ -0,0 +1,41 @@ +import { redirect } from 'next/navigation'; + +import { SearchView } from '@/components/search-view'; +import { searchEntries } from '@/lib/entries'; +import { getSupabaseEnv } from '@/lib/supabase/env'; +import { createClient } from '@/lib/supabase/server'; + +type SearchPageProps = { + searchParams: Promise<{ q?: string; page?: string }>; +}; + +export default async function SearchPage({ searchParams }: SearchPageProps) { + const { configured } = getSupabaseEnv(); + if (!configured) { + redirect('/'); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect('/login'); + } + + const params = await searchParams; + const query = params.q ?? ''; + const result = await searchEntries(supabase, query, params.page); + + return ( + + ); +} diff --git a/apps/web/src/app/ticker/[id]/actions.ts b/apps/web/src/app/ticker/[id]/actions.ts new file mode 100644 index 0000000..f72dc21 --- /dev/null +++ b/apps/web/src/app/ticker/[id]/actions.ts @@ -0,0 +1,76 @@ +'use server'; + +import { CreateEntrySchema } from '@ticker-journal/shared'; +import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; + +import { createEntryRecord, deleteEntryRecord } from '@/lib/entries'; +import { createClient } from '@/lib/supabase/server'; + +export type EntryActionState = { error: string | null }; + +export const createEntry = async (_prev: EntryActionState, formData: FormData): Promise => { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { error: '로그인이 필요합니다.' }; + + const tickerId = String(formData.get('ticker_id') ?? ''); + const entryType = String(formData.get('entry_type') ?? 'memo'); + + try { + if (entryType === 'memo') { + const input = CreateEntrySchema.parse({ + type: 'memo', + ticker_id: tickerId, + body: String(formData.get('body') ?? ''), + }); + await createEntryRecord(supabase, user.id, input); + } else if (entryType === 'link') { + const title = String(formData.get('title') ?? '').trim(); + const note = String(formData.get('note') ?? '').trim(); + const input = CreateEntrySchema.parse({ + type: 'link', + ticker_id: tickerId, + url: String(formData.get('url') ?? ''), + title: title ? title : null, + note: note ? note : null, + }); + await createEntryRecord(supabase, user.id, input); + } else { + const reason = String(formData.get('reason') ?? '').trim(); + const input = CreateEntrySchema.parse({ + type: 'trade', + ticker_id: tickerId, + side: String(formData.get('side') ?? 'buy'), + traded_at: new Date().toISOString(), + reason: reason ? reason : null, + }); + await createEntryRecord(supabase, user.id, input); + } + revalidatePath(`/ticker/${tickerId}`); + redirect(`/ticker/${tickerId}`); + } catch (err) { + return { error: err instanceof Error ? err.message : '엔트리를 저장하지 못했습니다.' }; + } +}; + +export const deleteEntry = async (formData: FormData): Promise => { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return; + + const entryId = String(formData.get('entry_id') ?? ''); + const tickerId = String(formData.get('ticker_id') ?? ''); + if (!entryId || !tickerId) return; + + try { + await deleteEntryRecord(supabase, entryId); + revalidatePath(`/ticker/${tickerId}`); + } catch (err) { + console.error('delete entry failed:', err); + } +}; diff --git a/apps/web/src/app/ticker/[id]/page.tsx b/apps/web/src/app/ticker/[id]/page.tsx new file mode 100644 index 0000000..4822237 --- /dev/null +++ b/apps/web/src/app/ticker/[id]/page.tsx @@ -0,0 +1,40 @@ +import { notFound, redirect } from 'next/navigation'; +import { TickerDetailView } from '@/components/ticker-detail-view'; +import { getTickerById, listEntriesForTicker } from '@/lib/entries'; +import { getSupabaseEnv } from '@/lib/supabase/env'; +import { createClient } from '@/lib/supabase/server'; +import { parseTimelineFilter } from '@/lib/timeline-filter'; + +type TickerPageProps = { + params: Promise<{ id: string }>; + searchParams: Promise<{ filter?: string }>; +}; + +export default async function TickerPage({ params, searchParams }: TickerPageProps) { + const { configured } = getSupabaseEnv(); + if (!configured) { + redirect('/'); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect('/login'); + } + + const { id } = await params; + const { filter: filterRaw } = await searchParams; + const filter = parseTimelineFilter(filterRaw); + + const ticker = await getTickerById(supabase, id); + if (!ticker) { + notFound(); + } + + const entries = await listEntriesForTicker(supabase, id, filter); + + return ; +} diff --git a/apps/web/src/components/home-view.tsx b/apps/web/src/components/home-view.tsx index ee8a251..768f363 100644 --- a/apps/web/src/components/home-view.tsx +++ b/apps/web/src/components/home-view.tsx @@ -40,7 +40,8 @@ export function HomeView({ email = null, tickers = [], configured = false, loadE

- 모바일에서 넣은 종목 타임라인을 같은 Supabase 계정으로 확인합니다. Phase 1에서 검색·상세를 확장합니다. + 모바일에서 넣은 종목 타임라인을 같은 Supabase 계정으로 확인합니다. 검색으로 기록을 찾고 종목 상세에서 + 차트·CRUD를 사용하세요.

{!configured ? ( @@ -50,41 +51,65 @@ export function HomeView({ email = null, tickers = [], configured = false, loadE ) : null} {email ? ( -
-

- 로그인: {email} -

-

관심종목

- {loadError ? ( -
-

관심종목을 불러오지 못했습니다. 잠시 후 다시 시도하세요.

-

{loadError}

-
- ) : tickers.length === 0 ? ( -

- 아직 종목이 없습니다. 모바일 앱에서 먼저 추가하세요. + <> +

+ + +
+ +
+

+ 로그인: {email}

- ) : ( -
    - {tickers.map((ticker) => ( -
  • -

    - {ticker.symbol}{' '} - {ticker.market} -

    -

    {ticker.name ?? '이름 없음'}

    -
  • - ))} -
- )} -
+

관심종목

+ {loadError ? ( +
+

관심종목을 불러오지 못했습니다. 잠시 후 다시 시도하세요.

+

{loadError}

+
+ ) : tickers.length === 0 ? ( +

+ 아직 종목이 없습니다. 모바일 앱에서 먼저 추가하세요. +

+ ) : ( +
    + {tickers.map((ticker) => ( +
  • + +

    + {ticker.symbol}{' '} + + {ticker.market} + +

    +

    {ticker.name ?? '이름 없음'}

    + +
  • + ))} +
+ )} +
+ ) : (

시작

  • 매직링크 로그인
  • 모바일과 동일 계정으로 관심종목 조회
  • -
  • Phase 1: entries 검색 · 종목 상세
  • +
  • entries 검색 · 종목 상세 (차트 + 타임라인 CRUD)
)} diff --git a/apps/web/src/components/search-view.tsx b/apps/web/src/components/search-view.tsx new file mode 100644 index 0000000..1f23399 --- /dev/null +++ b/apps/web/src/components/search-view.tsx @@ -0,0 +1,108 @@ +import { APP_NAME } from '@ticker-journal/shared'; +import Link from 'next/link'; + +import type { SearchEntryHit } from '@/lib/search-query'; + +type SearchViewProps = { + query: string; + hits: SearchEntryHit[]; + page: number; + hasMore: boolean; + loadError?: string | null; + email?: string | null; +}; + +export function SearchView({ query, hits, page, hasMore, loadError = null, email = null }: SearchViewProps) { + const trimmed = query.trim(); + const prevPage = page > 1 ? page - 1 : null; + const nextPage = hasMore ? page + 1 : null; + + return ( +
+
+
+ + ← {APP_NAME} + +

아카이브 검색

+ {email ?

로그인: {email}

: null} +
+ +
+ + +
+ + {!trimmed ? ( +

+ 검색어를 입력하면 타임라인 기록을 찾습니다. +

+ ) : loadError ? ( +
+

검색에 실패했습니다.

+

{loadError}

+
+ ) : hits.length === 0 ? ( +

+ 「{trimmed}」에 맞는 기록이 없습니다. +

+ ) : ( +
    + {hits.map((hit) => ( +
  • + +

    + {hit.type} · {hit.ticker_symbol}{' '} + ({hit.ticker_market}) +

    +

    {hit.ticker_name ?? hit.ticker_symbol}

    +

    {hit.preview}

    + +
  • + ))} +
+ )} + + {trimmed && !loadError && (prevPage || nextPage) ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/ticker-detail-view.tsx b/apps/web/src/components/ticker-detail-view.tsx new file mode 100644 index 0000000..51016ae --- /dev/null +++ b/apps/web/src/components/ticker-detail-view.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { buildChartHtml, type Entry, type Ticker, TimelineFilterSchema } from '@ticker-journal/shared'; +import Link from 'next/link'; +import { useActionState, useMemo, useState } from 'react'; +import { useFormStatus } from 'react-dom'; + +import { createEntry, deleteEntry, type EntryActionState } from '@/app/ticker/[id]/actions'; +import { formatEntryPreview } from '@/lib/entry-format'; + +const FILTERS = TimelineFilterSchema.options; +const ENTRY_TYPES = ['memo', 'link', 'trade'] as const; +const INITIAL: EntryActionState = { error: null }; + +type TickerDetailViewProps = { + ticker: Ticker; + entries: Entry[]; + filter: (typeof FILTERS)[number]; +}; + +function SaveButton() { + const { pending } = useFormStatus(); + return ( + + ); +} + +export function TickerDetailView({ ticker, entries, filter }: TickerDetailViewProps) { + const chartHtml = useMemo(() => buildChartHtml(ticker.market, ticker.symbol), [ticker.market, ticker.symbol]); + const [modalOpen, setModalOpen] = useState(false); + const [entryType, setEntryType] = useState<(typeof ENTRY_TYPES)[number]>('memo'); + const [state, formAction] = useActionState(createEntry, INITIAL); + + const closeModal = () => { + setModalOpen(false); + setEntryType('memo'); + }; + + return ( +
+
+ + ← 홈 + + +
+

{ticker.symbol}

+

+ {ticker.name ?? '이름 없음'} · {ticker.market} +

+
+ +
+