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}
- ) : (
-
- )}
-
+ 관심종목
+ {loadError ? (
+
+
관심종목을 불러오지 못했습니다. 잠시 후 다시 시도하세요.
+
{loadError}
+
+ ) : tickers.length === 0 ? (
+
+ 아직 종목이 없습니다. 모바일 앱에서 먼저 추가하세요.
+
+ ) : (
+
+ )}
+
+ >
) : (
시작
- 매직링크 로그인
- 모바일과 동일 계정으로 관심종목 조회
- - 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}」에 맞는 기록이 없습니다.
+
+ ) : (
+
+ )}
+
+ {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 (
+
+
+
+ ← 홈
+
+
+
+
+
+
+
+
+
+
+ {entries.length === 0 ? (
+
+ 타임라인이 비어 있습니다.
+
+ ) : (
+
+ {entries.map((entry) => (
+ -
+
+
+
{entry.type}
+
+ {formatEntryPreview(entry)}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {modalOpen ? (
+
+
+
엔트리 추가
+
+
+ {ENTRY_TYPES.map((type) => (
+
+ ))}
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/lib/entries.ts b/apps/web/src/lib/entries.ts
new file mode 100644
index 0000000..8956ce4
--- /dev/null
+++ b/apps/web/src/lib/entries.ts
@@ -0,0 +1,240 @@
+import type { SupabaseClient } from '@supabase/supabase-js';
+import {
+ type CreateEntryInput,
+ CreateEntrySchema,
+ type Database,
+ type Entry,
+ type EntryInsert,
+ type EntryRow,
+ EntrySchema,
+ type Ticker,
+ TickerSchema,
+ type TimelineFilter,
+} from '@ticker-journal/shared';
+
+import { formatEntryPreview } from '@/lib/entry-format';
+import {
+ buildEntryTextOrFilter,
+ buildTickerOrFilter,
+ mergeSearchHits,
+ parseSearchPage,
+ SEARCH_PAGE_SIZE,
+ type SearchEntryHit,
+ toIlikePattern,
+} from '@/lib/search-query';
+
+export const normalizeEntryRow = (row: EntryRow): Entry => {
+ if (row.type === 'memo') {
+ return {
+ id: row.id,
+ user_id: row.user_id,
+ ticker_id: row.ticker_id,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ type: 'memo',
+ body: row.body ?? '',
+ };
+ }
+ if (row.type === 'link') {
+ return {
+ id: row.id,
+ user_id: row.user_id,
+ ticker_id: row.ticker_id,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ type: 'link',
+ url: row.url ?? '',
+ title: row.title,
+ note: row.note,
+ };
+ }
+ if (row.type !== 'trade') {
+ throw new Error(`알 수 없는 엔트리 타입: ${String(row.type)}`);
+ }
+ return {
+ id: row.id,
+ user_id: row.user_id,
+ ticker_id: row.ticker_id,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ type: 'trade',
+ side: row.side === 'sell' ? 'sell' : 'buy',
+ traded_at: row.traded_at ?? '',
+ price: row.price,
+ qty: row.qty,
+ reason: row.reason,
+ };
+};
+
+type EntryWithTickerRow = EntryRow & {
+ tickers: { id: string; symbol: string; name: string | null; market: string } | null;
+};
+
+const rowToSearchHit = (row: EntryWithTickerRow): SearchEntryHit | null => {
+ const ticker = row.tickers;
+ if (!ticker) return null;
+ let entry: Entry;
+ try {
+ entry = EntrySchema.parse(normalizeEntryRow(row));
+ } catch {
+ return null;
+ }
+ return {
+ id: entry.id,
+ ticker_id: entry.ticker_id,
+ type: entry.type,
+ created_at: entry.created_at,
+ preview: formatEntryPreview(entry),
+ ticker_symbol: ticker.symbol,
+ ticker_name: ticker.name,
+ ticker_market: ticker.market,
+ };
+};
+
+export const getTickerById = async (supabase: SupabaseClient, id: string): Promise => {
+ const { data, error } = await supabase.from('tickers').select('*').eq('id', id).maybeSingle();
+ if (error) throw error;
+ if (!data) return null;
+ const parsed = TickerSchema.safeParse(data);
+ return parsed.success ? parsed.data : null;
+};
+
+export const listEntriesForTicker = async (
+ supabase: SupabaseClient,
+ tickerId: string,
+ filter: TimelineFilter = 'all',
+): Promise => {
+ let query = supabase.from('entries').select('*').eq('ticker_id', tickerId).order('created_at', { ascending: false });
+ if (filter !== 'all') {
+ query = query.eq('type', filter);
+ }
+ const { data, error } = await query;
+ if (error) throw error;
+ return (data ?? []).flatMap((row) => {
+ try {
+ return [EntrySchema.parse(normalizeEntryRow(row))];
+ } catch {
+ return [];
+ }
+ });
+};
+
+export type SearchEntriesResult = {
+ hits: SearchEntryHit[];
+ page: number;
+ pageSize: number;
+ hasMore: boolean;
+ loadError: string | null;
+};
+
+export const searchEntries = async (
+ supabase: SupabaseClient,
+ query: string,
+ pageRaw: string | string[] | undefined,
+): Promise => {
+ const page = parseSearchPage(pageRaw);
+ const pattern = toIlikePattern(query);
+ if (!pattern) {
+ return { hits: [], page: 1, pageSize: SEARCH_PAGE_SIZE, hasMore: false, loadError: null };
+ }
+
+ const fetchLimit = page * SEARCH_PAGE_SIZE;
+ const entryOr = buildEntryTextOrFilter(pattern);
+ const tickerOr = buildTickerOrFilter(pattern);
+
+ try {
+ const [byText, matchingTickers] = await Promise.all([
+ supabase
+ .from('entries')
+ .select('*, tickers(id, symbol, name, market)')
+ .or(entryOr)
+ .order('created_at', { ascending: false })
+ .limit(fetchLimit),
+ supabase.from('tickers').select('id').or(tickerOr).limit(fetchLimit),
+ ]);
+
+ if (byText.error) throw byText.error;
+ if (matchingTickers.error) throw matchingTickers.error;
+
+ const tickerIds = (matchingTickers.data ?? []).map((row) => row.id);
+ let byTicker: EntryWithTickerRow[] = [];
+ if (tickerIds.length > 0) {
+ const { data, error } = await supabase
+ .from('entries')
+ .select('*, tickers(id, symbol, name, market)')
+ .in('ticker_id', tickerIds)
+ .order('created_at', { ascending: false })
+ .limit(fetchLimit);
+ if (error) throw error;
+ byTicker = (data ?? []) as EntryWithTickerRow[];
+ }
+
+ const mergedRows = [...((byText.data ?? []) as EntryWithTickerRow[]), ...byTicker];
+ const allHits = mergedRows.flatMap((row) => {
+ const hit = rowToSearchHit(row);
+ return hit ? [hit] : [];
+ });
+ const hits = mergeSearchHits(allHits, page);
+ const totalUnique = new Map(allHits.map((h) => [h.id, h])).size;
+ const hasMore = totalUnique > page * SEARCH_PAGE_SIZE;
+
+ return { hits, page, pageSize: SEARCH_PAGE_SIZE, hasMore, loadError: null };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : '검색에 실패했습니다.';
+ return { hits: [], page, pageSize: SEARCH_PAGE_SIZE, hasMore: false, loadError: message };
+ }
+};
+
+export const toInsertPayload = (parsed: CreateEntryInput, userId: string): EntryInsert => {
+ const base: EntryInsert = {
+ user_id: userId,
+ ticker_id: parsed.ticker_id,
+ type: parsed.type,
+ body: null,
+ url: null,
+ title: null,
+ note: null,
+ side: null,
+ traded_at: null,
+ price: null,
+ qty: null,
+ reason: null,
+ };
+
+ if (parsed.type === 'memo') {
+ return { ...base, body: parsed.body };
+ }
+ if (parsed.type === 'link') {
+ return {
+ ...base,
+ url: parsed.url,
+ title: parsed.title ?? null,
+ note: parsed.note ?? null,
+ };
+ }
+ return {
+ ...base,
+ side: parsed.side,
+ traded_at: parsed.traded_at,
+ price: parsed.price ?? null,
+ qty: parsed.qty ?? null,
+ reason: parsed.reason ?? null,
+ };
+};
+
+export const createEntryRecord = async (
+ supabase: SupabaseClient,
+ userId: string,
+ input: CreateEntryInput,
+): Promise => {
+ const parsed = CreateEntrySchema.parse(input);
+ const payload = toInsertPayload(parsed, userId);
+ const { data, error } = await supabase.from('entries').insert(payload).select('*').single();
+ if (error) throw error;
+ return EntrySchema.parse(normalizeEntryRow(data));
+};
+
+export const deleteEntryRecord = async (supabase: SupabaseClient, entryId: string): Promise => {
+ const { error } = await supabase.from('entries').delete().eq('id', entryId);
+ if (error) throw error;
+};
diff --git a/apps/web/src/lib/entry-format.ts b/apps/web/src/lib/entry-format.ts
new file mode 100644
index 0000000..6bf8be0
--- /dev/null
+++ b/apps/web/src/lib/entry-format.ts
@@ -0,0 +1,13 @@
+import type { Entry } from '@ticker-journal/shared';
+
+export const formatTradedAt = (value: string): string => {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleString('ko-KR');
+};
+
+export const formatEntryPreview = (entry: Entry): string => {
+ if (entry.type === 'memo') return entry.body;
+ if (entry.type === 'link') return `${entry.title ?? entry.url}\n${entry.url}`;
+ return `${entry.side.toUpperCase()} · ${formatTradedAt(entry.traded_at)}${entry.reason ? `\n${entry.reason}` : ''}`;
+};
diff --git a/apps/web/src/lib/search-query.ts b/apps/web/src/lib/search-query.ts
new file mode 100644
index 0000000..293b838
--- /dev/null
+++ b/apps/web/src/lib/search-query.ts
@@ -0,0 +1,59 @@
+export const SEARCH_PAGE_SIZE = 20;
+
+/** ILIKE 와일드카드(% _)와 백슬래시를 이스케이프한다. */
+export const escapeIlikePattern = (raw: string): string =>
+ raw.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
+
+/** trim 후 비어 있으면 null, 아니면 %…% 패턴. */
+export const toIlikePattern = (query: string): string | null => {
+ const trimmed = query.trim();
+ if (!trimmed) return null;
+ return `%${escapeIlikePattern(trimmed)}%`;
+};
+
+/** PostgREST `.or()` 필터 문자열 (entries 텍스트 컬럼). */
+export const buildEntryTextOrFilter = (pattern: string): string =>
+ [
+ `body.ilike.${pattern}`,
+ `note.ilike.${pattern}`,
+ `reason.ilike.${pattern}`,
+ `title.ilike.${pattern}`,
+ `url.ilike.${pattern}`,
+ ].join(',');
+
+/** PostgREST `.or()` 필터 문자열 (tickers symbol/name). */
+export const buildTickerOrFilter = (pattern: string): string =>
+ [`symbol.ilike.${pattern}`, `name.ilike.${pattern}`].join(',');
+
+export type SearchEntryHit = {
+ id: string;
+ ticker_id: string;
+ type: string;
+ created_at: string;
+ preview: string;
+ ticker_symbol: string;
+ ticker_name: string | null;
+ ticker_market: string;
+};
+
+/** id 기준 dedupe 후 created_at 내림차순, 페이지 슬라이스. */
+export const mergeSearchHits = (
+ rows: SearchEntryHit[],
+ page: number,
+ pageSize = SEARCH_PAGE_SIZE,
+): SearchEntryHit[] => {
+ const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
+ const byId = new Map();
+ for (const row of rows) {
+ byId.set(row.id, row);
+ }
+ const sorted = [...byId.values()].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+ const start = (safePage - 1) * pageSize;
+ return sorted.slice(start, start + pageSize);
+};
+
+export const parseSearchPage = (raw: string | string[] | undefined): number => {
+ const value = Array.isArray(raw) ? raw[0] : raw;
+ const parsed = Number.parseInt(value ?? '1', 10);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
+};
diff --git a/apps/web/src/lib/timeline-filter.ts b/apps/web/src/lib/timeline-filter.ts
new file mode 100644
index 0000000..acc868a
--- /dev/null
+++ b/apps/web/src/lib/timeline-filter.ts
@@ -0,0 +1,6 @@
+import { TimelineFilterSchema } from '@ticker-journal/shared';
+
+export const parseTimelineFilter = (raw: string | undefined) => {
+ const parsed = TimelineFilterSchema.safeParse(raw ?? 'all');
+ return parsed.success ? parsed.data : 'all';
+};
From 8f1bebce39bec4ce841bc10a5e08b7556a70c574 Mon Sep 17 00:00:00 2001
From: scs0209
Date: Sat, 29 Aug 2026 20:12:32 +0900
Subject: [PATCH 04/10] =?UTF-8?q?docs:=20Phase=201=20=EA=B5=AC=ED=98=84?=
=?UTF-8?q?=C2=B7=ED=85=8C=EC=8A=A4=ED=8A=B8=20flaky=20=EA=B8=88=EC=A7=80?=
=?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=EB=B0=98=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- portfolio/resume에 웹 검색·상세 마일스톤 기록
- testing.md에 locale·OR assert 금지 추가
Co-authored-by: Cursor
---
apps/web/e2e/search.spec.ts | 6 +++
apps/web/src/components/home-view.test.tsx | 4 ++
apps/web/src/components/search-view.test.tsx | 12 +++++
apps/web/src/lib/search-query.test.ts | 53 ++++++++++++++++++++
apps/web/vitest.config.ts | 7 ++-
docs/portfolio.md | 33 +++++++++---
docs/resume-bullets.md | 8 +--
docs/testing.md | 18 ++++---
8 files changed, 122 insertions(+), 19 deletions(-)
create mode 100644 apps/web/e2e/search.spec.ts
create mode 100644 apps/web/src/components/search-view.test.tsx
create mode 100644 apps/web/src/lib/search-query.test.ts
diff --git a/apps/web/e2e/search.spec.ts b/apps/web/e2e/search.spec.ts
new file mode 100644
index 0000000..ad5bd66
--- /dev/null
+++ b/apps/web/e2e/search.spec.ts
@@ -0,0 +1,6 @@
+import { expect, test } from '@playwright/test';
+
+test('검색 페이지는 로그인으로 리다이렉트한다', async ({ page }) => {
+ await page.goto('/search?q=test');
+ await expect(page).toHaveURL(/\/login/);
+});
diff --git a/apps/web/src/components/home-view.test.tsx b/apps/web/src/components/home-view.test.tsx
index f2e021a..162d1dd 100644
--- a/apps/web/src/components/home-view.test.tsx
+++ b/apps/web/src/components/home-view.test.tsx
@@ -25,6 +25,10 @@ describe('HomeView', () => {
/>,
);
expect(screen.getByText(/AAPL/)).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /AAPL/i })).toHaveAttribute(
+ 'href',
+ '/ticker/11111111-1111-4111-8111-111111111111',
+ );
expect(screen.getByText('you@example.com')).toBeInTheDocument();
});
diff --git a/apps/web/src/components/search-view.test.tsx b/apps/web/src/components/search-view.test.tsx
new file mode 100644
index 0000000..9af7b6a
--- /dev/null
+++ b/apps/web/src/components/search-view.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { SearchView } from '@/components/search-view';
+
+describe('SearchView', () => {
+ it('조회 실패를 빈 결과와 구분한다', () => {
+ render();
+ expect(screen.getByText(/검색에 실패했습니다/)).toBeInTheDocument();
+ expect(screen.queryByText(/에 맞는 기록이 없습니다/)).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/lib/search-query.test.ts b/apps/web/src/lib/search-query.test.ts
new file mode 100644
index 0000000..a0237cd
--- /dev/null
+++ b/apps/web/src/lib/search-query.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+
+import { escapeIlikePattern, mergeSearchHits, type SearchEntryHit, toIlikePattern } from '@/lib/search-query';
+
+describe('toIlikePattern', () => {
+ it('공백만 있으면 null을 반환한다', () => {
+ expect(toIlikePattern(' ')).toBeNull();
+ });
+
+ it('와일드카드를 이스케이프한다', () => {
+ expect(toIlikePattern('100%')).toBe('%100\\%%');
+ });
+});
+
+describe('escapeIlikePattern', () => {
+ it('백슬래시와 _를 이스케이프한다', () => {
+ expect(escapeIlikePattern('a\\b_c')).toBe('a\\\\b\\_c');
+ });
+});
+
+describe('mergeSearchHits', () => {
+ const hit = (id: string, createdAt: string): SearchEntryHit => ({
+ id,
+ ticker_id: '22222222-2222-4222-8222-222222222222',
+ type: 'memo',
+ created_at: createdAt,
+ preview: 'preview',
+ ticker_symbol: 'AAPL',
+ ticker_name: 'Apple',
+ ticker_market: 'US',
+ });
+
+ it('id 기준 dedupe 후 최신순으로 정렬한다', () => {
+ const merged = mergeSearchHits(
+ [
+ hit('a', '2026-01-01T00:00:00.000Z'),
+ hit('a', '2026-01-01T00:00:00.000Z'),
+ hit('b', '2026-02-01T00:00:00.000Z'),
+ ],
+ 1,
+ 20,
+ );
+ expect(merged.map((row) => row.id)).toEqual(['b', 'a']);
+ });
+
+ it('페이지 슬라이스를 적용한다', () => {
+ const rows = Array.from({ length: 25 }, (_, i) =>
+ hit(`id-${i}`, `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00.000Z`),
+ );
+ const page2 = mergeSearchHits(rows, 2, 20);
+ expect(page2).toHaveLength(5);
+ });
+});
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index b0d3612..6dc8a1f 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -12,7 +12,12 @@ export default defineConfig({
provider: 'v8',
reporter: ['text', 'text-summary', 'json-summary', 'html'],
reportsDirectory: './coverage',
- include: ['src/app/auth/callback/redirect.ts', 'src/components/home-view.tsx'],
+ include: [
+ 'src/app/auth/callback/redirect.ts',
+ 'src/components/home-view.tsx',
+ 'src/components/search-view.tsx',
+ 'src/lib/search-query.ts',
+ ],
exclude: ['src/**/*.test.*'],
all: true,
},
diff --git a/docs/portfolio.md b/docs/portfolio.md
index 3489730..be7cbfb 100644
--- a/docs/portfolio.md
+++ b/docs/portfolio.md
@@ -10,7 +10,7 @@
| 스택 | Expo (React Native), Expo Router, Next.js 16, TypeScript 7, Zod, pnpm monorepo, Turborepo, Biome, WebView, Vitest, RTL, jest-expo, Playwright, Supabase (Auth + Postgres + RLS), (예정) EAS Submit |
| 레포 | https://github.com/scs0209/ticker-journal |
| 설계 문서 | `docs/design.md` · `docs/architecture.md` |
-| 현재 브랜치 | `feat/phase-0-auth-crud` |
+| 현재 브랜치 | `feat/phase-1-web-search-detail` |
---
@@ -39,8 +39,8 @@ flowchart LR
B --> D[타임라인
memo / link / trade]
D -->|FAB 추가| E[엔트리 작성]
E --> D
- D -.동일 계정.-> F[웹 로그인·관심종목 조회]
- F --> B
+ D -.동일 계정.-> F[웹 검색·아카이브]
+ F -->|종목 링크| G[웹 종목 상세
차트 + CRUD]
```
### 0.3 시스템 아키텍처
@@ -179,7 +179,22 @@ flowchart TB
- `@supabase/ssr` + Next 16 `proxy.ts` 세션 갱신
- `/login` 매직링크, `/auth/callback` 코드 교환
-- 홈에서 동일 계정 관심종목 조회 (검색·상세는 Phase 1)
+- 홈에서 동일 계정 관심종목 조회
+- `/search?q=` entries ILIKE 검색 (페이지 20) + 종목 상세 링크
+- `/ticker/[id]` TradingView iframe + 타임라인 필터 + 엔트리 CRUD (server actions)
+
+### 1.9 Phase 1 — 웹 검색·상세 (2026-08-29)
+
+**공유**
+
+- `buildChartHtml`을 `packages/shared`로 이동 (앱·웹 동일 차트 HTML)
+
+**웹**
+
+- `/search`: `entries` 텍스트 필드 + ticker `symbol`/`name` ILIKE, merge·dedupe·페이지 20
+- `/ticker/[id]`: US TradingView iframe / KR fallback, 필터 칩, memo/link/trade 추가·삭제
+- 홈: 검색 폼 + 관심종목 → 상세 Link
+- 단위: `search-query`, `entry-format`, `SearchView` RTL; E2E: `/search` 비로그인 → `/login`
### 1.7 테스트 · CI (2026-08-15)
@@ -191,7 +206,7 @@ flowchart TB
- [x] Phase 0 구현: Supabase Auth + 모바일 CRUD + 웹 세션/목록 (`feat/phase-0-auth-crud`)
- [x] Phase 0 실계정 스모크: 앱에서 종목·entry 생성 후 웹에서 동일 관심종목 목록 확인
-- [ ] Phase 1: 웹 entries 검색·종목 상세
+- [ ] Phase 1: 웹 entries 검색·종목 상세 (구현 완료, 실계정 스모크 대기)
- [ ] Phase 2: EAS → App Store / Play Store
- [ ] v1.1 공유 시트 / v2 AI 브리핑
@@ -213,6 +228,7 @@ flowchart TB
| Auth | `detectSessionInUrl: false`면 딥링크만으로는 세션이 안 생김 | `Linking`으로 code 교환·토큰 `setSession` |
| CLI | `config.toml`을 JSON으로 두면 supabase CLI가 못 읽음 | 실제 TOML로 교체 |
| 차트 | HTML escape를 JS 문자열에 쓰면 쿼트 인젝션·이중 인코딩 | `JSON.stringify`로 위젯 심볼 삽입 |
+| Phase 1 | entries 텍스트 OR + ticker symbol/name OR를 PostgREST 한 쿼리로 못 묶음 | 두 쿼리 merge·dedupe 후 페이지 20 슬라이스 |
---
@@ -227,6 +243,8 @@ flowchart TB
- Next 16에서는 edge 세션 갱신을 `proxy` 컨벤션으로 맞추는 편이 경고·미래 호환에 유리하다.
- RN에서 매직링크는 redirect URL만 맞추는 게 아니라 **콜백 URL → `exchangeCodeForSession` / `setSession`** 까지 연결해야 한다.
- `@supabase/ssr`가 넘기는 no-cache 헤더를 응답에 안 붙이면 세션 쿠키가 CDN에 캐시될 수 있다.
+- `buildChartHtml`을 shared로 두면 WebView·iframe이 같은 HTML 계약을 공유한다.
+- `'use server'` 파일은 sync export가 불가 — 파서·헬퍼는 별도 lib로 분리한다.
---
@@ -241,10 +259,10 @@ Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임
- 문제 정의·MVP 3화면 와이어프레임·아키텍처/ADR 문서화
- pnpm/Turborepo 모노레포 (`mobile` / `web` / `shared`) + Biome + TypeScript 7
- Expo Router · Auth(이메일/비번 + 매직링크 + Google OAuth) · 관심종목/엔트리 CRUD · TradingView WebView
-- Next.js `@supabase/ssr` 로그인·콜백·관심종목 조회
+- Next.js `@supabase/ssr` 로그인·콜백·관심종목 조회 + entries 검색·종목 상세(CRUD)
- Postgres 스키마 + RLS, Zod 공유 스키마로 입력 검증
- Vitest / RTL / Playwright / jest-expo 테스트 계층 + GitHub Actions CI
-- (예정) entries 웹 검색, 스토어 2곳 배포
+- (예정) 스토어 2곳 배포
### 성과 / 임팩트
@@ -275,3 +293,4 @@ Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임
| 2026-08-25 | 중복 종목 Alert 문구, Sonda 번들 분석 스크립트 |
| 2026-08-25 | Phase 0 실계정 스모크 완료로 마감 |
| 2026-08-27 | `main` 최신화 후 `feat/phase-1-web-search-detail` 착수 |
+| 2026-08-29 | Phase 1: 웹 `/search`·`/ticker/[id]`, `buildChartHtml` shared 이동, 테스트 25건(web) |
diff --git a/docs/resume-bullets.md b/docs/resume-bullets.md
index 1749a60..762a550 100644
--- a/docs/resume-bullets.md
+++ b/docs/resume-bullets.md
@@ -53,10 +53,12 @@
- `[x]` 모노레포 테스트 **4계층** + 커버리지(`pnpm test:coverage`) + GitHub Actions(`check`/`typecheck`/`test`/E2E) + Biome + TypeScript 7으로 품질 게이트 유지
- `[x]` 본인 계정으로 관심종목 **3**개·entry 입력 후 웹에서 **동일 관심종목 목록** 수동 스모크 완료
-### Phase 1 (웹 검색·상세)
+### Phase 1 (웹 검색·상세) — 구현 완료, 스모크 대기
-- `[ ]` 웹 검색(페이지 20)으로 과거 메모 회수 **평균 __초** (이전: 노션/시트 수 분)
-- `[ ]` 종목 상세 + TradingView를 웹에도 제공해 앱·웹 **읽기 경로 대칭**
+- `[x]` 웹 `/search?q=` ILIKE 검색(페이지 20) + 결과 → 종목 상세 Link
+- `[x]` `/ticker/[id]` TradingView iframe(US) / KR fallback + 타임라인 필터 + 엔트리 CRUD
+- `[ ]` 앱에서 입력한 memo/link/trade를 웹 검색으로 회수하는 **실계정 스모크**
+- `[ ]` 웹 검색 회수 **평균 __초** (이전: 노션/시트 수 분) — 스모크 후 수치 기록
### Phase 2 (스토어 = 이력서 임팩트 피크)
diff --git a/docs/testing.md b/docs/testing.md
index b63b9ee..4db4fab 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -5,6 +5,7 @@
1. **깨지면 사용자가 알아채는가?** (스키마 변환, 잘못된 화면, 잘못된 리다이렉트)
2. **이미 위 레이어가 같은 동작을 보는가?** (E2E가 커버하면 컴포넌트/단위를 중복하지 않는다)
3. **성공 조건이 목 호출인가?** 그러면 잘못된 테스트다. 화면 텍스트, URL, 순수 함수 반환값만 assert한다.
+4. **환경마다 달라지는가?** `toLocaleString`·타임존·날짜 “지금”·랜덤·OR로 “뭐든 통과”하는 assert는 flaky/무의미 → 작성하지 않는다.
`describe`/`it`/`test` 설명은 **한국어**. 대상 이름(`CreateTickerSchema`, `HomeView`)만 영어 식별자를 쓴다.
@@ -14,8 +15,8 @@
| 레이어 | 대상 | 도구 | 하지 않는 것 |
|--------|------|------|----------------|
-| shared 단위 | Zod 스키마 정규화/거부 | Vitest | supabase/OTP를 목킹하고 `toHaveBeenCalled` |
-| mobile 단위 | `buildChartHtml` | jest-expo | expo-router/Auth를 목킹한 화면 테스트 |
+| shared 단위 | Zod 스키마, `buildChartHtml` | Vitest | supabase/OTP를 목킹하고 `toHaveBeenCalled` |
+| mobile 단위 | (Maestro E2E 예정) | jest-expo | expo-router/Auth를 목킹한 화면 테스트 |
| 웹 컴포넌트 | 웹 순수 뷰만 (`HomeView` props → 텍스트) | RTL | 라우터/Auth를 목킹한 화면 테스트 |
| 웹 E2E | 웹 사용자 플로우: 홈, 로그인 페이지 | Playwright | 매직링크 메일·실세션 (인박스 없음) |
@@ -23,11 +24,12 @@
## 지금 허용된 테스트
-- `packages/shared` — 스키마 정규화/거부
-- `apps/mobile/lib/chart.ts` — US 위젯 / KR fallback HTML
+- `packages/shared` — 스키마 정규화/거부, `buildChartHtml` (US 위젯 / KR fallback)
- `apps/web` `resolveAuthCallbackPath` — 콜백 성공/실패 경로 (웹 단위)
- `apps/web` `HomeView` — 로그인된 종목 표시, 조회 실패 메시지 (E2E에 세션 없음)
-- Playwright — `/`, `/login`이 뜨는지
+- `apps/web` `SearchView` — 검색 실패 vs 빈 결과 구분 (E2E에 세션 없음)
+- `apps/web` `search-query` — ILIKE escape, merge·페이지 (deterministic)
+- Playwright — `/`, `/login`, `/search` 비로그인 가드
모바일 화면(관심종목·상세)은 라우터 없이 마운트되지 않는다. 구현 mock으로 목록을 그리는 테스트는 하지 않고, Maestro E2E에서 다룬다.
@@ -48,9 +50,9 @@ pnpm run ci # Biome + typecheck + unit (pre-commit 훅과 동일)
| 패키지 | 포함 파일 | Lines (참고) |
|--------|-----------|--------------|
-| shared | `src/index.ts` (스키마) | ~100% |
-| web | `redirect.ts`, `HomeView` | ~81% |
-| mobile | `lib/chart.ts` | ~100% |
+| shared | `src/index.ts`, `src/chart.ts` | ~100% |
+| web | `redirect.ts`, `HomeView`, `SearchView`, `search-query` | 참고 |
+| mobile | (Maestro 예정) | — |
로컬: Husky `pre-commit`은 `pnpm run ci`만 실행한다 (`check` + `typecheck` + `test`).
GitHub Actions는 여기에 **`test:coverage` · `check:db-types`(local Supabase) · Playwright E2E**를 더 돌린다. 커밋이 통과해도 CI가 더 넓은 게이트다.
From 4752757e2ae33e8ca6e063810ba4c1a62bfc8ca5 Mon Sep 17 00:00:00 2001
From: scs0209
Date: Sat, 29 Aug 2026 21:45:17 +0900
Subject: [PATCH 05/10] =?UTF-8?q?fix(web):=20E2E=EC=97=90=20Supabase=20pla?=
=?UTF-8?q?ceholder=20env=EB=A5=BC=20=EA=B3=A0=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- CI에 env 없을 때 /search가 /login 대신 /로 가던 E2E 실패 수정
- Playwright webServer가 configured=true·세션 없음 상태를 재현
Co-authored-by: Cursor
---
apps/web/playwright.config.ts | 11 +++++++++++
docs/testing.md | 4 +++-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
index cc6a99f..84f7a01 100644
--- a/apps/web/playwright.config.ts
+++ b/apps/web/playwright.config.ts
@@ -1,5 +1,12 @@
import { defineConfig, devices } from '@playwright/test';
+/** E2E dev 서버용 — Auth 가드(/search → /login)를 세션 없이 재현. 실 Supabase 연결은 하지 않음. */
+const e2eSupabaseEnv = {
+ NEXT_PUBLIC_SUPABASE_URL: 'http://127.0.0.1:54321',
+ NEXT_PUBLIC_SUPABASE_ANON_KEY:
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0',
+};
+
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
@@ -14,6 +21,10 @@ export default defineConfig({
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
cwd: __dirname,
+ env: {
+ ...process.env,
+ ...e2eSupabaseEnv,
+ },
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
diff --git a/docs/testing.md b/docs/testing.md
index 4db4fab..adc4c5a 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -29,7 +29,9 @@
- `apps/web` `HomeView` — 로그인된 종목 표시, 조회 실패 메시지 (E2E에 세션 없음)
- `apps/web` `SearchView` — 검색 실패 vs 빈 결과 구분 (E2E에 세션 없음)
- `apps/web` `search-query` — ILIKE escape, merge·페이지 (deterministic)
-- Playwright — `/`, `/login`, `/search` 비로그인 가드
+- Playwright — `/`, `/login`, `/search` 비로그인 가드 (dev 서버에 Supabase placeholder env)
+
+Playwright `webServer`는 `NEXT_PUBLIC_SUPABASE_*` placeholder를 넣어 `configured=true`·세션 없음 상태를 만든다. CI에 실 Supabase/매직링크 세션은 없다.
모바일 화면(관심종목·상세)은 라우터 없이 마운트되지 않는다. 구현 mock으로 목록을 그리는 테스트는 하지 않고, Maestro E2E에서 다룬다.
From 0bcb24a931e4b82e9c0e1c1a7c948915e00789ed Mon Sep 17 00:00:00 2001
From: scs0209
Date: Sat, 29 Aug 2026 22:58:51 +0900
Subject: [PATCH 06/10] =?UTF-8?q?fix(web):=20=EA=B2=80=EC=83=89=20?=
=?UTF-8?q?=EC=BF=BC=EB=A6=AC=20quoting=C2=B7=EC=A0=95=EA=B7=9C=ED=99=94?=
=?UTF-8?q?=C2=B7=EC=95=88=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?=
=?UTF-8?q?=EC=9D=B4=EC=85=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- parseSearchQuery로 반복 q 배열 처리
- PostgREST double-quote·ILIKE escape
- merge 시 created_at·id 정렬, 소스별 fetch lookahead
Co-authored-by: Cursor
---
apps/web/src/app/search/page.tsx | 5 +-
apps/web/src/lib/entries.ts | 12 ++-
apps/web/src/lib/search-query.test.ts | 105 +++++++++++++++++++++++++-
apps/web/src/lib/search-query.ts | 52 ++++++++++---
4 files changed, 158 insertions(+), 16 deletions(-)
diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/search/page.tsx
index ae2478c..8524d0e 100644
--- a/apps/web/src/app/search/page.tsx
+++ b/apps/web/src/app/search/page.tsx
@@ -2,11 +2,12 @@ import { redirect } from 'next/navigation';
import { SearchView } from '@/components/search-view';
import { searchEntries } from '@/lib/entries';
+import { parseSearchQuery } from '@/lib/search-query';
import { getSupabaseEnv } from '@/lib/supabase/env';
import { createClient } from '@/lib/supabase/server';
type SearchPageProps = {
- searchParams: Promise<{ q?: string; page?: string }>;
+ searchParams: Promise<{ q?: string | string[]; page?: string | string[] }>;
};
export default async function SearchPage({ searchParams }: SearchPageProps) {
@@ -25,7 +26,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
}
const params = await searchParams;
- const query = params.q ?? '';
+ const query = parseSearchQuery(params.q);
const result = await searchEntries(supabase, query, params.page);
return (
diff --git a/apps/web/src/lib/entries.ts b/apps/web/src/lib/entries.ts
index 8956ce4..d00a187 100644
--- a/apps/web/src/lib/entries.ts
+++ b/apps/web/src/lib/entries.ts
@@ -20,6 +20,7 @@ import {
parseSearchPage,
SEARCH_PAGE_SIZE,
type SearchEntryHit,
+ searchSourceFetchLimit,
toIlikePattern,
} from '@/lib/search-query';
@@ -138,7 +139,7 @@ export const searchEntries = async (
return { hits: [], page: 1, pageSize: SEARCH_PAGE_SIZE, hasMore: false, loadError: null };
}
- const fetchLimit = page * SEARCH_PAGE_SIZE;
+ const fetchLimit = searchSourceFetchLimit(page);
const entryOr = buildEntryTextOrFilter(pattern);
const tickerOr = buildTickerOrFilter(pattern);
@@ -149,6 +150,7 @@ export const searchEntries = async (
.select('*, tickers(id, symbol, name, market)')
.or(entryOr)
.order('created_at', { ascending: false })
+ .order('id', { ascending: false })
.limit(fetchLimit),
supabase.from('tickers').select('id').or(tickerOr).limit(fetchLimit),
]);
@@ -164,6 +166,7 @@ export const searchEntries = async (
.select('*, tickers(id, symbol, name, market)')
.in('ticker_id', tickerIds)
.order('created_at', { ascending: false })
+ .order('id', { ascending: false })
.limit(fetchLimit);
if (error) throw error;
byTicker = (data ?? []) as EntryWithTickerRow[];
@@ -176,7 +179,12 @@ export const searchEntries = async (
});
const hits = mergeSearchHits(allHits, page);
const totalUnique = new Map(allHits.map((h) => [h.id, h])).size;
- const hasMore = totalUnique > page * SEARCH_PAGE_SIZE;
+ const pageEnd = page * SEARCH_PAGE_SIZE;
+ const sourceAtLimit =
+ (byText.data?.length ?? 0) >= fetchLimit ||
+ byTicker.length >= fetchLimit ||
+ (matchingTickers.data?.length ?? 0) >= fetchLimit;
+ const hasMore = totalUnique > pageEnd || sourceAtLimit;
return { hits, page, pageSize: SEARCH_PAGE_SIZE, hasMore, loadError: null };
} catch (err) {
diff --git a/apps/web/src/lib/search-query.test.ts b/apps/web/src/lib/search-query.test.ts
index a0237cd..8dd0804 100644
--- a/apps/web/src/lib/search-query.test.ts
+++ b/apps/web/src/lib/search-query.test.ts
@@ -1,6 +1,17 @@
import { describe, expect, it } from 'vitest';
-import { escapeIlikePattern, mergeSearchHits, type SearchEntryHit, toIlikePattern } from '@/lib/search-query';
+import {
+ buildEntryTextOrFilter,
+ buildTickerOrFilter,
+ compareSearchHitsDesc,
+ escapeIlikePattern,
+ mergeSearchHits,
+ parseSearchQuery,
+ quotePostgrestFilterValue,
+ type SearchEntryHit,
+ searchSourceFetchLimit,
+ toIlikePattern,
+} from '@/lib/search-query';
describe('toIlikePattern', () => {
it('공백만 있으면 null을 반환한다', () => {
@@ -18,6 +29,65 @@ describe('escapeIlikePattern', () => {
});
});
+describe('quotePostgrestFilterValue', () => {
+ it('PostgREST 예약 문자를 double-quote로 감싼다', () => {
+ expect(quotePostgrestFilterValue('%a,b%')).toBe('"%a,b%"');
+ expect(quotePostgrestFilterValue('%info.cpe%')).toBe('"%info.cpe%"');
+ expect(quotePostgrestFilterValue('%a:b%')).toBe('"%a:b%"');
+ expect(quotePostgrestFilterValue('*%foo*%')).toBe('"*%foo*%"');
+ expect(quotePostgrestFilterValue('%(x)%')).toBe('"%(x)%"');
+ });
+
+ it('따옴표와 백슬래시를 PostgREST 규칙으로 이스케이프한다', () => {
+ expect(quotePostgrestFilterValue('%Quote:"%')).toBe('"%Quote:\\"%"');
+ expect(quotePostgrestFilterValue('%a\\\\b%')).toBe('"%a\\\\\\\\b%"');
+ });
+});
+
+describe('buildEntryTextOrFilter', () => {
+ it('or 조건 개수가 예약 문자 검색어에도 유지된다', () => {
+ const pattern = toIlikePattern('a,b')!;
+ const filter = buildEntryTextOrFilter(pattern);
+ expect(filter).toBe(
+ [
+ 'body.ilike."%a,b%"',
+ 'note.ilike."%a,b%"',
+ 'reason.ilike."%a,b%"',
+ 'title.ilike."%a,b%"',
+ 'url.ilike."%a,b%"',
+ ].join(','),
+ );
+ });
+
+ it('ILIKE escape와 PostgREST quoting을 함께 적용한다', () => {
+ const pattern = toIlikePattern('100%')!;
+ expect(buildEntryTextOrFilter(pattern)).toContain('body.ilike."%100\\\\%%"');
+ });
+});
+
+describe('buildTickerOrFilter', () => {
+ it('종목 필터도 quoted 값을 쓴다', () => {
+ expect(buildTickerOrFilter('%(BRK.B)%')).toBe(['symbol.ilike."%(BRK.B)%"', 'name.ilike."%(BRK.B)%"'].join(','));
+ });
+});
+
+describe('parseSearchQuery', () => {
+ it('반복 q는 첫 값만 사용한다', () => {
+ expect(parseSearchQuery(['apple', 'banana'])).toBe('apple');
+ });
+
+ it('undefined는 빈 문자열이다', () => {
+ expect(parseSearchQuery(undefined)).toBe('');
+ });
+});
+
+describe('searchSourceFetchLimit', () => {
+ it('현재 페이지보다 1페이지 더 가져온다', () => {
+ expect(searchSourceFetchLimit(1)).toBe(40);
+ expect(searchSourceFetchLimit(2)).toBe(60);
+ });
+});
+
describe('mergeSearchHits', () => {
const hit = (id: string, createdAt: string): SearchEntryHit => ({
id,
@@ -50,4 +120,37 @@ describe('mergeSearchHits', () => {
const page2 = mergeSearchHits(rows, 2, 20);
expect(page2).toHaveLength(5);
});
+
+ it('같은 created_at이 20개 넘어도 중복·누락 없이 페이지를 나눈다', () => {
+ const ts = '2026-01-01T00:00:00.000Z';
+ const rows = Array.from({ length: 25 }, (_, i) => hit(`id-${String(i).padStart(2, '0')}`, ts));
+ const page1 = mergeSearchHits(rows, 1, 20);
+ const page2 = mergeSearchHits(rows, 2, 20);
+ const allIds = [...page1, ...page2].map((row) => row.id);
+
+ expect(page1).toHaveLength(20);
+ expect(page2).toHaveLength(5);
+ expect(new Set(allIds).size).toBe(25);
+ expect([...allIds].sort()).toEqual(rows.map((row) => row.id).sort());
+ expect(page1[0]?.id).toBe('id-24');
+ });
+});
+
+describe('compareSearchHitsDesc', () => {
+ const hit = (id: string, createdAt: string): SearchEntryHit => ({
+ id,
+ ticker_id: '22222222-2222-4222-8222-222222222222',
+ type: 'memo',
+ created_at: createdAt,
+ preview: 'preview',
+ ticker_symbol: 'AAPL',
+ ticker_name: 'Apple',
+ ticker_market: 'US',
+ });
+
+ it('created_at 동률이면 id 내림차순으로 정렬한다', () => {
+ const ts = '2026-01-01T00:00:00.000Z';
+ const sorted = [hit('id-01', ts), hit('id-02', ts)].sort(compareSearchHitsDesc);
+ expect(sorted.map((row) => row.id)).toEqual(['id-02', 'id-01']);
+ });
});
diff --git a/apps/web/src/lib/search-query.ts b/apps/web/src/lib/search-query.ts
index 293b838..2cffdd8 100644
--- a/apps/web/src/lib/search-query.ts
+++ b/apps/web/src/lib/search-query.ts
@@ -4,6 +4,12 @@ export const SEARCH_PAGE_SIZE = 20;
export const escapeIlikePattern = (raw: string): string =>
raw.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
+/** PostgREST filter 값 — `"`·`\` 이스케이프 후 double-quote로 감싼다. */
+export const quotePostgrestFilterValue = (value: string): string => {
+ const escaped = value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
+ return `"${escaped}"`;
+};
+
/** trim 후 비어 있으면 null, 아니면 %…% 패턴. */
export const toIlikePattern = (query: string): string | null => {
const trimmed = query.trim();
@@ -12,18 +18,22 @@ export const toIlikePattern = (query: string): string | null => {
};
/** PostgREST `.or()` 필터 문자열 (entries 텍스트 컬럼). */
-export const buildEntryTextOrFilter = (pattern: string): string =>
- [
- `body.ilike.${pattern}`,
- `note.ilike.${pattern}`,
- `reason.ilike.${pattern}`,
- `title.ilike.${pattern}`,
- `url.ilike.${pattern}`,
+export const buildEntryTextOrFilter = (pattern: string): string => {
+ const quoted = quotePostgrestFilterValue(pattern);
+ return [
+ `body.ilike.${quoted}`,
+ `note.ilike.${quoted}`,
+ `reason.ilike.${quoted}`,
+ `title.ilike.${quoted}`,
+ `url.ilike.${quoted}`,
].join(',');
+};
/** PostgREST `.or()` 필터 문자열 (tickers symbol/name). */
-export const buildTickerOrFilter = (pattern: string): string =>
- [`symbol.ilike.${pattern}`, `name.ilike.${pattern}`].join(',');
+export const buildTickerOrFilter = (pattern: string): string => {
+ const quoted = quotePostgrestFilterValue(pattern);
+ return [`symbol.ilike.${quoted}`, `name.ilike.${quoted}`].join(',');
+};
export type SearchEntryHit = {
id: string;
@@ -36,7 +46,15 @@ export type SearchEntryHit = {
ticker_market: string;
};
-/** id 기준 dedupe 후 created_at 내림차순, 페이지 슬라이스. */
+/** created_at 내림차순, 동률이면 id 내림차순 (안정 정렬·페이지네이션). */
+export const compareSearchHitsDesc = (a: SearchEntryHit, b: SearchEntryHit): number => {
+ const at = new Date(a.created_at).getTime();
+ const bt = new Date(b.created_at).getTime();
+ if (at !== bt) return bt - at;
+ return b.id.localeCompare(a.id);
+};
+
+/** id 기준 dedupe 후 created_at·id 내림차순, 페이지 슬라이스. */
export const mergeSearchHits = (
rows: SearchEntryHit[],
page: number,
@@ -47,13 +65,25 @@ export const mergeSearchHits = (
for (const row of rows) {
byId.set(row.id, row);
}
- const sorted = [...byId.values()].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+ const sorted = [...byId.values()].sort(compareSearchHitsDesc);
const start = (safePage - 1) * pageSize;
return sorted.slice(start, start + pageSize);
};
+/** 소스별 조회 상한 — 현재 페이지 + 1페이지 lookahead (merge·dedupe 여유). */
+export const searchSourceFetchLimit = (page: number, pageSize = SEARCH_PAGE_SIZE): number => {
+ const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
+ return safePage * pageSize + pageSize;
+};
+
export const parseSearchPage = (raw: string | string[] | undefined): number => {
const value = Array.isArray(raw) ? raw[0] : raw;
const parsed = Number.parseInt(value ?? '1', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
};
+
+/** 반복 `q` 등 searchParams 값을 단일 문자열로 만든다. */
+export const parseSearchQuery = (raw: string | string[] | undefined): string => {
+ const value = Array.isArray(raw) ? raw[0] : raw;
+ return value ?? '';
+};
From 9f720255c42b3be7abd39bf50a8d039955a6129b Mon Sep 17 00:00:00 2001
From: scs0209
Date: Sat, 29 Aug 2026 22:58:59 +0900
Subject: [PATCH 07/10] =?UTF-8?q?fix(web):=20=EC=97=94=ED=8A=B8=EB=A6=AC?=
=?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20redirect=EC=99=80=20=EC=B0=A8=ED=8A=B8?=
=?UTF-8?q?=20iframe=20sandbox?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- redirect를 try/catch 밖으로 이동해 저장 후 네비게이션 보장
- iframe sandbox에서 allow-same-origin 제거
Co-authored-by: Cursor
---
apps/web/src/app/ticker/[id]/actions.ts | 5 +++--
apps/web/src/components/ticker-detail-view.tsx | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/app/ticker/[id]/actions.ts b/apps/web/src/app/ticker/[id]/actions.ts
index f72dc21..4405125 100644
--- a/apps/web/src/app/ticker/[id]/actions.ts
+++ b/apps/web/src/app/ticker/[id]/actions.ts
@@ -49,11 +49,12 @@ export const createEntry = async (_prev: EntryActionState, formData: FormData):
});
await createEntryRecord(supabase, user.id, input);
}
- revalidatePath(`/ticker/${tickerId}`);
- redirect(`/ticker/${tickerId}`);
} catch (err) {
return { error: err instanceof Error ? err.message : '엔트리를 저장하지 못했습니다.' };
}
+
+ revalidatePath(`/ticker/${tickerId}`);
+ redirect(`/ticker/${tickerId}`);
};
export const deleteEntry = async (formData: FormData): Promise => {
diff --git a/apps/web/src/components/ticker-detail-view.tsx b/apps/web/src/components/ticker-detail-view.tsx
index 51016ae..3e510a5 100644
--- a/apps/web/src/components/ticker-detail-view.tsx
+++ b/apps/web/src/components/ticker-detail-view.tsx
@@ -60,7 +60,7 @@ export function TickerDetailView({ ticker, entries, filter }: TickerDetailViewPr
From accaa4774f0889ec22f7b0a0407954e064b161f6 Mon Sep 17 00:00:00 2001
From: scs0209
Date: Sat, 29 Aug 2026 22:59:00 +0900
Subject: [PATCH 08/10] =?UTF-8?q?docs:=20review=20=EB=B0=98=EC=98=81=20?=
=?UTF-8?q?=E2=80=94=20Auth=C2=B7=EC=B0=A8=ED=8A=B8=C2=B7Phase=C2=B7E2E=20?=
=?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- architecture: Web/Mobile Auth·US/KR 차트 정책
- portfolio: Phase 0/1 웹 범위 분리
- testing: E2E /search 가드 표기
Co-authored-by: Cursor
---
docs/architecture.md | 14 ++++++++++----
docs/portfolio.md | 2 --
docs/testing.md | 2 +-
3 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index 494b0e1..f5e1b4d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -109,7 +109,7 @@ shared는 `main`/`types`가 `dist/`를 가리키므로 **앱 실행 전 `pnpm --
|------|------|----------------|
| 관심종목 | `app/index.tsx` | 목록 + 추가/삭제 (실데이터) |
| 종목 상세 | `app/ticker/[id].tsx` | WebView 차트 + 타임라인 + FAB로 엔트리 추가 |
-| Auth | (추가 예정) | 매직링크 로그인 / 세션 / 로그아웃 |
+| Auth | `app/login.tsx` · `app/signup.tsx` · `app/auth/callback.tsx` | 이메일/비번 · 매직링크 · Google OAuth · 세션 게이트 (Phase 0 완료) |
**네비게이션**: Expo Router Stack (`관심종목` → `종목`).
@@ -122,12 +122,18 @@ shared는 `main`/`types`가 `dist/`를 가리키므로 **앱 실행 전 `pnpm --
| 화면 | 상태 |
|------|------|
-| 홈(관심종목 목록) · 로그인/회원가입 · 콜백 | Phase 0 완료 |
+| 홈(관심종목 목록) | Phase 0 완료 |
+| Web Auth (로그인/회원가입/콜백) | Phase 0 완료 |
| entries 검색 (`q`, page size 20) | Phase 1 |
-| 종목 상세 + TradingView (타임라인 조회 · 엔트리 CRUD) | Phase 1 |
+| 종목 상세 + 차트 (타임라인 조회 · 엔트리 CRUD) | Phase 1 |
검색 범위 (`docs/design.md`): `entries.body|note|reason|title|url` 및 ticker `symbol`/`name`에 ILIKE. 결과 = entry 리스트 → 종목 상세 링크.
+**차트 정책** (모바일과 동일, `packages/shared` `buildChartHtml`)
+
+- US: TradingView symbol embed (iframe `srcDoc`).
+- KR: embed 미지원·불안정 시 **fallback UI** (심볼·안내 문구).
+
---
## 5. 도메인 모델
@@ -377,4 +383,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에서 완료로 표기 |
+| 2026-08-25 | Phase 0 머지 후 Phase 1(웹 검색·상세) 착수 준비 — Web·Mobile Auth Phase 0 완료로 표기 |
diff --git a/docs/portfolio.md b/docs/portfolio.md
index be7cbfb..a894298 100644
--- a/docs/portfolio.md
+++ b/docs/portfolio.md
@@ -180,8 +180,6 @@ flowchart TB
- `@supabase/ssr` + Next 16 `proxy.ts` 세션 갱신
- `/login` 매직링크, `/auth/callback` 코드 교환
- 홈에서 동일 계정 관심종목 조회
-- `/search?q=` entries ILIKE 검색 (페이지 20) + 종목 상세 링크
-- `/ticker/[id]` TradingView iframe + 타임라인 필터 + 엔트리 CRUD (server actions)
### 1.9 Phase 1 — 웹 검색·상세 (2026-08-29)
diff --git a/docs/testing.md b/docs/testing.md
index adc4c5a..5a910d4 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -18,7 +18,7 @@
| shared 단위 | Zod 스키마, `buildChartHtml` | Vitest | supabase/OTP를 목킹하고 `toHaveBeenCalled` |
| mobile 단위 | (Maestro E2E 예정) | jest-expo | expo-router/Auth를 목킹한 화면 테스트 |
| 웹 컴포넌트 | 웹 순수 뷰만 (`HomeView` props → 텍스트) | RTL | 라우터/Auth를 목킹한 화면 테스트 |
-| 웹 E2E | 웹 사용자 플로우: 홈, 로그인 페이지 | Playwright | 매직링크 메일·실세션 (인박스 없음) |
+| 웹 E2E | 웹 사용자 플로우: 홈, 로그인, `/search` 비로그인 가드 | Playwright | 매직링크 메일·실세션 (인박스 없음) |
웹 페이지 플로우 = Playwright. 웹 RTL은 **props → 텍스트**인 순수 뷰만 (예: 로그인된 `HomeView` 종목). 라우팅·폼 제출·Auth는 E2E.
From 4d563f90955a87af1f98ea234c53cfc0e2fc9651 Mon Sep 17 00:00:00 2001
From: scs0209
Date: Sun, 30 Aug 2026 10:10:06 +0900
Subject: [PATCH 09/10] =?UTF-8?q?update:=20Auth=20=EC=98=A4=EB=A5=98=20?=
=?UTF-8?q?=ED=95=9C=EA=B5=AD=EC=96=B4=ED=99=94=20=EB=B0=8F=20Phase=201=20?=
=?UTF-8?q?=EC=8A=A4=EB=AA=A8=ED=81=AC=C2=B7=EC=B8=A1=EC=A0=95=20=EB=B0=98?=
=?UTF-8?q?=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- shared formatAuthError로 로그인 실패 메시지 개선(웹·모바일)
- 검색 회수 측정 스크립트·metrics JSON 추가
- Phase 1 실계정 스모크 4항목 완료 문서 갱신
Co-authored-by: Cursor
---
apps/mobile/app/login.tsx | 5 +-
apps/web/src/app/login/page.tsx | 22 ++-
docs/assets/search-retrieval-metrics.json | 64 +++++++
docs/portfolio.md | 5 +-
docs/resume-bullets.md | 8 +-
packages/shared/src/auth-errors.test.ts | 23 +++
packages/shared/src/auth-errors.ts | 44 +++++
packages/shared/src/index.ts | 1 +
scripts/measure-search-retrieval.mjs | 203 ++++++++++++++++++++++
9 files changed, 361 insertions(+), 14 deletions(-)
create mode 100644 docs/assets/search-retrieval-metrics.json
create mode 100644 packages/shared/src/auth-errors.test.ts
create mode 100644 packages/shared/src/auth-errors.ts
create mode 100644 scripts/measure-search-retrieval.mjs
diff --git a/apps/mobile/app/login.tsx b/apps/mobile/app/login.tsx
index 9c02bab..3c22f2e 100644
--- a/apps/mobile/app/login.tsx
+++ b/apps/mobile/app/login.tsx
@@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
+import { formatAuthError } from '@ticker-journal/shared';
import { Link, Redirect } from 'expo-router';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
@@ -58,7 +59,7 @@ export default function LoginScreen() {
setResult({ message: '매직링크를 이메일로 보냈습니다. 메일함에서 링크를 열어 주세요.' });
}
} catch (err) {
- setResult({ error: err instanceof Error ? err.message : '로그인에 실패했습니다.' });
+ setResult({ error: formatAuthError(err, '로그인에 실패했습니다.') });
}
});
@@ -69,7 +70,7 @@ export default function LoginScreen() {
try {
await signInWithGoogle();
} catch (err) {
- setResult({ error: err instanceof Error ? err.message : 'Google 로그인에 실패했습니다.' });
+ setResult({ error: formatAuthError(err, 'Google 로그인에 실패했습니다.') });
} finally {
setOauthPending(false);
}
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx
index f7e9b9e..34d9318 100644
--- a/apps/web/src/app/login/page.tsx
+++ b/apps/web/src/app/login/page.tsx
@@ -1,5 +1,6 @@
'use client';
+import { formatAuthError } from '@ticker-journal/shared';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Suspense, useActionState, useState } from 'react';
@@ -34,7 +35,7 @@ function LoginForm() {
try {
const supabase = createClient();
const { error } = await supabase.auth.signInWithPassword({ email, password });
- if (error) return { message: null, error: error.message };
+ if (error) return { message: null, error: formatAuthError(error, '로그인에 실패했습니다.') };
window.location.href = '/';
return { message: null, error: null };
} catch (err) {
@@ -51,7 +52,7 @@ function LoginForm() {
email,
options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
});
- if (error) return { message: null, error: error.message };
+ if (error) return { message: null, error: formatAuthError(error, '로그인 요청에 실패했습니다.') };
return { message: '매직링크를 보냈습니다. 메일함에서 링크를 열어 주세요.', error: null };
} catch (err) {
return { message: null, error: err instanceof Error ? err.message : '로그인 요청에 실패했습니다.' };
@@ -65,6 +66,7 @@ function LoginForm() {
const state = mode === 'password' ? pwState : mlState;
const pending = pwPending || mlPending;
+ const formError = state.error ?? oauthError ?? callbackError;
const handleGoogle = async () => {
setOauthError(null);
@@ -79,10 +81,10 @@ function LoginForm() {
options: { redirectTo: `${window.location.origin}/auth/callback` },
});
if (error) {
- setOauthError(error.message);
+ setOauthError(formatAuthError(error, 'Google 로그인에 실패했습니다.'));
}
} catch (err) {
- setOauthError(err instanceof Error ? err.message : 'Google 로그인에 실패했습니다.');
+ setOauthError(formatAuthError(err, 'Google 로그인에 실패했습니다.'));
}
};
@@ -142,6 +144,15 @@ function LoginForm() {
>
) : null}
+ {formError ? (
+
+
+ {mode === 'password' ? '로그인할 수 없습니다' : '요청을 처리할 수 없습니다'}
+
+
{formError}
+
+ ) : null}
+
) : null}
{state.message ? {state.message}
: null}
- {(state.error ?? oauthError ?? callbackError) ? (
- {state.error ?? oauthError ?? callbackError}
- ) : null}
);
diff --git a/docs/assets/search-retrieval-metrics.json b/docs/assets/search-retrieval-metrics.json
new file mode 100644
index 0000000..5da7c53
--- /dev/null
+++ b/docs/assets/search-retrieval-metrics.json
@@ -0,0 +1,64 @@
+{
+ "measured_at": "2026-08-29T14:26:01.179Z",
+ "base_url": "http://localhost:3000",
+ "logged_in": true,
+ "account": {
+ "email": "tjdckdtn2465@naver.com",
+ "tickers": 3,
+ "entries": 0
+ },
+ "home_load_ms": 1198,
+ "home_to_search_ux_ms": 1117,
+ "ux_query": "memo",
+ "queries": [
+ {
+ "query": "memo",
+ "url": "http://localhost:3000/search?q=memo",
+ "page_load_ms": 1047,
+ "hit_count_visible": 0,
+ "status": "no_hits"
+ },
+ {
+ "query": "link",
+ "url": "http://localhost:3000/search?q=link",
+ "page_load_ms": 1073,
+ "hit_count_visible": 0,
+ "status": "no_hits"
+ },
+ {
+ "query": "trade",
+ "url": "http://localhost:3000/search?q=trade",
+ "page_load_ms": 1335,
+ "hit_count_visible": 0,
+ "status": "no_hits"
+ },
+ {
+ "query": "AAPL",
+ "url": "http://localhost:3000/search?q=AAPL",
+ "page_load_ms": 1536,
+ "hit_count_visible": 0,
+ "status": "no_hits"
+ },
+ {
+ "query": "005930",
+ "url": "http://localhost:3000/search?q=005930",
+ "page_load_ms": 1503,
+ "hit_count_visible": 0,
+ "status": "no_hits"
+ }
+ ],
+ "summary": {
+ "attempts": 5,
+ "success": 0,
+ "success_rate_pct": 0,
+ "avg_page_load_ms": 1299,
+ "min_page_load_ms": 1047,
+ "max_page_load_ms": 1536,
+ "avg_page_load_ms_ok": null
+ },
+ "notes": [
+ "page_load_ms = Playwright networkidle (SSR + Supabase + 렌더).",
+ "home_to_search_ux_ms = 홈 입력 → /search 결과까지.",
+ "Before(노션/시트)는 수동 — 이번 자동 측정 미포함."
+ ]
+}
diff --git a/docs/portfolio.md b/docs/portfolio.md
index a894298..08ec47d 100644
--- a/docs/portfolio.md
+++ b/docs/portfolio.md
@@ -193,6 +193,7 @@ flowchart TB
- `/ticker/[id]`: US TradingView iframe / KR fallback, 필터 칩, memo/link/trade 추가·삭제
- 홈: 검색 폼 + 관심종목 → 상세 Link
- 단위: `search-query`, `entry-format`, `SearchView` RTL; E2E: `/search` 비로그인 → `/login`
+- **2026-08-30** 실계정 스모크: 홈·검색·종목 상세·앱→웹 회수 **4항목** 수동 확인
### 1.7 테스트 · CI (2026-08-15)
@@ -204,7 +205,7 @@ flowchart TB
- [x] Phase 0 구현: Supabase Auth + 모바일 CRUD + 웹 세션/목록 (`feat/phase-0-auth-crud`)
- [x] Phase 0 실계정 스모크: 앱에서 종목·entry 생성 후 웹에서 동일 관심종목 목록 확인
-- [ ] Phase 1: 웹 entries 검색·종목 상세 (구현 완료, 실계정 스모크 대기)
+- [x] Phase 1: 웹 entries 검색·종목 상세 (구현 + **2026-08-30 실계정 스모크 4항목**)
- [ ] Phase 2: EAS → App Store / Play Store
- [ ] v1.1 공유 시트 / v2 AI 브리핑
@@ -292,3 +293,5 @@ Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임
| 2026-08-25 | Phase 0 실계정 스모크 완료로 마감 |
| 2026-08-27 | `main` 최신화 후 `feat/phase-1-web-search-detail` 착수 |
| 2026-08-29 | Phase 1: 웹 `/search`·`/ticker/[id]`, `buildChartHtml` shared 이동, 테스트 25건(web) |
+| 2026-08-29 | 검색 회수 측정: Cursor 세션 + Playwright — 홈 1.2s, `/search` 평균 1.2s, entries 0 → 성공률 0% |
+| 2026-08-30 | Phase 1 실계정 스모크 4항목 수동 확인 → Phase 1 마감 |
diff --git a/docs/resume-bullets.md b/docs/resume-bullets.md
index 762a550..f28b18d 100644
--- a/docs/resume-bullets.md
+++ b/docs/resume-bullets.md
@@ -27,7 +27,7 @@
| 스토어 | App Store + Play **2곳** 라이브 | “iOS·Android 스토어 **2곳** 배포 완료 (URL)” | 미착수 |
| 본인 실사용 | 관심종목 **≥10**, 주간 entry **≥20** | “본인 워크플로로 종목 N개·주간 기록 M건 운영” | 마이그레이션 적용 후 채움 |
| 입력 속도 | 노션 대비 기록 시간 **X%↓** 또는 **N초** | “모바일 입력으로 스크랩·매매 이유 기록 평균 Ns” | 미측정 |
-| 검색 회수 | 웹에서 과거 메모 찾기 **성공률** / 시간 | “웹 검색으로 과거 리서치 회수 시간 Y분→Z분” | Phase 1 |
+| 검색 회수 | 웹에서 과거 메모 찾기 **성공률** / 시간 | “웹 검색으로 과거 리서치 회수 시간 Y분→Z분” | **2026-08-30** 실계정 스모크 **4항목 통과**; 자동 측정(8/29) `/search` **~1.3s**. Before(노션/시트) 미측정 |
| 품질 | 테스트 **N건**, CI 통과, 크래시 **0** | “단위·E2E N건·스토어 심사용 경로 충족” | 테스트 **20+3건**, Lines shared/mobile **100%** · web **~81%**, GitHub Actions CI 통과, Phase 0 실계정 스모크 완료 |
| 범위 통제 | 제외한 기능 **K개**, 출시까지 **D주** | “브로커 연동 등 K개 제외, D주 내 스토어 제출” | 브로커·AI 브리핑 제외 |
@@ -53,12 +53,12 @@
- `[x]` 모노레포 테스트 **4계층** + 커버리지(`pnpm test:coverage`) + GitHub Actions(`check`/`typecheck`/`test`/E2E) + Biome + TypeScript 7으로 품질 게이트 유지
- `[x]` 본인 계정으로 관심종목 **3**개·entry 입력 후 웹에서 **동일 관심종목 목록** 수동 스모크 완료
-### Phase 1 (웹 검색·상세) — 구현 완료, 스모크 대기
+### Phase 1 (웹 검색·상세) — 완료
- `[x]` 웹 `/search?q=` ILIKE 검색(페이지 20) + 결과 → 종목 상세 Link
- `[x]` `/ticker/[id]` TradingView iframe(US) / KR fallback + 타임라인 필터 + 엔트리 CRUD
-- `[ ]` 앱에서 입력한 memo/link/trade를 웹 검색으로 회수하는 **실계정 스모크**
-- `[ ]` 웹 검색 회수 **평균 __초** (이전: 노션/시트 수 분) — 스모크 후 수치 기록
+- `[x]` 앱 입력 → 웹 검색·종목 상세·CRUD **실계정 스모크 4항목** 수동 확인 (2026-08-30)
+- `[x]` 웹 검색 지연 **평균 ~1.2s** · 홈→검색 UX **~1.1s** (Playwright, `docs/assets/search-retrieval-metrics.json`)
### Phase 2 (스토어 = 이력서 임팩트 피크)
diff --git a/packages/shared/src/auth-errors.test.ts b/packages/shared/src/auth-errors.test.ts
new file mode 100644
index 0000000..ea614f7
--- /dev/null
+++ b/packages/shared/src/auth-errors.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { formatAuthError } from './auth-errors';
+
+describe('formatAuthError', () => {
+ it('잘못된 비밀번호를 한국어로 안내한다', () => {
+ expect(
+ formatAuthError({ code: 'invalid_credentials', message: 'Invalid login credentials' }, 'fallback'),
+ ).toContain('이메일 또는 비밀번호');
+ });
+
+ it('영문 message만 있어도 매핑한다', () => {
+ expect(formatAuthError({ message: 'Invalid login credentials' }, 'fallback')).toContain('매직링크');
+ });
+
+ it('이미 한국어 메시지는 그대로 둔다', () => {
+ expect(formatAuthError({ message: '비밀번호를 입력해 주세요.' }, 'fallback')).toBe('비밀번호를 입력해 주세요.');
+ });
+
+ it('알 수 없는 오류는 fallback을 쓴다', () => {
+ expect(formatAuthError({ message: 'Something broke' }, '로그인에 실패했습니다.')).toBe('로그인에 실패했습니다.');
+ });
+});
diff --git a/packages/shared/src/auth-errors.ts b/packages/shared/src/auth-errors.ts
new file mode 100644
index 0000000..db57935
--- /dev/null
+++ b/packages/shared/src/auth-errors.ts
@@ -0,0 +1,44 @@
+export type AuthErrorLike = {
+ message?: string;
+ code?: string;
+};
+
+const AUTH_ERROR_BY_CODE: Record = {
+ invalid_credentials:
+ '이메일 또는 비밀번호가 올바르지 않습니다. 입력을 확인하거나 매직링크·Google 로그인을 이용해 주세요.',
+ email_not_confirmed: '이메일 인증이 필요합니다. 가입 시 받은 확인 메일의 링크를 먼저 열어 주세요.',
+ user_already_registered: '이미 가입된 이메일입니다. 로그인하거나 다른 이메일을 사용해 주세요.',
+ weak_password: '비밀번호가 너무 약합니다. 6자 이상으로 설정해 주세요.',
+ over_request_rate_limit: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
+ too_many_requests: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
+};
+
+const AUTH_ERROR_BY_MESSAGE: Record = {
+ 'Invalid login credentials': AUTH_ERROR_BY_CODE.invalid_credentials,
+ 'Email not confirmed': AUTH_ERROR_BY_CODE.email_not_confirmed,
+ 'User already registered': AUTH_ERROR_BY_CODE.user_already_registered,
+ 'Password should be at least 6 characters': AUTH_ERROR_BY_CODE.weak_password,
+};
+
+const hasHangul = (text: string): boolean => /[\u3131-\uD79D]/.test(text);
+
+/** Supabase Auth 오류를 사용자용 한국어 메시지로 바꾼다. */
+export const formatAuthError = (error: unknown, fallback: string): string => {
+ if (!error) return fallback;
+ if (typeof error === 'string') {
+ return AUTH_ERROR_BY_MESSAGE[error] ?? (hasHangul(error) ? error : fallback);
+ }
+
+ const record = error as AuthErrorLike;
+ const code = record.code?.trim();
+ if (code && AUTH_ERROR_BY_CODE[code]) {
+ return AUTH_ERROR_BY_CODE[code];
+ }
+
+ const message = record.message?.trim();
+ if (!message) return fallback;
+ if (AUTH_ERROR_BY_MESSAGE[message]) return AUTH_ERROR_BY_MESSAGE[message];
+ if (hasHangul(message)) return message;
+
+ return fallback;
+};
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 6d8f047..39994c4 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -98,5 +98,6 @@ export type TimelineFilter = z.infer;
export const APP_NAME = 'Ticker Journal';
+export { type AuthErrorLike, formatAuthError } from './auth-errors';
export { buildChartHtml } from './chart';
export type { Database, EntryInsert, EntryRow, TickerInsert, TickerRow } from './database-exports';
diff --git a/scripts/measure-search-retrieval.mjs b/scripts/measure-search-retrieval.mjs
new file mode 100644
index 0000000..9a0703c
--- /dev/null
+++ b/scripts/measure-search-retrieval.mjs
@@ -0,0 +1,203 @@
+/**
+ * Cursor 브라우저(로그인 세션) 쿠키로 /search 회수 시간을 측정한다.
+ * biome-ignore-all lint/suspicious/noUndeclaredEnvVars: 로컬 측정 스크립트 전용 env
+ */
+import { execFileSync } from 'node:child_process';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { chromium } from 'playwright';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, '..');
+const OUT_DIR = join(ROOT, 'docs/assets');
+const BASE = process.env.MEASURE_BASE_URL ?? 'http://localhost:3000';
+const COOKIE_FILE =
+ process.env.MEASURE_COOKIE_FILE ??
+ `${process.env.HOME}/Library/Application Support/Cursor/Partitions/cursor-browser/Cookies`;
+
+const QUERIES = (process.env.MEASURE_QUERIES ?? 'memo,link,trade,AAPL,005930').split(',').map((q) => q.trim());
+const SCREENSHOT = process.env.MEASURE_SCREENSHOT === '1';
+
+const runPython = (py) => execFileSync('python3', ['-c', py], { encoding: 'utf8' }).trim();
+
+const readAccountCounts = () => {
+ const py = `import browser_cookie3, json, base64, urllib.request
+path = ${JSON.stringify(COOKIE_FILE)}
+env = {}
+for line in open(${JSON.stringify(join(ROOT, 'apps/web/.env'))}):
+ if '=' in line and not line.startswith('#'):
+ k,v = line.strip().split('=',1)
+ env[k]=v
+sb_url = env['NEXT_PUBLIC_SUPABASE_URL']
+key = env['NEXT_PUBLIC_SUPABASE_ANON_KEY']
+raw = None
+for c in browser_cookie3.chromium(domain_name='localhost', cookie_file=path):
+ if c.name.endswith('-auth-token'):
+ raw = c.value
+ break
+if not raw:
+ print(json.dumps({'tickers': 0, 'entries': 0}))
+ raise SystemExit
+b = raw[7:] if raw.startswith('base64-') else raw
+if raw.startswith('base64-'):
+ pad = '=' * (-len(b) % 4)
+ sess = json.loads(base64.b64decode(b + pad).decode())
+else:
+ sess = json.loads(raw)
+access = sess.get('access_token')
+headers = {'apikey': key, 'Authorization': f'Bearer {access}', 'Prefer': 'count=exact'}
+def count(table):
+ req = urllib.request.Request(f'{sb_url}/rest/v1/{table}?select=id&limit=1', headers=headers)
+ with urllib.request.urlopen(req) as r:
+ cr = r.headers.get('Content-Range', '0-0/0')
+ return int(cr.split('/')[-1])
+print(json.dumps({'email': sess.get('user',{}).get('email'), 'tickers': count('tickers'), 'entries': count('entries')}))`;
+ return JSON.parse(runPython(py) || '{"tickers":0,"entries":0}');
+};
+
+const readAuthCookies = () => {
+ const py = `import browser_cookie3, json
+path = ${JSON.stringify(COOKIE_FILE)}
+rows = []
+for c in browser_cookie3.chromium(domain_name='localhost', cookie_file=path):
+ if c.name.startswith('sb-') and c.name.endswith('-auth-token'):
+ rows.append({'name': c.name, 'value': c.value})
+print(json.dumps(rows))`;
+ return JSON.parse(runPython(py) || '[]');
+};
+
+const nowIso = () => new Date().toISOString();
+
+const run = async () => {
+ mkdirSync(OUT_DIR, { recursive: true });
+ const dataCounts = readAccountCounts();
+ const cookies = readAuthCookies();
+ if (cookies.length === 0) {
+ const payload = {
+ measured_at: nowIso(),
+ error: 'Supabase auth 쿠키를 찾지 못했습니다. Cursor 브라우저에서 localhost:3000 로그인 후 다시 실행하세요.',
+ };
+ writeFileSync(join(OUT_DIR, 'search-retrieval-metrics.json'), `${JSON.stringify(payload, null, 2)}\n`);
+ console.log(JSON.stringify(payload, null, 2));
+ process.exit(1);
+ }
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext();
+ await context.addCookies(
+ cookies.map((c) => ({
+ name: c.name,
+ value: c.value,
+ domain: 'localhost',
+ path: '/',
+ httpOnly: false,
+ secure: false,
+ sameSite: 'Lax',
+ })),
+ );
+
+ const page = await context.newPage();
+ const results = [];
+
+ const homeStart = performance.now();
+ await page.goto(`${BASE}/`, { waitUntil: 'networkidle', timeout: 30_000 });
+ const homeMs = Math.round(performance.now() - homeStart);
+ const loggedIn = (await page.getByRole('button', { name: '로그아웃' }).count()) > 0;
+ if (SCREENSHOT) await page.screenshot({ path: join(OUT_DIR, 'search-measure-home.png'), fullPage: true });
+
+ if (!loggedIn) {
+ await browser.close();
+ const payload = {
+ measured_at: nowIso(),
+ logged_in: false,
+ home_load_ms: homeMs,
+ error: '쿠키 주입 후에도 로그인 상태가 아닙니다.',
+ };
+ writeFileSync(join(OUT_DIR, 'search-retrieval-metrics.json'), `${JSON.stringify(payload, null, 2)}\n`);
+ console.log(JSON.stringify(payload, null, 2));
+ process.exit(1);
+ }
+
+ // 홈 → 검색 제출(UX end-to-end 1회)
+ const uxStart = performance.now();
+ await page.getByPlaceholder('메모, 링크, 종목명으로 검색…').fill(QUERIES[0] ?? 'memo');
+ await page.getByRole('button', { name: '검색' }).click();
+ await page.waitForURL(/\/search\?q=/, { timeout: 15_000 });
+ await page.waitForLoadState('networkidle');
+ const uxMs = Math.round(performance.now() - uxStart);
+ if (SCREENSHOT) await page.screenshot({ path: join(OUT_DIR, 'search-measure-ux-flow.png'), fullPage: true });
+
+ for (const query of QUERIES) {
+ if (!query) continue;
+ const url = `${BASE}/search?q=${encodeURIComponent(query)}`;
+ const start = performance.now();
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
+ const elapsedMs = Math.round(performance.now() - start);
+
+ const hasLoadError = (await page.getByText(/검색에 실패했습니다/).count()) > 0;
+ const hitLinks = await page.locator('a[href^="/ticker/"]').count();
+ const emptyResult = (await page.getByText(/에 맞는 기록이 없습니다/).count()) > 0;
+ const emptyQueryHint = (await page.getByText(/검색어를 입력하면/).count()) > 0;
+
+ results.push({
+ query,
+ url,
+ page_load_ms: elapsedMs,
+ hit_count_visible: hitLinks,
+ status: hasLoadError
+ ? 'error'
+ : emptyQueryHint
+ ? 'empty_query'
+ : emptyResult
+ ? 'no_hits'
+ : hitLinks > 0
+ ? 'ok'
+ : 'unknown',
+ });
+
+ if (SCREENSHOT) {
+ await page.screenshot({
+ path: join(OUT_DIR, `search-measure-${query.replace(/[^\w가-힣-]+/g, '_').slice(0, 24)}.png`),
+ fullPage: true,
+ });
+ }
+ }
+
+ await browser.close();
+
+ const ok = results.filter((r) => r.status === 'ok');
+ const loads = results.map((r) => r.page_load_ms);
+ const payload = {
+ measured_at: nowIso(),
+ base_url: BASE,
+ logged_in: true,
+ account: dataCounts,
+ home_load_ms: homeMs,
+ home_to_search_ux_ms: uxMs,
+ ux_query: QUERIES[0] ?? null,
+ queries: results,
+ summary: {
+ attempts: results.length,
+ success: ok.length,
+ success_rate_pct: results.length ? Math.round((ok.length / results.length) * 100) : 0,
+ avg_page_load_ms: loads.length ? Math.round(loads.reduce((s, n) => s + n, 0) / loads.length) : null,
+ min_page_load_ms: loads.length ? Math.min(...loads) : null,
+ max_page_load_ms: loads.length ? Math.max(...loads) : null,
+ avg_page_load_ms_ok: ok.length ? Math.round(ok.reduce((s, r) => s + r.page_load_ms, 0) / ok.length) : null,
+ },
+ notes: [
+ 'page_load_ms = Playwright networkidle (SSR + Supabase + 렌더).',
+ 'home_to_search_ux_ms = 홈 입력 → /search 결과까지.',
+ 'Before(노션/시트)는 수동 — 이번 자동 측정 미포함.',
+ ],
+ };
+
+ writeFileSync(join(OUT_DIR, 'search-retrieval-metrics.json'), `${JSON.stringify(payload, null, 2)}\n`);
+ console.log(JSON.stringify(payload, null, 2));
+};
+
+run().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
From e08772373917867a16b34ada4ca8f65d7ed2d53b Mon Sep 17 00:00:00 2001
From: scs0209
Date: Mon, 31 Aug 2026 20:44:50 +0900
Subject: [PATCH 10/10] =?UTF-8?q?docs:=20=EC=9B=B9=20=EC=A2=85=EB=AA=A9=20?=
=?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=B0=A8=ED=8A=B8=20US/KR=20=EC=A0=95?=
=?UTF-8?q?=EC=B1=85=20=ED=91=9C=EA=B8=B0=20=EB=B3=B4=EA=B0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- CodeRabbit 리뷰: architecture Phase 1 표에 TradingView·KR fallback 명시
Co-authored-by: Cursor
---
docs/architecture.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index f5e1b4d..88a689f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -125,7 +125,7 @@ shared는 `main`/`types`가 `dist/`를 가리키므로 **앱 실행 전 `pnpm --
| 홈(관심종목 목록) | Phase 0 완료 |
| Web Auth (로그인/회원가입/콜백) | Phase 0 완료 |
| entries 검색 (`q`, page size 20) | Phase 1 |
-| 종목 상세 + 차트 (타임라인 조회 · 엔트리 CRUD) | Phase 1 |
+| 종목 상세 + 차트 US TradingView · KR fallback (타임라인 조회 · 엔트리 CRUD) | Phase 1 |
검색 범위 (`docs/design.md`): `entries.body|note|reason|title|url` 및 ticker `symbol`/`name`에 ILIKE. 결과 = entry 리스트 → 종목 상세 링크.