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/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/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/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/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/apps/web/src/app/search/page.tsx b/apps/web/src/app/search/page.tsx
new file mode 100644
index 0000000..8524d0e
--- /dev/null
+++ b/apps/web/src/app/search/page.tsx
@@ -0,0 +1,42 @@
+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 | string[]; page?: string | 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 = parseSearchQuery(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..4405125
--- /dev/null
+++ b/apps/web/src/app/ticker/[id]/actions.ts
@@ -0,0 +1,77 @@
+'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);
+ }
+ } catch (err) {
+ return { error: err instanceof Error ? err.message : '엔트리를 저장하지 못했습니다.' };
+ }
+
+ revalidatePath(`/ticker/${tickerId}`);
+ redirect(`/ticker/${tickerId}`);
+};
+
+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.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/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.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/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..3e510a5
--- /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..d00a187
--- /dev/null
+++ b/apps/web/src/lib/entries.ts
@@ -0,0 +1,248 @@
+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,
+ searchSourceFetchLimit,
+ 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 = searchSourceFetchLimit(page);
+ 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 })
+ .order('id', { 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 })
+ .order('id', { 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 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) {
+ 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.test.ts b/apps/web/src/lib/search-query.test.ts
new file mode 100644
index 0000000..8dd0804
--- /dev/null
+++ b/apps/web/src/lib/search-query.test.ts
@@ -0,0 +1,156 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildEntryTextOrFilter,
+ buildTickerOrFilter,
+ compareSearchHitsDesc,
+ escapeIlikePattern,
+ mergeSearchHits,
+ parseSearchQuery,
+ quotePostgrestFilterValue,
+ type SearchEntryHit,
+ searchSourceFetchLimit,
+ 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('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,
+ 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);
+ });
+
+ 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
new file mode 100644
index 0000000..2cffdd8
--- /dev/null
+++ b/apps/web/src/lib/search-query.ts
@@ -0,0 +1,89 @@
+export const SEARCH_PAGE_SIZE = 20;
+
+/** ILIKE 와일드카드(% _)와 백슬래시를 이스케이프한다. */
+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();
+ if (!trimmed) return null;
+ return `%${escapeIlikePattern(trimmed)}%`;
+};
+
+/** PostgREST `.or()` 필터 문자열 (entries 텍스트 컬럼). */
+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 => {
+ const quoted = quotePostgrestFilterValue(pattern);
+ return [`symbol.ilike.${quoted}`, `name.ilike.${quoted}`].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;
+};
+
+/** 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,
+ 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(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 ?? '';
+};
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';
+};
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/architecture.md b/docs/architecture.md
index 48fb618..88a689f 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 (`관심종목` → `종목`).
@@ -120,14 +120,19 @@ shared는 `main`/`types`가 `dist/`를 가리키므로 **앱 실행 전 `pnpm --
### 4.2 Web (`apps/web`) — Phase 1
-| 화면 | Phase |
-|------|-------|
-| 랜딩 / 아카이브 안내 | 스캐폴드 (현재) |
-| 매직링크 로그인 | Phase 1 (앱과 동일 Supabase 프로젝트) |
+| 화면 | 상태 |
+|------|------|
+| 홈(관심종목 목록) | Phase 0 완료 |
+| Web Auth (로그인/회원가입/콜백) | Phase 0 완료 |
| entries 검색 (`q`, page size 20) | Phase 1 |
-| 종목 상세 + TradingView | Phase 1 |
+| 종목 상세 + 차트 US TradingView · KR fallback (타임라인 조회 · 엔트리 CRUD) | Phase 1 |
+
+검색 범위 (`docs/design.md`): `entries.body|note|reason|title|url` 및 ticker `symbol`/`name`에 ILIKE. 결과 = entry 리스트 → 종목 상세 링크.
+
+**차트 정책** (모바일과 동일, `packages/shared` `buildChartHtml`)
-Phase 0 동안 웹은 **스키마·디자인 계약만 맞춤**. 필수는 아님.
+- US: TradingView symbol embed (iframe `srcDoc`).
+- KR: embed 미지원·불안정 시 **fallback UI** (심볼·안내 문구).
---
@@ -378,3 +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(웹 검색·상세) 착수 준비 — Web·Mobile Auth Phase 0 완료로 표기 |
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 389382f..08ec47d 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,21 @@ flowchart TB
- `@supabase/ssr` + Next 16 `proxy.ts` 세션 갱신
- `/login` 매직링크, `/auth/callback` 코드 교환
-- 홈에서 동일 계정 관심종목 조회 (검색·상세는 Phase 1)
+- 홈에서 동일 계정 관심종목 조회
+
+### 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`
+- **2026-08-30** 실계정 스모크: 홈·검색·종목 상세·앱→웹 회수 **4항목** 수동 확인
### 1.7 테스트 · CI (2026-08-15)
@@ -191,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 브리핑
@@ -213,6 +227,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 +242,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 +258,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곳 배포
### 성과 / 임팩트
@@ -274,3 +291,7 @@ 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` 착수 |
+| 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 1749a60..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,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
+- `[x]` 앱 입력 → 웹 검색·종목 상세·CRUD **실계정 스모크 4항목** 수동 확인 (2026-08-30)
+- `[x]` 웹 검색 지연 **평균 ~1.2s** · 홈→검색 UX **~1.1s** (Playwright, `docs/assets/search-retrieval-metrics.json`)
### Phase 2 (스토어 = 이력서 임팩트 피크)
diff --git a/docs/testing.md b/docs/testing.md
index b63b9ee..5a910d4 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,20 +15,23 @@
| 레이어 | 대상 | 도구 | 하지 않는 것 |
|--------|------|------|----------------|
-| 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 | 매직링크 메일·실세션 (인박스 없음) |
+| 웹 E2E | 웹 사용자 플로우: 홈, 로그인, `/search` 비로그인 가드 | Playwright | 매직링크 메일·실세션 (인박스 없음) |
웹 페이지 플로우 = Playwright. 웹 RTL은 **props → 텍스트**인 순수 뷰만 (예: 로그인된 `HomeView` 종목). 라우팅·폼 제출·Auth는 E2E.
## 지금 허용된 테스트
-- `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` 비로그인 가드 (dev 서버에 Supabase placeholder env)
+
+Playwright `webServer`는 `NEXT_PUBLIC_SUPABASE_*` placeholder를 넣어 `configured=true`·세션 없음 상태를 만든다. CI에 실 Supabase/매직링크 세션은 없다.
모바일 화면(관심종목·상세)은 라우터 없이 마운트되지 않는다. 구현 mock으로 목록을 그리는 테스트는 하지 않고, Maestro E2E에서 다룬다.
@@ -48,9 +52,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가 더 넓은 게이트다.
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/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..39994c4 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -98,4 +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);
+});