feat: 优化当日收益计算方式

This commit is contained in:
hzm
2026-03-22 13:51:21 +08:00
parent 270bc3ab08
commit 73ce520573
9 changed files with 338 additions and 26 deletions

123
AGENTS.md Normal file
View File

@@ -0,0 +1,123 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-03-21T03:22:46Z
**Commit:** 270bc3a
**Branch:** main
## OVERVIEW
Real-time mutual fund valuation tracker (基估宝). Next.js 16 App Router, pure JavaScript (JSX, no TypeScript), static export to GitHub Pages. Glassmorphism UI with heavy custom CSS variables (3557-line globals.css). All data via JSONP/script injection to external Chinese financial APIs (天天基金, 东方财富, 腾讯财经). localStorage as primary database; Supabase for optional cloud sync.
## STRUCTURE
```
real-time-fund/
├── app/ # Next.js App Router root
│ ├── page.jsx # MONOLITHIC SPA entry (~3000+ lines) — ALL state + logic here
│ ├── layout.jsx # Root layout (theme init, PWA, GA, Toaster)
│ ├── globals.css # Tailwind v4 + glassmorphism CSS variables (~3557 lines)
│ ├── api/fund.js # ALL external data fetching (~954 lines, JSONP + script injection)
│ ├── components/ # 47 app-specific UI components (modals, cards, tables, charts)
│ ├── lib/ # Core utilities: supabase, cacheRequest, tradingCalendar, valuationTimeseries
│ ├── hooks/ # Custom hooks: useBodyScrollLock, useFundFuzzyMatcher
│ └── assets/ # Static images (GitHub SVG, donation QR codes)
├── components/ui/ # 15 shadcn/ui primitives (accordion, button, dialog, drawer, etc.)
├── lib/utils.js # cn() helper only (clsx + tailwind-merge)
├── public/ # Static: allFund.json, PWA manifest, service worker, icon
├── doc/ # Documentation: localStorage schema, Supabase SQL, dev group QR
├── .github/workflows/ # CI/CD: nextjs.yml (GitHub Pages), docker-ci.yml (Docker build)
├── .husky/ # Pre-commit: lint-staged → ESLint
├── Dockerfile # Multi-stage: Node 22 build → Nginx Alpine serve
├── docker-compose.yml # Docker Compose config
├── entrypoint.sh # Runtime env var placeholder replacement
├── nginx.conf # Nginx config (port 3000, SPA fallback)
├── next.config.js # Static export, reactStrictMode, reactCompiler
├── jsconfig.json # Path aliases: @/* → ./*
├── eslint.config.mjs # ESLint flat config: next/core-web-vitals
├── postcss.config.mjs # Tailwind v4 PostCSS plugin
├── components.json # shadcn/ui config (new-york, JSX, RSC)
└── package.json # Node >= 20.9.0, lint-staged, husky
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Fund valuation logic | `app/api/fund.js` | JSONP to 天天基金, script injection to 腾讯财经 |
| Main UI orchestration | `app/page.jsx` | Monolithic — all useState, business logic, rendering |
| Fund card display | `app/components/FundCard.jsx` | Individual fund card with holdings |
| Desktop table | `app/components/PcFundTable.jsx` | PC-specific table layout |
| Mobile table | `app/components/MobileFundTable.jsx` | Mobile-specific layout, swipe actions |
| Holding calculations | `app/page.jsx` (getHoldingProfit) | Profit/loss computation |
| Cloud sync | `app/lib/supabase.js` + page.jsx sync functions | Supabase auth + data sync |
| Trading/DCA | `app/components/TradeModal.jsx`, `DcaModal.jsx` | Buy/sell, dollar-cost averaging |
| Fund fuzzy search | `app/hooks/useFundFuzzyMatcher.js` | Fuse.js based name/code matching |
| OCR import | `app/page.jsx` (processFiles) | Tesseract.js + LLM parsing |
| Valuation intraday chart | `app/lib/valuationTimeseries.js` | localStorage time-series |
| Trading calendar | `app/lib/tradingCalendar.js` | Chinese holiday detection via CDN |
| Request caching | `app/lib/cacheRequest.js` | In-memory cache with dedup |
| UI primitives | `components/ui/` | shadcn/ui — accordion, dialog, drawer, select, etc. |
| Global styles | `app/globals.css` | CSS variables, glassmorphism, responsive |
| CI/CD | `.github/workflows/nextjs.yml` | Build + deploy to GitHub Pages |
| Docker | `Dockerfile`, `docker-compose.yml` | Multi-stage build with runtime env injection |
| localStorage schema | `doc/localStorage 数据结构.md` | Full documentation of stored data shapes |
| Supabase schema | `doc/supabase.sql` | Database tables for cloud sync |
## CONVENTIONS
- **JavaScript only** — no TypeScript. `tsx: false` in shadcn config.
- **No src/ directory** — app/, components/, lib/ at root level.
- **Static export** — `output: 'export'` in next.config.js. No server-side runtime.
- **JSONP + script injection** — all external API calls bypass CORS via `<script>` tags, not fetch().
- **localStorage-first** — all user data stored locally; Supabase sync is optional/secondary.
- **Monolithic page.jsx** — entire app state and logic in one file (~3000+ lines). No state management library.
- **Dual responsive layouts** — `PcFundTable` and `MobileFundTable` switch at 640px breakpoint.
- **shadcn/ui conventions** — new-york style, CSS variables enabled, Lucide icons, path aliases (`@/components`, `@/lib/utils`).
- **Linting only** — ESLint + lint-staged on pre-commit. No Prettier, no auto-formatting.
- **React Compiler** — `reactCompiler: true` in next.config.js (experimental auto-memoization).
## ANTI-PATTERNS (THIS PROJECT)
- **No test infrastructure** — zero test files, no test framework, no test scripts.
- **Dual ESLint configs** — both `.eslintrc.json` (legacy) and `eslint.config.mjs` (flat) exist. Flat config is active.
- **`--legacy-peer-deps`** — Dockerfile uses this flag, indicating peer dependency conflicts.
- **Console statements** — 20 console.error/warn/log across codebase (mostly error logging in page.jsx).
- **2 eslint-disable comments** — `no-await-in-loop` in MobileFundTable, `react-hooks/exhaustive-deps` in HoldingEditModal.
- **Hardcoded API keys** — `app/api/fund.js` lines 911-914 contain plaintext API keys for LLM service.
- **Empty catch blocks** — several `catch (e) {}` blocks that swallow errors silently.
## UNIQUE STYLES
- **Glassmorphism design** — frosted glass effect via `backdrop-filter: blur()` + semi-transparent backgrounds.
- **CSS variable system** — 50+ CSS custom properties for colors, spacing, transitions in globals.css.
- **Runtime env injection** — Docker entrypoint replaces `__PLACEHOLDER__` strings in static JS/HTML at container start.
- **JSONP everywhere** — financial APIs (天天基金, 腾讯财经) accessed via script tag injection, not fetch().
- **OCR + LLM import** — Tesseract.js OCR → LLM text parsing → fund code extraction.
- **Multiple IDE configs** — .cursor/, .qoder/, .trae/ directories suggest active AI-assisted development.
## COMMANDS
```bash
# Development
npm run dev # Start dev server (localhost:3000)
npm run build # Static export to out/
npm run lint # ESLint check
npm run lint:fix # ESLint auto-fix
# Docker
docker build -t real-time-fund .
docker run -d -p 3000:3000 --env-file .env real-time-fund
docker compose up -d
# Environment
cp env.example .env.local # Copy template, fill NEXT_PUBLIC_* values
```
## NOTES
- **Fund code format**: 6-digit numeric codes (e.g., 110022). Stored in localStorage key `localFunds`.
- **Data sources**: 天天基金 (valuation JSONP), 东方财富 (holdings HTML parsing), 腾讯财经 (stock quotes script injection).
- **Deployment**: GitHub Actions auto-deploys main → GitHub Pages. Also supports Vercel, Cloudflare Pages, Docker.
- **Node requirement**: >= 20.9.0 (enforced in package.json engines).
- **License**: AGPL-3.0 — derivative works must be open-sourced under same license.
- **Chinese UI** — all user-facing text is Chinese (zh-CN). README is bilingual (Chinese primary).

38
app/api/AGENTS.md Normal file
View File

@@ -0,0 +1,38 @@
# app/api/ — Data Fetching Layer
## OVERVIEW
Single file (`fund.js`, ~954 lines) containing ALL external data fetching for the entire application. Pure client-side: JSONP + script tag injection to bypass CORS.
## WHERE TO LOOK
| Function | Purpose |
|----------|---------|
| `fetchFundData(code)` | Main fund data (valuation + NAV + holdings). Uses 天天基金 JSONP |
| `fetchFundDataFallback(code)` | Backup data source when primary fails |
| `fetchSmartFundNetValue(code, date)` | Smart NAV lookup with date fallback |
| `searchFunds(val)` | Fund search by name/code (东方财富) |
| `fetchFundHistory(code, range)` | Historical NAV data via pingzhongdata |
| `fetchFundPingzhongdata(code)` | Raw eastmoney pingzhongdata (trend, grand total) |
| `fetchMarketIndices()` | 24 A-share/HK/US indices via 腾讯财经 |
| `fetchShanghaiIndexDate()` | Shanghai index date for trading day check |
| `parseFundTextWithLLM(text)` | OCR text → fund codes via LLM (apis.iflow.cn) |
| `loadScript(url)` | JSONP helper — creates script tag, waits for global var |
| `fetchRelatedSectors(code)` | Fund sector/track info (unused in main UI) |
## CONVENTIONS
- **JSONP pattern**: `loadScript(url)` → sets global callback → script.onload → reads `window.XXX` → cleanup
- **All functions return Promises** — async/await throughout
- **Cached via `cachedRequest()`** from `app/lib/cacheRequest.js`
- **Error handling**: try/catch returning null/empty — never throws to UI
- **Market indices**: `MARKET_INDEX_KEYS` array defines 24 indices with `code`, `varKey`, `name`
- **Stock code normalization**: `normalizeTencentCode()` handles A-share (6-digit), HK (5-digit), US (letter codes)
## ANTI-PATTERNS (THIS DIRECTORY)
- **Hardcoded API keys** (lines 911-914) — plaintext LLM service keys in source
- **Empty catch blocks** — several `catch (e) {}` silently swallowing errors
- **Global window pollution** — JSONP callbacks assigned to `window.jsonpgz`, `window.SuggestData_*`, etc.
- **No retry logic** — failed requests return null, no exponential backoff
- **Script cleanup race conditions** — scripts removed from DOM after onload/onerror, but timeout may trigger after removal

View File

@@ -155,6 +155,38 @@ const parseLatestNetValueFromLsjzContent = (content) => {
return null; return null;
}; };
/**
* 解析历史净值数据(支持多条记录)
* 返回按日期升序排列的净值数组
*/
const parseNetValuesFromLsjzContent = (content) => {
if (!content || content.includes('暂无数据')) return [];
const rowMatches = content.match(/<tr[\s\S]*?<\/tr>/gi) || [];
const results = [];
for (const row of rowMatches) {
const cells = row.match(/<td[^>]*>(.*?)<\/td>/gi) || [];
if (!cells.length) continue;
const getText = (td) => td.replace(/<[^>]+>/g, '').trim();
const dateStr = getText(cells[0] || '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
const navStr = getText(cells[1] || '');
const nav = parseFloat(navStr);
if (!Number.isFinite(nav)) continue;
let growth = null;
for (const c of cells) {
const txt = getText(c);
const m = txt.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
if (m) {
growth = parseFloat(m[1]);
break;
}
}
results.push({ date: dateStr, nav, growth });
}
// 返回按日期升序排列的结果API返回的是倒序需要反转
return results.reverse();
};
const extractHoldingsReportDate = (html) => { const extractHoldingsReportDate = (html) => {
if (!html) return null; if (!html) return null;
@@ -316,16 +348,19 @@ export const fetchFundData = async (c) => {
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
}; };
const lsjzPromise = new Promise((resolveT) => { const lsjzPromise = new Promise((resolveT) => {
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`; const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=2&sdate=&edate=`;
loadScript(url) loadScript(url)
.then((apidata) => { .then((apidata) => {
const content = apidata?.content || ''; const content = apidata?.content || '';
const latest = parseLatestNetValueFromLsjzContent(content); const navList = parseNetValuesFromLsjzContent(content);
if (latest && latest.nav) { if (navList.length > 0) {
const latest = navList[navList.length - 1];
const previousNav = navList.length > 1 ? navList[navList.length - 2] : null;
resolveT({ resolveT({
dwjz: String(latest.nav), dwjz: String(latest.nav),
zzl: Number.isFinite(latest.growth) ? latest.growth : null, zzl: Number.isFinite(latest.growth) ? latest.growth : null,
jzrq: latest.date jzrq: latest.date,
lastNav: previousNav ? String(previousNav.nav) : null
}); });
} else { } else {
resolveT(null); resolveT(null);
@@ -506,6 +541,7 @@ export const fetchFundData = async (c) => {
gzData.dwjz = tData.dwjz; gzData.dwjz = tData.dwjz;
gzData.jzrq = tData.jzrq; gzData.jzrq = tData.jzrq;
gzData.zzl = tData.zzl; gzData.zzl = tData.zzl;
gzData.lastNav = tData.lastNav;
} }
} }
resolve({ resolve({

56
app/components/AGENTS.md Normal file
View File

@@ -0,0 +1,56 @@
# app/components/ — UI Components
## OVERVIEW
47 app-specific React components (all client-side). Modals dominate (~26). Core display: FundCard, PcFundTable, MobileFundTable.
## STRUCTURE
```
app/components/
├── Core Display (6)
│ ├── FundCard.jsx # Individual fund card (valuation + holdings)
│ ├── PcFundTable.jsx # Desktop table layout
│ ├── MobileFundTable.jsx # Mobile list with swipe actions
│ ├── MobileFundCardDrawer.jsx# Mobile fund detail drawer
│ ├── GroupSummary.jsx # Group portfolio summary
│ └── MarketIndexAccordion.jsx# Market indices (24 A/HK/US)
├── Modals (26)
│ ├── Fund ops: AddFundToGroupModal, GroupManageModal, GroupModal, AddResultModal
│ ├── Trading: TradeModal, HoldingEditModal, HoldingActionModal, TransactionHistoryModal, PendingTradesModal, DcaModal, AddHistoryModal
│ ├── Settings: SettingsModal, MarketSettingModal, MobileSettingModal, PcTableSettingModal, SortSettingModal
│ ├── Auth: LoginModal, CloudConfigModal
│ ├── Scan: ScanPickModal, ScanProgressModal, ScanImportConfirmModal, ScanImportProgressModal
│ └── Misc: ConfirmModal, SuccessModal, DonateModal, FeedbackModal, WeChatModal, UpdatePromptModal, FundHistoryNetValueModal
├── Charts (3)
│ ├── FundIntradayChart.jsx # Intraday valuation chart (localStorage data)
│ ├── FundTrendChart.jsx # Fund trend chart (pingzhongdata)
│ └── FundHistoryNetValue.jsx # Historical NAV display
└── Utilities (7)
├── Icons.jsx # Custom SVG icons (Close, Eye, Moon, Sun, etc.)
├── Common.jsx # Shared UI helpers
├── FitText.jsx # Auto-fit text sizing
├── RefreshButton.jsx # Manual refresh control
├── EmptyStateCard.jsx # Empty state placeholder
├── Announcement.jsx # Banner announcement
├── ThemeColorSync.jsx # Theme meta tag sync
├── PwaRegister.jsx # Service worker registration
└── AnalyticsGate.jsx # Conditional GA loader
```
## CONVENTIONS
- **All client components** — `'use client'` at top, no server components
- **State from parent** — page.jsx manages ALL state; components receive props only
- **shadcn/ui primitives** — imported from `@/components/ui/*`
- **Mobile/Desktop switching** — parent passes `isMobile` prop; 640px breakpoint
- **Modals**: use `useBodyScrollLock(open)` hook for scroll prevention
- **Icons**: mix of custom SVG (Icons.jsx) + lucide-react
- **Styling**: glassmorphism via CSS variables (globals.css), no component-level CSS
## ANTI-PATTERNS (THIS DIRECTORY)
- **No prop drilling avoidance** — all state flows from page.jsx via props (30+ prop holes in FundCard)
- **Modal sprawl** — 26 modals could benefit from a modal manager/context
- **Swipe gesture duplication** — MobileFundTable and MobileFundCardDrawer both implement swipe logic
- **No loading skeletons** — components show spinners, not skeleton placeholders

View File

@@ -458,7 +458,7 @@ export default function MobileFundTable({
while (queue.length) { while (queue.length) {
const item = queue.shift(); const item = queue.shift();
if (item == null) continue; if (item == null) continue;
// eslint-disable-next-line no-await-in-loop
await worker(item); await worker(item);
} }
}); });
@@ -859,7 +859,6 @@ export default function MobileFundTable({
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted'; const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
const amountStr = hasProfit ? (info.getValue() ?? '') : '—'; const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
const percentStr = original.todayProfitPercent ?? ''; const percentStr = original.todayProfitPercent ?? '';
const isUpdated = original.isUpdated;
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}> <span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
@@ -867,7 +866,7 @@ export default function MobileFundTable({
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr} {masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
</FitText> </FitText>
</span> </span>
{percentStr && !isUpdated && !masked ? ( {percentStr && !masked ? (
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}> <span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}> <FitText maxFontSize={11} minFontSize={9}>
{percentStr} {percentStr}

View File

@@ -867,7 +867,7 @@ export default function PcFundTable({
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}> <FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr} {masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
</FitText> </FitText>
{percentStr && !isUpdated && !masked ? ( {percentStr && !masked ? (
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}> <span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}> <FitText maxFontSize={11} minFontSize={9}>
{percentStr} {percentStr}

28
app/lib/AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# app/lib/ — Core Utilities
## OVERVIEW
4 utility modules: Supabase client, request cache, trading calendar, valuation time-series.
## WHERE TO LOOK
| File | Exports | Purpose |
|------|---------|---------|
| `supabase.js` | `supabase`, `isSupabaseConfigured` | Supabase client (or noop fallback). Auth + DB + realtime |
| `cacheRequest.js` | `cachedRequest()`, `clearCachedRequest()` | In-memory request dedup + TTL cache |
| `tradingCalendar.js` | `loadHolidaysForYear()`, `loadHolidaysForYears()`, `isTradingDay()` | Chinese stock market holiday detection via CDN |
| `valuationTimeseries.js` | `recordValuation()`, `getValuationSeries()`, `clearFund()`, `getAllValuationSeries()` | Fund valuation time-series (localStorage) |
## CONVENTIONS
- **supabase.js**: creates `createNoopSupabase()` when env vars missing — all auth/DB methods return safe defaults
- **cacheRequest.js**: deduplicates concurrent requests for same key; default 10s TTL
- **tradingCalendar.js**: downloads `chinese-days` JSON from cdn.jsdelivr.net; caches per-year in Map
- **valuationTimeseries.js**: localStorage key `fundValuationTimeseries`; auto-clears old dates on new data
## ANTI-PATTERNS (THIS DIRECTORY)
- **No error reporting** — all modules silently fail (console.warn at most)
- **localStorage quota not handled** — valuationTimeseries writes without checking available space
- **Cache only in-memory** — cacheRequest lost on page reload; no persistent cache
- **No request cancellation** — JSONP scripts can't be aborted once injected

View File

@@ -600,26 +600,30 @@ export default function HomePage() {
if (canCalcTodayProfit) { if (canCalcTodayProfit) {
const amount = holding.share * currentNav; const amount = holding.share * currentNav;
// 优先用 zzl (真实涨跌幅), 降级用 gszzl // 优先使用昨日净值直接计算(更精确,避免涨跌幅四舍五入误差)
// 若 gztime 日期 > jzrq说明估值更新晚于净值日期优先使用 gszzl 计算当日盈亏 const lastNav = fund.lastNav != null && fund.lastNav !== '' ? Number(fund.lastNav) : null;
const gz = isString(fund.gztime) ? toTz(fund.gztime) : null; if (lastNav && Number.isFinite(lastNav) && lastNav > 0) {
const jz = isString(fund.jzrq) ? toTz(fund.jzrq) : null; profitToday = (currentNav - lastNav) * holding.share;
const preferGszzl =
!!gz &&
!!jz &&
gz.isValid() &&
jz.isValid() &&
gz.startOf('day').isAfter(jz.startOf('day'));
let rate;
if (preferGszzl) {
rate = Number(fund.gszzl);
} else { } else {
const zzl = fund.zzl !== undefined ? Number(fund.zzl) : Number.NaN; const gz = isString(fund.gztime) ? toTz(fund.gztime) : null;
rate = Number.isFinite(zzl) ? zzl : Number(fund.gszzl); const jz = isString(fund.jzrq) ? toTz(fund.jzrq) : null;
const preferGszzl =
!!gz &&
!!jz &&
gz.isValid() &&
jz.isValid() &&
gz.startOf('day').isAfter(jz.startOf('day'));
let rate;
if (preferGszzl) {
rate = Number(fund.gszzl);
} else {
const zzl = fund.zzl !== undefined ? Number(fund.zzl) : Number.NaN;
rate = Number.isFinite(zzl) ? zzl : Number(fund.gszzl);
}
if (!Number.isFinite(rate)) rate = 0;
profitToday = amount - (amount / (1 + rate / 100));
} }
if (!Number.isFinite(rate)) rate = 0;
profitToday = amount - (amount / (1 + rate / 100));
} else { } else {
profitToday = null; profitToday = null;
} }

28
components/ui/AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# components/ui/ — shadcn/ui Primitives
## OVERVIEW
15 shadcn/ui components (new-york style, JSX). Low-level primitives — do NOT modify manually.
## WHERE TO LOOK
```
accordion.jsx button.jsx dialog.jsx drawer.jsx
field.jsx input-otp.jsx label.jsx progress.jsx
radio-group.jsx select.jsx separator.jsx sonner.jsx
spinner.jsx switch.jsx tabs.jsx
```
## CONVENTIONS
- **Add via CLI**: `npx shadcn@latest add <component>` — never copy-paste manually
- **Style**: new-york, CSS variables enabled, neutral base color
- **Icons**: lucide-react
- **Path aliases**: `@/components/ui/*`, `@/lib/utils` (cn helper)
- **forwardRef pattern** — all components use React.forwardRef
- **Styling**: tailwind-merge via `cn()` in `lib/utils.js`
## ANTI-PATTERNS (THIS DIRECTORY)
- **Do not edit** — manual changes will be overwritten by shadcn CLI updates
- **No custom components here** — app-specific components belong in `app/components/`