Skip to content

Commit 7a4586c

Browse files
authored
Merge pull request #111 from profullstack/feat/watchlist-sparklines
feat(finance): watchlist sparklines (last week, Datatype font)
2 parents 5bbb425 + b7588b5 commit 7a4586c

5 files changed

Lines changed: 150 additions & 1 deletion

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* GET /api/finance/sparklines?symbols=NVDA,AAPL,…
3+
*
4+
* Returns last-week close samples per symbol for the tiny watchlist sparklines.
5+
* Paid-gated; uses the per-profile market-data provider (connected Alpaca →
6+
* Yahoo fallback) and read-through caches each symbol's samples.
7+
*/
8+
9+
import { NextRequest, NextResponse } from 'next/server';
10+
import { requireActiveSubscription } from '@/lib/subscription/guard';
11+
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
12+
import { getFallbackMarketDataProvider, type MarketDataProvider } from '@/lib/finance/market-data';
13+
import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile';
14+
import { readThrough, CANDLES_TTL_SECONDS } from '@/lib/finance/market-data/cache';
15+
import { parseSymbolList } from '@/lib/finance/watchlist';
16+
17+
export const dynamic = 'force-dynamic';
18+
19+
const MAX_SYMBOLS = 60;
20+
const MAX_POINTS = 24;
21+
22+
/** Downsample an array to at most `max` evenly-spaced points (keeps last). */
23+
function downsample(values: number[], max: number): number[] {
24+
if (values.length <= max) return values;
25+
const step = values.length / max;
26+
const out: number[] = [];
27+
for (let i = 0; i < max; i++) out.push(values[Math.min(values.length - 1, Math.floor(i * step))]);
28+
out[out.length - 1] = values[values.length - 1];
29+
return out;
30+
}
31+
32+
async function samplesFor(provider: MarketDataProvider, symbol: string): Promise<number[]> {
33+
return readThrough<number[]>(symbol, `sparkline:${provider.id}`, CANDLES_TTL_SECONDS, async () => {
34+
const candles = await provider.getCandles(symbol, '5D');
35+
return downsample(candles.map((c) => c.close), MAX_POINTS);
36+
});
37+
}
38+
39+
export async function GET(request: NextRequest): Promise<NextResponse> {
40+
const gate = await requireActiveSubscription(request);
41+
if (gate) return gate;
42+
43+
const { valid } = parseSymbolList(request.nextUrl.searchParams.get('symbols') ?? '');
44+
if (valid.length === 0) return NextResponse.json({ samples: {} });
45+
46+
const profileId = await getActiveProfileId();
47+
const provider = await getMarketDataProviderForProfile(profileId);
48+
const fallback = getFallbackMarketDataProvider();
49+
50+
const symbols = valid.slice(0, MAX_SYMBOLS);
51+
const entries = await Promise.all(
52+
symbols.map(async (symbol): Promise<[string, number[]]> => {
53+
try {
54+
return [symbol, await samplesFor(provider, symbol)];
55+
} catch {
56+
try {
57+
return [symbol, await samplesFor(fallback, symbol)];
58+
} catch {
59+
return [symbol, []];
60+
}
61+
}
62+
}),
63+
);
64+
65+
return NextResponse.json({ samples: Object.fromEntries(entries) });
66+
}

src/app/finance/finance-hub.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useRouter } from 'next/navigation';
99
import Link from 'next/link';
1010
import { normalizeSymbol } from '@/lib/finance/market-data/stooq';
1111
import { BrokerConnect } from './broker-connect';
12+
import { Sparkline } from '@/components/finance/sparkline';
1213

1314
const RECENT_KEY = 'finance:recent';
1415

@@ -26,6 +27,7 @@ export function FinanceHub(): React.ReactElement {
2627
const [bulk, setBulk] = useState('');
2728
const [bulkBusy, setBulkBusy] = useState(false);
2829
const [bulkMsg, setBulkMsg] = useState<string | null>(null);
30+
const [sparklines, setSparklines] = useState<Record<string, number[]>>({});
2931

3032
useEffect(() => {
3133
try {
@@ -47,6 +49,25 @@ export function FinanceHub(): React.ReactElement {
4749
loadWatchlist();
4850
}, [loadWatchlist]);
4951

52+
// Fetch last-week sparkline samples for the watchlist symbols (one batch call).
53+
const watchlistKey = watchlist.map((w) => w.symbol).join(',');
54+
useEffect(() => {
55+
if (!watchlistKey) {
56+
setSparklines({});
57+
return;
58+
}
59+
let cancelled = false;
60+
fetch(`/api/finance/sparklines?symbols=${encodeURIComponent(watchlistKey)}`, { cache: 'no-store' })
61+
.then((res) => (res.ok ? res.json() : { samples: {} }))
62+
.then((body: { samples?: Record<string, number[]> }) => {
63+
if (!cancelled) setSparklines(body.samples ?? {});
64+
})
65+
.catch(() => undefined);
66+
return () => {
67+
cancelled = true;
68+
};
69+
}, [watchlistKey]);
70+
5071
const addBulk = useCallback(
5172
async (e: React.FormEvent) => {
5273
e.preventDefault();
@@ -164,7 +185,10 @@ export function FinanceHub(): React.ReactElement {
164185
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
165186
{watchlist.map((row) => (
166187
<Link key={row.id} href={`/finance/ticker/${row.symbol}`} className="card p-4 hover:bg-bg-tertiary">
167-
<div className="text-lg font-semibold text-text-primary">{row.symbol}</div>
188+
<div className="flex items-center justify-between gap-2">
189+
<div className="text-lg font-semibold text-text-primary">{row.symbol}</div>
190+
<Sparkline samples={sparklines[row.symbol]} width={56} />
191+
</div>
168192
{row.exchange ? <div className="text-xs text-text-muted">{row.exchange}</div> : null}
169193
</Link>
170194
))}

src/app/fonts/Datatype.woff2

80 KB
Binary file not shown.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { datatypeFont } from '@/lib/datatype-font';
2+
3+
/**
4+
* Inline price sparkline rendered via the Datatype variable font (ported from
5+
* b1dz.com). Syntax: `{l:v1,v2,…}` where each v is 0–100; the font's ligatures
6+
* substitute that text with a chart glyph. Color reflects last-vs-first
7+
* direction (green up / red down).
8+
*/
9+
export function Sparkline({
10+
samples,
11+
width = 60,
12+
}: {
13+
samples?: number[];
14+
width?: number;
15+
}): React.ReactElement {
16+
if (!samples || samples.length < 2) {
17+
return <span className="text-text-muted"></span>;
18+
}
19+
20+
const min = Math.min(...samples);
21+
const max = Math.max(...samples);
22+
const range = max - min;
23+
const normalized =
24+
range === 0 ? samples.map(() => 50) : samples.map((v) => Math.round(((v - min) / range) * 100));
25+
26+
const isUp = samples[samples.length - 1] >= samples[0];
27+
const colorClass = isUp ? 'text-green-400' : 'text-red-400';
28+
29+
return (
30+
<span
31+
className={`${datatypeFont.className} ${colorClass} inline-block`}
32+
style={{
33+
minWidth: width,
34+
fontSize: '1.4em',
35+
lineHeight: 1,
36+
fontVariationSettings: "'wdth' 75, 'wght' 500",
37+
fontFeatureSettings: "'calt' 1, 'liga' 1, 'dlig' 1",
38+
WebkitFontFeatureSettings: "'calt' 1, 'liga' 1, 'dlig' 1",
39+
}}
40+
>
41+
{`{l:${normalized.join(',')}}`}
42+
</span>
43+
);
44+
}

src/lib/datatype-font.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import localFont from 'next/font/local';
2+
3+
/**
4+
* Datatype variable font — OpenType ligatures render the literal text
5+
* `{l:v1,v2,…}` (each v 0–100) as an inline sparkline glyph at draw time.
6+
* Loaded once at module scope so it's preloaded and shares one hash.
7+
* (Ported from b1dz.com.)
8+
*/
9+
export const datatypeFont = localFont({
10+
src: '../app/fonts/Datatype.woff2',
11+
display: 'swap',
12+
weight: '100 900',
13+
preload: true,
14+
variable: '--font-datatype',
15+
});

0 commit comments

Comments
 (0)