diff --git a/docs/ui-interaction-and-motion.md b/docs/ui-interaction-and-motion.md index 41831dfb4..3c78e5d64 100644 --- a/docs/ui-interaction-and-motion.md +++ b/docs/ui-interaction-and-motion.md @@ -34,6 +34,22 @@ information should recede without becoming illegible. - Keep copy direct and operational. Lead with the state or object, then the explanation and next action. +News rows in `ui/src/pages/NewsPage.tsx` form a local-calendar-day timeline. +Time stays in the left gutter. The headline is a separate, prominent block above +the summary, which previews up to three lines. Editorial images align with the +headline on the right, with the source below; narrow screens place this image +and source beneath the text. Bordered market/topic labels and a compact +disclosure arrow follow the summary; expanding never hides the image. Flags +identify the supplied market classification, not a company's domicile or the +country mentioned in a headline. The local flag assets retain their license +under `ui/public/market/flags/`; unknown tags remain text. The current news +contract has no structured related-company identity or company-logo field. + +The news scroller reveals already-fetched results in batches of 40 when its +bottom sentinel approaches the viewport. Appending keeps the reading position; +changing a filter resets the batch and scroll position. This is not server +pagination: the existing query limit and refresh cadence remain unchanged. + The stable page hierarchy is: 1. global shell and activity rail; @@ -404,6 +420,35 @@ drill-in in a fresh copy of the same shell or mask the resulting remount with a transition. Session terminals and other heavy page content remain active-only unless their own lifecycle explicitly requires otherwise. +Market's quotes, news, boards, rotation and instrument views share the +`market` shell. News contributes content only; its grouped categories live +inside MarketSidebar and use SidebarRow rather than feature-colored buttons. +Category and news-view selection use separate URL parameters, so selecting an +importance or sentiment view does not discard the current category. Existing +source-tag matching is unchanged by this navigation and theme integration. +News category/view selections are adopted into one news tab and projected back +to the URL, including on a fresh load. Changing a selection does not create +another tab. Narrow screens use the same news directory in the Market drawer. +News initially mounts forty stories and reveals more on demand. Selection +changes reset the visible batch; full story text remains expandable. Polling +waits for an outstanding request instead of repeatedly replacing a slow load, +while explicit refresh, query changes and unmount cancel superseded requests. + +Market directory headings (News, Markets, Macro, Watchlist) are static captions. +Parent headings use the shared hierarchy variant (13px, medium weight), and +each child navigation level adds a 12px inset. Clickable categories and leaf +rows share 13px regular text and foreground color; gray is reserved for +secondary summaries and chevrons. Only News category groups disclose children, +with trailing chevrons. Only destination rows receive page selection +styling; a collapsed category group shows the selected category as a plain +summary. Restoring a News selection reveals its group, while users can still +collapse it manually. Search results appear immediately below the search field. +The shared Base UI Collapsible owns keyboard/ARIA and measured panel lifetime; +its 180ms height/opacity transition is disabled with reduced motion. Closing +panels become inert and aria-hidden immediately, including during animation. +The same directory and touch-sized controls serve the narrow-screen drawer. +No new persisted preference is added. + Keyboard focus is not a motion effect. Interactive controls still require a clear `focus-visible` treatment, meaningful labels, and sensible tab order. diff --git a/src/domain/news/collector/rss-parser.spec.ts b/src/domain/news/collector/rss-parser.spec.ts index 210e99f40..61d609773 100644 --- a/src/domain/news/collector/rss-parser.spec.ts +++ b/src/domain/news/collector/rss-parser.spec.ts @@ -127,8 +127,76 @@ describe('parseRSSXml', () => { const items = parseRSSXml(xml) expect(items[0].content).toBe('This is the full article text with much more detail.') }) -}) + it('extracts safe RSS image media without adding image to old items', () => { + const xml = ` + + Thumbnail article + + + + Enclosure article + + + + HTML article + Summary

]]>
+
+ No image +
` + const items = parseRSSXml(xml) + expect(items[0].image).toBe('https://cdn.example.com/thumb.jpg') + expect(items[1].image).toBe('https://cdn.example.com/article.png') + expect(items[2].image).toBe('https://cdn.example.com/summary.webp') + expect(items[3]).not.toHaveProperty('image') + }) + + it('rejects unsafe and non-image media', () => { + const xml = ` + + Unsafe + + ]]> + + + Not an image + + + + ` + + const items = parseRSSXml(xml) + expect(items[0]).not.toHaveProperty('image') + expect(items[1]).not.toHaveProperty('image') + }) + + it('accepts image media content and ignores non-image variants', () => { + const xml = ` + + Media article + + + + ` + + expect(parseRSSXml(xml)[0].image).toBe('https://cdn.example.com/photo.jpg') + }) + + it('does not confuse similarly named tags or attributes with the requested values', () => { + const xml = `Wrong + Right + Body + + ]]> + ` + + const items = parseRSSXml(xml) + expect(items).toHaveLength(1) + expect(items[0].title).toBe('Right') + expect(items[0].image).toBeUndefined() + }) + +}) // ==================== fetchAndParseFeed ==================== const MINIMAL_RSS = ` diff --git a/src/domain/news/collector/rss-parser.ts b/src/domain/news/collector/rss-parser.ts index 23fd2d659..20cd9abfc 100644 --- a/src/domain/news/collector/rss-parser.ts +++ b/src/domain/news/collector/rss-parser.ts @@ -2,7 +2,7 @@ * News Collector — Zero-dependency RSS / Atom parser * * Handles standard RSS 2.0 () and Atom () feeds. - * Extracts: title, description/summary, link, guid/id, pubDate. + * Extracts: title, description/summary, link, guid, pubDate, and safe image URLs. * Supports CDATA-wrapped content. */ @@ -12,6 +12,8 @@ export interface ParsedFeedItem { link: string | null guid: string | null pubDate: Date | null + /** Safe HTTP(S) image URL when the feed supplied one. */ + image?: string } /** @@ -48,17 +50,16 @@ export function parseRSSXml(xml: string): ParsedFeedItem[] { let match: RegExpExecArray | null while ((match = itemRegex.exec(xml)) !== null) { const block = match[1] - // For title & content: strip HTML first, then decode XML entities. - // This prevents <tag> from being decoded to then stripped as HTML. + const contentRaw = extractTagRaw(block, 'content:encoded') + ?? extractTagRaw(block, 'description') + ?? extractTagRaw(block, 'summary') + ?? extractTagRaw(block, 'content') + ?? '' + const image = extractImage(block, contentRaw) + items.push({ title: cleanText(extractTagRaw(block, 'title') ?? ''), - content: cleanText( - extractTagRaw(block, 'content:encoded') - ?? extractTagRaw(block, 'description') - ?? extractTagRaw(block, 'summary') - ?? extractTagRaw(block, 'content') - ?? '', - ), + content: cleanText(contentRaw), link: extractTag(block, 'link') ?? extractAttr(block, 'link', 'href'), guid: extractTag(block, 'guid') ?? extractTag(block, 'id'), pubDate: parseDate( @@ -66,6 +67,7 @@ export function parseRSSXml(xml: string): ParsedFeedItem[] { ?? extractTag(block, 'published') ?? extractTag(block, 'updated'), ), + ...(image ? { image } : {}), }) } @@ -81,7 +83,7 @@ export function parseRSSXml(xml: string): ParsedFeedItem[] { function extractTagRaw(xml: string, tag: string): string | null { // Try CDATA first: const cdataRegex = new RegExp( - `<${escapeRegex(tag)}[^>]*>\\s*\\s*`, + String.raw`<${escapeRegex(tag)}\b[^>]*>\s*\s*`, 'i', ) const cdataMatch = cdataRegex.exec(xml) @@ -89,13 +91,74 @@ function extractTagRaw(xml: string, tag: string): string | null { // Plain text: content const regex = new RegExp( - `<${escapeRegex(tag)}[^>]*>([\\s\\S]*?)`, + String.raw`<${escapeRegex(tag)}\b[^>]*>([\s\S]*?)`, 'i', ) const match = regex.exec(xml) return match ? match[1].trim() : null } +/** + * Find the first feed-provided image that is both an image media item and a + * safe remote URL. Feed metadata is trusted only after this boundary check. + */ +function extractImage(block: string, contentRaw: string): string | null { + const thumbnail = safeHttpImageUrl( + extractAttr(block, 'media:thumbnail', 'url') + ?? extractAttr(block, 'media:thumbnail', 'href'), + ) + if (thumbnail) return thumbnail + + for (const tag of ['enclosure', 'media:content']) { + const tagRegex = new RegExp(`<${escapeRegex(tag)}\\b[^>]*>`, 'gi') + let match: RegExpExecArray | null + while ((match = tagRegex.exec(block)) !== null) { + const tagText = match[0] + const url = extractAttr(tagText, tag, 'url') + if (!url || !isImageMedia(tagText, tag, url)) continue + const image = safeHttpImageUrl(url) + if (image) return image + } + } + + const nestedImage = extractTagRaw(block, 'image') + const imageUrl = nestedImage ? extractTag(nestedImage, 'url') : null + const feedImage = safeHttpImageUrl(imageUrl) + if (feedImage) return feedImage + + const htmlImage = /]*>/i.exec(decodeXmlEntities(contentRaw)) + return htmlImage ? safeHttpImageUrl(extractAttr(htmlImage[0], 'img', 'src')) : null +} + +function isImageMedia(tagText: string, tag: string, url: string): boolean { + const type = extractAttr(tagText, tag, 'type')?.toLowerCase() + const medium = extractAttr(tagText, tag, 'medium')?.toLowerCase() + if (medium && medium !== 'image') return false + if (type && !type.startsWith('image/')) return false + return Boolean(type || medium || hasImageExtension(url)) +} + +function hasImageExtension(url: string): boolean { + try { + return /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(new URL(url).pathname) + } catch { + return false + } +} + +function safeHttpImageUrl(raw: string | null): string | null { + if (!raw) return null + const url = decodeXmlEntities(raw.trim()) + try { + const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null + if (parsed.username || parsed.password) return null + return url + } catch { + return null + } +} + /** * Strip HTML tags first, then decode XML entities. * Order matters: <tag> → (strip: no-op) → decode → "" (preserved) @@ -110,7 +173,7 @@ function cleanText(raw: string): string { function extractTag(xml: string, tag: string): string | null { // Try CDATA first: const cdataRegex = new RegExp( - `<${escapeRegex(tag)}[^>]*>\\s*\\s*`, + String.raw`<${escapeRegex(tag)}\b[^>]*>\s*\s*`, 'i', ) const cdataMatch = cdataRegex.exec(xml) @@ -118,7 +181,7 @@ function extractTag(xml: string, tag: string): string | null { // Plain text: content const regex = new RegExp( - `<${escapeRegex(tag)}[^>]*>([\\s\\S]*?)`, + String.raw`<${escapeRegex(tag)}\b[^>]*>([\s\S]*?)`, 'i', ) const match = regex.exec(xml) @@ -130,9 +193,9 @@ function extractTag(xml: string, tag: string): string | null { * e.g. → "https://..." */ function extractAttr(xml: string, tag: string, attr: string): string | null { - const regex = new RegExp(`<${escapeRegex(tag)}[^>]*${attr}="([^"]*)"`, 'i') + const regex = new RegExp(String.raw`<${escapeRegex(tag)}\b[^>]*\s${escapeRegex(attr)}\s*=(?:"([^"]*)"|'([^']*)')`, 'i') const match = regex.exec(xml) - return match ? match[1] : null + return match ? (match[1] ?? match[2]) : null } /** diff --git a/src/domain/news/collector/rss.ts b/src/domain/news/collector/rss.ts index 01bd0c7cd..f3cb75187 100644 --- a/src/domain/news/collector/rss.ts +++ b/src/domain/news/collector/rss.ts @@ -126,6 +126,7 @@ export class NewsCollector { ingestSource: 'rss', dedupKey, ...(feed.categories ? { categories: feed.categories.join(',') } : {}), + ...(item.image ? { image: item.image } : {}), }, }) diff --git a/src/webui/routes/news.spec.ts b/src/webui/routes/news.spec.ts new file mode 100644 index 000000000..f4b949688 --- /dev/null +++ b/src/webui/routes/news.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import { createNewsRoutes } from './news.js' +import type { EngineContext } from '../../core/types.js' +import type { GetNewsV2Options, INewsProvider, NewsItem } from '../../domain/news/types.js' + +const BASE = new Date('2026-02-27T00:00:00Z').getTime() + +function item(index: number, fields: Partial = {}): NewsItem { + return { + id: index, + time: new Date(BASE + index * 3_600_000), + title: `Headline ${index}`, + content: `Content ${index}`, + metadata: { source: 'feed-a', categories: 'markets' }, + ...fields, + } +} + +function routes(getNewsV2: INewsProvider['getNewsV2']) { + return createNewsRoutes({ newsProvider: { getNewsV2 } } as unknown as EngineContext) +} + +describe('news routes', () => { + it('uses explicit timestamps instead of lookback and returns a nullable lookback', async () => { + const getNewsV2 = vi.fn(async () => [item(1)]) + const app = routes(getNewsV2) + const res = await app.request('/?startTime=2026-02-27T01:00:00.000Z&endTime=2026-02-27T05:00:00.000Z&lookback=bad') + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.lookback).toBeNull() + expect(getNewsV2).toHaveBeenCalledWith({ + startTime: new Date('2026-02-27T01:00:00.000Z'), + endTime: new Date('2026-02-27T05:00:00.000Z'), + limit: 50, + }) + }) + + it.each([ + ['/?startTime=not-a-date', 'startTime'], + ['/?endTime=not-a-date', 'endTime'], + ['/?startTime=2026-02-27T05:00:00.000Z&endTime=2026-02-27T01:00:00.000Z', 'startTime'], + ])('rejects invalid timestamp query (%s)', async (path) => { + const res = await routes(vi.fn(async () => [])).request(path) + expect(res.status).toBe(400) + }) + + it('applies literal keyword and symbol filters before the result limit', async () => { + const getNewsV2 = vi.fn(async (options: GetNewsV2Options) => { + expect(options.limit).toBeUndefined() + return [ + item(1, { title: 'Older target report', content: 'alpha' }), + item(2, { title: 'Noise', content: 'target' }), + item(3, { title: 'Target headline', content: 'AAPL outlook', metadata: { source: 'feed-a', categories: 'markets,us' } }), + item(4, { title: 'Newest target', content: 'AAPL update', metadata: { source: 'feed-a', categories: 'markets' } }), + ] + }) + const res = await routes(getNewsV2).request('/?keyword=TARGET&symbol=aapl&limit=1') + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.count).toBe(1) + expect(body.items[0].title).toBe('Newest target') + }) + + it('filters sources before limiting and only returns safe image URLs', async () => { + const getNewsV2 = vi.fn(async () => [ + item(1, { metadata: { source: 'other', image: 'https://example.com/old.jpg' } }), + item(2, { metadata: { source: 'feed-a', image: 'javascript:alert(1)' } }), + item(3, { metadata: { source: 'feed-a', image: 'https://example.com/news.png' } }), + ]) + const res = await routes(getNewsV2).request('/?source=FEED-A&limit=1') + + const body = await res.json() + expect(body.items).toHaveLength(1) + expect(body.items[0].title).toBe('Headline 3') + expect(body.items[0].image).toBe('https://example.com/news.png') + }) + + it('normalizes limits and orders filtered results newest-last', async () => { + const getNewsV2 = vi.fn(async () => [item(4), item(2), item(3)]) + const res = await routes(getNewsV2).request('/?keyword=headline&limit=1.9') + + expect(res.status).toBe(200) + expect((await res.json()).items.map((entry: { title: string }) => entry.title)).toEqual(['Headline 4']) + expect(getNewsV2).toHaveBeenCalledWith({ endTime: expect.any(Date), lookback: '24h' }) + }) + + it('treats an empty source list as no source filter', async () => { + const getNewsV2 = vi.fn(async () => [item(1), item(2)]) + const res = await routes(getNewsV2).request('/?source=,%20,') + + expect(res.status).toBe(200) + expect((await res.json()).count).toBe(2) + expect(getNewsV2).toHaveBeenCalledWith({ endTime: expect.any(Date), lookback: '24h', limit: 50 }) + }) +}) diff --git a/src/webui/routes/news.ts b/src/webui/routes/news.ts index f0dbda0f5..60d9b8a92 100644 --- a/src/webui/routes/news.ts +++ b/src/webui/routes/news.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' -import type { EngineContext } from '../../core/types.js' +import type { EngineContext } from '../../core/types.js' +import type { GetNewsV2Options, NewsItem } from '../../domain/news/types.js' const VALID_LOOKBACKS = new Set(['1h', '2h', '12h', '24h', '1d', '2d', '7d', '30d']) const DEFAULT_LOOKBACK = '24h' const DEFAULT_LIMIT = 50 @@ -15,37 +16,45 @@ export function createNewsRoutes(ctx: EngineContext) { return c.json({ error: 'News provider not available' }, 503) } + const startRaw = c.req.query('startTime') + const endRaw = c.req.query('endTime') + const startTime = parseQueryTime(startRaw) + const parsedEndTime = parseQueryTime(endRaw) + const endTime = parsedEndTime ?? new Date() + if (startRaw !== undefined && !startTime) { + return c.json({ error: 'Invalid startTime; expected an ISO timestamp' }, 400) + } + if (endRaw !== undefined && !parsedEndTime) { + return c.json({ error: 'Invalid endTime; expected an ISO timestamp' }, 400) + } + if (startTime && startTime.getTime() >= endTime.getTime()) { + return c.json({ error: 'startTime must be before endTime' }, 400) + } + const lookback = c.req.query('lookback') || DEFAULT_LOOKBACK - if (!VALID_LOOKBACKS.has(lookback)) { + if (!startTime && !VALID_LOOKBACKS.has(lookback)) { return c.json({ error: `Invalid lookback "${lookback}". Valid: ${[...VALID_LOOKBACKS].join(', ')}`, }, 400) } - const rawLimit = Number(c.req.query('limit')) || DEFAULT_LIMIT - const limit = Math.min(Math.max(1, rawLimit), MAX_LIMIT) - - const sourceFilter = c.req.query('source') || undefined - const sources = sourceFilter - ? new Set(sourceFilter.split(',').map((s) => s.trim().toLowerCase())) - : undefined - - let items = await ctx.newsProvider.getNewsV2({ - endTime: new Date(), - lookback, - limit: sources ? undefined : limit, - }) - - if (sources) { - items = items.filter((item) => { - const src = item.metadata.source?.toLowerCase() - return src != null && sources.has(src) - }) + const limit = parseLimit(c.req.query('limit')) + const sourceFilter = c.req.query('source') + const sourceValues = sourceFilter?.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean) ?? [] + const sources = sourceValues.length > 0 ? new Set(sourceValues) : undefined + const keyword = normalizedTerm(c.req.query('keyword')) + const symbol = normalizedTerm(c.req.query('symbol')) + const hasFilters = Boolean(sources || keyword || symbol) + const options: GetNewsV2Options = { + endTime, + ...(startTime ? { startTime } : { lookback }), + ...(hasFilters ? {} : { limit }), } - if (items.length > limit) { - items = items.slice(-limit) - } + let items = await ctx.newsProvider.getNewsV2(options) + items = items.filter((item) => matchesNewsFilters(item, sources, keyword, symbol)) + items.sort(compareNewsItems) + if (items.length > limit) items = items.slice(-limit) const shaped = items.map((item) => ({ time: item.time.toISOString(), @@ -54,10 +63,66 @@ export function createNewsRoutes(ctx: EngineContext) { source: item.metadata.source ?? null, link: item.metadata.link ?? null, categories: item.metadata.categories ?? null, + image: safeHttpImageUrl(item.metadata.image), })) - return c.json({ items: shaped, count: shaped.length, lookback }) + return c.json({ items: shaped, count: shaped.length, lookback: startTime ? null : lookback }) }) return app } + +function parseQueryTime(raw: string | undefined): Date | undefined { + if (raw === undefined || raw.trim() === '') return undefined + const value = new Date(raw) + return Number.isNaN(value.getTime()) ? undefined : value +} + +function parseLimit(raw: string | undefined): number { + if (raw === undefined || raw.trim() === '') return DEFAULT_LIMIT + const parsed = Number(raw) + if (!Number.isFinite(parsed)) return DEFAULT_LIMIT + return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(parsed))) +} + +function normalizedTerm(value: string | undefined): string | undefined { + const term = value?.trim().toLowerCase() + return term || undefined +} + +function matchesNewsFilters( + item: NewsItem, + sources: Set | undefined, + keyword: string | undefined, + symbol: string | undefined, +): boolean { + if (sources) { + const source = item.metadata.source?.toLowerCase() + if (!source || !sources.has(source)) return false + } + + const searchable = [ + item.title, + item.content, + item.metadata.categories ?? '', + ].join('\n').toLowerCase() + return (!keyword || searchable.includes(keyword)) && (!symbol || searchable.includes(symbol)) +} + +function compareNewsItems(a: NewsItem, b: NewsItem): number { + const timeDelta = a.time.getTime() - b.time.getTime() + if (Number.isFinite(timeDelta) && timeDelta !== 0) return timeDelta + if (a.id !== b.id) return a.id - b.id + return a.title < b.title ? -1 : a.title > b.title ? 1 : 0 +} + +function safeHttpImageUrl(raw: string | null | undefined): string | null { + if (!raw) return null + try { + const url = new URL(raw.trim()) + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) return null + return raw.trim() + } catch { + return null + } +} diff --git a/ui/public/market/flags/LICENSE b/ui/public/market/flags/LICENSE new file mode 100644 index 000000000..152dda1d6 --- /dev/null +++ b/ui/public/market/flags/LICENSE @@ -0,0 +1,24 @@ +Circle Flags — https://github.com/HatScripts/circle-flags +Source: https://github.com/HatScripts/circle-flags/tree/gh-pages/flags + +MIT License + +Copyright (c) 2026 HatScripts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ui/public/market/flags/cn.svg b/ui/public/market/flags/cn.svg new file mode 100644 index 000000000..a61d24ca4 --- /dev/null +++ b/ui/public/market/flags/cn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/market/flags/hk.svg b/ui/public/market/flags/hk.svg new file mode 100644 index 000000000..7b0510111 --- /dev/null +++ b/ui/public/market/flags/hk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/market/flags/us.svg b/ui/public/market/flags/us.svg new file mode 100644 index 000000000..e3560c6a5 --- /dev/null +++ b/ui/public/market/flags/us.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/api/news.ts b/ui/src/api/news.ts index 0e9e98fbd..10cf10771 100644 --- a/ui/src/api/news.ts +++ b/ui/src/api/news.ts @@ -1,12 +1,27 @@ import { fetchJson } from './client' import type { NewsListResponse } from './types' +export interface NewsQuery { + lookback?: string + limit?: number + source?: string + startTime?: string + endTime?: string + keyword?: string + symbol?: string +} + export const newsApi = { - async list(params?: { lookback?: string; limit?: number; source?: string }): Promise { + async list(params?: NewsQuery, signal?: AbortSignal): Promise { const qs = new URLSearchParams() if (params?.lookback) qs.set('lookback', params.lookback) - if (params?.limit) qs.set('limit', String(params.limit)) + if (params?.limit != null) qs.set('limit', String(params.limit)) if (params?.source) qs.set('source', params.source) - return fetchJson(`/api/news?${qs}`) + if (params?.startTime) qs.set('startTime', params.startTime) + if (params?.endTime) qs.set('endTime', params.endTime) + if (params?.keyword) qs.set('keyword', params.keyword) + if (params?.symbol) qs.set('symbol', params.symbol) + const query = qs.toString() + return fetchJson(`/api/news${query ? `?${query}` : ''}`, signal ? { signal } : undefined) }, } diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 9b6bb7fd4..bdad33a28 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -258,14 +258,16 @@ export interface NewsArticle { source: string | null link: string | null categories: string | null + image?: string | null } export interface NewsListResponse { items: NewsArticle[] count: number - lookback: string + lookback: string | null } + // ==================== Trading ==================== export type BrokerHealth = 'healthy' | 'degraded' | 'offline' diff --git a/ui/src/components/MarketSidebar.spec.tsx b/ui/src/components/MarketSidebar.spec.tsx index 65b4daffb..c1a67b9b5 100644 --- a/ui/src/components/MarketSidebar.spec.tsx +++ b/ui/src/components/MarketSidebar.spec.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, useLocation } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BarSourceCandidate } from '../api/market' @@ -53,32 +55,40 @@ beforeEach(async () => { }) afterEach(cleanup) +function renderSidebar() { + return render() +} describe('MarketSidebar search keyboard controls', () => { - it('groups peer destinations by purpose without nesting boards under News', () => { - render() - const markets = screen.getByRole('group', { name: 'Markets' }) - const macro = screen.getByRole('group', { name: 'Macro' }) - const news = screen.getByRole('button', { name: 'News' }) - expect(news.closest('[role="group"]')).toBeNull() - expect(screen.getAllByRole('button')[0]).toBe(news) - expect(within(markets).getAllByRole('button').map((button) => button.textContent)).toEqual([ - 'Browse Markets', 'Movers', 'Sector Rotation', 'Term Structure', - ]) - expect(within(macro).getAllByRole('button').map((button) => button.textContent)).toEqual([ - 'Calendar', 'Macro', 'Global Macro', 'Fed', 'Shipping', - ]) - for (const group of [markets, macro]) { - expect(group.className).not.toContain('border-l') - for (const button of within(group).getAllByRole('button')) fireEvent.click(button) + it('selects news categories using the focused tab view despite a stale router location', () => { + useWorkspace.getState().openOrFocus({ kind: 'news', params: { view: 'important' } }) + function Location() { + const location = useLocation() + return {location.pathname + location.search} } - expect(getFocusedTab(useWorkspace.getState())?.spec).toEqual({ - kind: 'market-board', params: { board: 'shipping' }, - }) + render() + const categories = screen.getByRole('navigation', { name: 'News categories' }) + expect(within(categories).queryByRole('button', { name: 'US Stocks' })).toBeNull() + expect(within(categories).queryByRole('button', { name: 'Markets' })).toBeNull() + fireEvent.click(within(categories).getByRole('button', { name: 'Equity markets' })) + fireEvent.click(within(categories).getByRole('button', { name: 'US Stocks' })) + expect(screen.getByLabelText('Current route').textContent).toBe('/market/news?view=important&category=us') + fireEvent.click(within(categories).getByRole('button', { name: 'All news' })) + expect(screen.getByLabelText('Current route').textContent).toBe('/market/news?view=important') + }) + + it('reveals the selected category when an existing Market shell restores News', () => { + useWorkspace.getState().openOrFocus({ kind: 'market-list', params: {} }) + renderSidebar() + act(() => useWorkspace.getState().openOrFocus({ kind: 'news', params: { category: 'us', view: 'positive' } })) + const navigation = screen.getByRole('navigation', { name: 'News categories' }) + expect(within(navigation).getByRole('button', { name: 'US Stocks' }).getAttribute('aria-current')).toBe('page') + fireEvent.click(within(navigation).getByRole('button', { name: 'Equity markets' })) + expect(within(navigation).queryByRole('button', { name: 'US Stocks' })).toBeNull() }) it('opens the first exact provider when Enter is pressed', () => { - render() + renderSidebar() const search = screen.getByRole('textbox', { name: 'Search assets…' }) fireEvent.change(search, { target: { value: 'apple' } }) @@ -95,7 +105,7 @@ describe('MarketSidebar search keyboard controls', () => { }) it('moves the provider highlight with arrow keys before selecting', () => { - const view = render() + const view = renderSidebar() const search = screen.getByRole('textbox', { name: 'Search assets…' }) fireEvent.change(search, { target: { value: 'apple' } }) @@ -116,7 +126,7 @@ describe('MarketSidebar search keyboard controls', () => { }) it('clears an inline search with Escape', () => { - render() + renderSidebar() const search = screen.getByRole('textbox', { name: 'Search assets…' }) fireEvent.change(search, { target: { value: 'apple' } }) @@ -130,7 +140,7 @@ describe('MarketSidebar search keyboard controls', () => { useWatchlist.setState({ entries: [{ assetClass: 'equity', symbol: 'AAPL', addedAt: 1 }], }) - render() + renderSidebar() const remove = screen.getByRole('button', { name: 'Remove AAPL' }) expect(remove.className).not.toContain('opacity-0') @@ -141,14 +151,46 @@ describe('MarketSidebar search keyboard controls', () => { expect(getFocusedTab(useWorkspace.getState())).toBeNull() }) - it('opens News as a Market browse leaf', () => { - render() + it('opens News through its child without making the group heading navigate', () => { + function Location() { + const location = useLocation() + return {location.pathname} + } + render() + expect(screen.getByRole('heading', { name: 'News' })).toBeTruthy() + expect(screen.getByLabelText('Current route').textContent).toBe('/market') + fireEvent.click(screen.getByRole('button', { name: 'All news' })) + expect(screen.getByLabelText('Current route').textContent).toBe('/market/news') + }) - fireEvent.click(screen.getByRole('button', { name: 'News' })) + it('keeps directory headings static and preserves selection when a category group is collapsed', async () => { + const user = userEvent.setup() + useWorkspace.getState().openOrFocus({ kind: 'news', params: { category: 'us' } }) + renderSidebar() + const active = getFocusedTab(useWorkspace.getState()) + for (const label of ['News', 'Markets', 'Macro', 'Watchlist']) { + const group = screen.getByRole('group', { name: label }) + expect(within(group).getByRole('heading', { name: label })).toBeTruthy() + } + const toggle = screen.getByRole('button', { name: 'Equity markets' }) + toggle.focus() + await user.keyboard('{Enter}') + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toContain('US Stocks') + expect(toggle.getAttribute('aria-current')).toBeNull() + const navigation = screen.getByRole('navigation', { name: 'News categories' }) + expect(within(navigation).queryByRole('button', { name: 'US Stocks' })).toBeNull() + expect(getFocusedTab(useWorkspace.getState())).toBe(active) + await user.keyboard(' ') + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(within(navigation).getByRole('button', { name: 'US Stocks' }).getAttribute('aria-current')).toBe('page') + }) - expect(getFocusedTab(useWorkspace.getState())?.spec).toEqual({ - kind: 'news', - params: {}, - }) + it('places search results before the news directory', () => { + renderSidebar() + fireEvent.change(screen.getByRole('textbox', { name: 'Search assets…' }), { target: { value: 'apple' } }) + const headings = screen.getAllByRole('heading') + expect(headings[0].textContent).toContain(i18n.t('market.searchResults')) + expect(headings[1].textContent).toBe('News') }) }) diff --git a/ui/src/components/MarketSidebar.tsx b/ui/src/components/MarketSidebar.tsx index 7f88e36f8..401cc06c2 100644 --- a/ui/src/components/MarketSidebar.tsx +++ b/ui/src/components/MarketSidebar.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' import { X } from 'lucide-react' import { type AssetClass, type BarSourceCandidate } from '../api/market' import { useAssetSearch } from './market/useAssetSearch' @@ -11,6 +12,7 @@ import { SidebarSectionHeader } from './SidebarSectionHeader' import { Spinner } from './StateViews' import { Button } from './ui/button' import { inputClass } from './form' +import { NewsMarketNavigation } from './market/NewsMarketNavigation.js' const ASSET_CLASS_COLORS: Record = { equity: 'bg-primary/15 text-primary', @@ -40,8 +42,9 @@ function routeAssetClass(c: BarSourceCandidate['assetClass']): AssetClass { * * Search results are debounced 300ms. */ -export function MarketSidebar() { +export function MarketSidebar({ onNavigate }: { onNavigate?: () => void }) { const { t } = useTranslation() + const navigate = useNavigate() const [query, setQuery] = useState('') // Shared with the main search box — one search logic, no drift. const { results, loading } = useAssetSearch(query) @@ -53,7 +56,11 @@ export function MarketSidebar() { const watchlist = useWatchlist((s) => s.entries) const removeFromWatchlist = useWatchlist((s) => s.remove) - const openOrFocus = useWorkspace((s) => s.openOrFocus) + const openTab = useWorkspace((s) => s.openOrFocus) + const openOrFocus = (spec: ViewSpec) => { + openTab(spec) + onNavigate?.() + } const focusedSpec = useWorkspace((state) => getFocusedTab(state)?.spec) const isFocused = (kind: ViewSpec['kind']) => focusedSpec?.kind === kind @@ -90,7 +97,7 @@ export function MarketSidebar() { } return ( -
+
{/* Search box */}
-
- openOrFocus({ kind: 'news', params: {} })} - /> -
- {t('market.marketsSection')} +
+ {/* Search results — only when query is non-empty */} + {query.trim() && ( + <> + + {t('market.searchResults')}{loading ? ` (${t('common.searching')})` : results.length ? ` (${results.length})` : ''} + + {loading && ( +
+ + {t('common.searching')} +
+ )} + {!loading && results.length === 0 && ( +

{t('market.noMatches')}

+ )} + {results.map((c, index) => ( +
setHighlight(index)} + className={index === highlight ? 'bg-muted/70' : undefined} + > + + {c.symbol} + {c.name && {c.name}} + + } + active={isFocusedDetail(routeAssetClass(c.assetClass), c.symbol, c.barId)} + onClick={() => handleSelectResult(c)} + trail={} + /> +
+ ))} + + )} + + { + const next = new URLSearchParams() + if (focusedSpec?.kind === 'news' && focusedSpec.params.view) next.set('view', focusedSpec.params.view) + if (category) next.set('category', category) + else next.delete('category') + navigate({ pathname: '/market/news', search: next.toString() }) + onNavigate?.() + }} /> + openOrFocus({ kind: 'market-board', params: { board: 'term-structure' } })} /> -
-
- {t('market.macroSection')} + + openOrFocus({ kind: 'market-board', params: { board: 'shipping' } })} /> -
- - {/* Search results — only when query is non-empty */} - {query.trim() && ( - <> - - {t('market.searchResults')}{loading ? ` (${t('common.searching')})` : results.length ? ` (${results.length})` : ''} - - {loading && ( -
- - {t('common.searching')} -
- )} - {!loading && results.length === 0 && ( -

{t('market.noMatches')}

- )} - {results.map((c, index) => ( -
setHighlight(index)} - className={index === highlight ? 'bg-muted/70' : undefined} - > - - {c.symbol} - {c.name && {c.name}} - - } - active={isFocusedDetail(routeAssetClass(c.assetClass), c.symbol, c.barId)} - onClick={() => handleSelectResult(c)} - trail={} - /> -
- ))} - - )} + {/* Watchlist */} - {t('market.watchlist')}{watchlist.length ? ` (${watchlist.length})` : ''} + {watchlist.length === 0 ? (

{t('market.emptyWatchlistHint')} @@ -239,11 +247,27 @@ export function MarketSidebar() { /> )) )} +

) } +function MarketSection({ label, children, count }: { + label: string + children: ReactNode + count?: number +}) { + return ( +
+ {count} : undefined}> + {label} + +
{children}
+
+ ) +} + function AssetClassChip({ cls }: { cls: string }) { return ( diff --git a/ui/src/components/SidebarSectionHeader.tsx b/ui/src/components/SidebarSectionHeader.tsx index 86c5f63ca..4fab5e65e 100644 --- a/ui/src/components/SidebarSectionHeader.tsx +++ b/ui/src/components/SidebarSectionHeader.tsx @@ -7,14 +7,17 @@ import type { ReactNode } from 'react' export function SidebarSectionHeader({ children, trailing, + hierarchy = false, }: { children: ReactNode /** Optional right-aligned slot (e.g. a count). */ trailing?: ReactNode + /** Parent title above indented navigation, rather than a muted caption. */ + hierarchy?: boolean }) { return (
-

+

{children}

{trailing && {trailing}} diff --git a/ui/src/components/TabHost.tsx b/ui/src/components/TabHost.tsx index c44c25ba5..e83418be7 100644 --- a/ui/src/components/TabHost.tsx +++ b/ui/src/components/TabHost.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useWorkspace } from '../tabs/store' import { type Tab } from '../tabs/types' -import { getView, getViewShell } from '../tabs/registry' +import { getView, getViewShell, MarketArea } from '../tabs/registry' import { EmptyEditor } from './EmptyEditor' import { ChatPageShell } from '../pages/ChatPageShell' @@ -85,6 +85,10 @@ function TabFrame({ tab, visible }: { tab: Tab; visible: boolean }) { + ) : shell === 'market' ? ( + + + ) : ( )} diff --git a/ui/src/components/market/NewsMarketNavigation.tsx b/ui/src/components/market/NewsMarketNavigation.tsx new file mode 100644 index 000000000..6473aa67d --- /dev/null +++ b/ui/src/components/market/NewsMarketNavigation.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { SidebarRow } from '../SidebarRow.js' +import { SidebarSectionHeader } from '../SidebarSectionHeader.js' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js' +import { NEWS_CATEGORIES, NEWS_CATEGORY_GROUPS, type NewsCategoryId } from './news-categories.js' + +type NavigationProps = { + active?: boolean + category: string | null + onSelect: (category: NewsCategoryId | null) => void +} + +export function NewsMarketNavigation({ category, onSelect, active = true }: NavigationProps) { + const { t } = useTranslation() + const selectedCategory = NEWS_CATEGORIES.find((item) => item.id === category)?.id ?? null + return ( +
+ {t('nav.item.news')} + +
+ ) +} + +function CategoryGroup({ group, category, active, onSelect }: NavigationProps & { + group: typeof NEWS_CATEGORY_GROUPS[number] +}) { + const { t } = useTranslation() + const selected = active ? NEWS_CATEGORIES.find((item) => item.id === category && group.categories.some((id) => id === item.id)) : undefined + const [open, setOpen] = useState(Boolean(selected)) + useEffect(() => { + if (selected) setOpen(true) + }, [selected]) + return ( + + + {t(group.labelKey)} + {!open && selected && {t(selected.labelKey)}} + + + +
+ {group.categories.map((categoryId) => { + const item = NEWS_CATEGORIES.find((candidate) => candidate.id === categoryId)! + return onSelect(categoryId)} /> + })} +
+
+
+ ) +} diff --git a/ui/src/components/market/news-categories.ts b/ui/src/components/market/news-categories.ts new file mode 100644 index 000000000..8fbbf74a6 --- /dev/null +++ b/ui/src/components/market/news-categories.ts @@ -0,0 +1,30 @@ +export const NEWS_CATEGORIES = [ + { id: 'themes', labelKey: 'news.categoryThemes', tags: ['theme', 'themes', 'concept', 'concepts'] }, + { id: 'a-shares', labelKey: 'news.categoryAShares', tags: ['cn', 'china', 'a-share', 'a-shares', 'ashare'] }, + { id: 'chinext', labelKey: 'news.categoryChiNext', tags: ['chinext', 'gem'] }, + { id: 'star', labelKey: 'news.categoryStar', tags: ['star-market', 'star market', 'sci-tech-innovation-board'] }, + { id: 'bse', labelKey: 'news.categoryBse', tags: ['bse', 'beijing-stock-exchange'] }, + { id: 'neeq', labelKey: 'news.categoryNeeq', tags: ['neeq', 'new-third-board'] }, + { id: 'hk', labelKey: 'news.categoryHk', tags: ['hk', 'hong-kong', 'hong kong'] }, + { id: 'china-concepts', labelKey: 'news.categoryChinaConcepts', tags: ['china-concept', 'china-concepts', 'chinese-adr'] }, + { id: 'us', labelKey: 'news.categoryUs', tags: ['us', 'usa', 'united-states', 'wall-street'] }, + { id: 'ipo', labelKey: 'news.categoryIpo', tags: ['ipo', 'new-listing', 'new-stock'] }, + { id: 'industries', labelKey: 'news.categoryIndustries', tags: ['industry', 'industries', 'sector', 'sectors'] }, + { id: 'funds', labelKey: 'news.categoryFunds', tags: ['fund', 'funds', 'etf'] }, + { id: 'bonds', labelKey: 'news.categoryBonds', tags: ['bond', 'bonds', 'rates', 'fixed-income'] }, + { id: 'futures', labelKey: 'news.categoryFutures', tags: ['future', 'futures', 'commodity', 'commodities'] }, + { id: 'macro', labelKey: 'news.categoryMacro', tags: ['macro', 'economy', 'economic', 'world', 'geopolitics'] }, + { id: 'fx', labelKey: 'news.categoryFx', tags: ['fx', 'forex', 'currency', 'currencies'] }, + { id: 'wealth', labelKey: 'news.categoryWealth', tags: ['wealth', 'wealth-management'] }, + { id: 'options', labelKey: 'news.categoryOptions', tags: ['option', 'options'] }, + { id: 'warrants', labelKey: 'news.categoryWarrants', tags: ['warrant', 'warrants'] }, +] as const + +export type NewsCategoryId = typeof NEWS_CATEGORIES[number]['id'] + +export const NEWS_CATEGORY_GROUPS = [ + { labelKey: 'news.groupEquities', categories: ['a-shares', 'chinext', 'star', 'bse', 'neeq', 'hk', 'us'] }, + { labelKey: 'news.groupTopics', categories: ['themes', 'industries', 'ipo', 'china-concepts'] }, + { labelKey: 'news.groupAssets', categories: ['funds', 'bonds', 'futures', 'fx', 'options', 'warrants'] }, + { labelKey: 'news.groupMacro', categories: ['macro', 'wealth'] }, +] as const satisfies ReadonlyArray<{ labelKey: string; categories: readonly NewsCategoryId[] }> diff --git a/ui/src/components/ui/collapsible.tsx b/ui/src/components/ui/collapsible.tsx new file mode 100644 index 000000000..feebf6847 --- /dev/null +++ b/ui/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible' +import { cn } from '../../lib/utils' + +export const Collapsible = CollapsiblePrimitive.Root +export const CollapsibleTrigger = CollapsiblePrimitive.Trigger + +export function CollapsibleContent({ className, ...props }: CollapsiblePrimitive.Panel.Props) { + return +} diff --git a/ui/src/demo/fixtures/news.ts b/ui/src/demo/fixtures/news.ts index 92a462600..b9f7c9a45 100644 --- a/ui/src/demo/fixtures/news.ts +++ b/ui/src/demo/fixtures/news.ts @@ -11,7 +11,7 @@ export const demoNewsArticles: NewsArticle[] = [ 'Apple reported Q1 FY26 results showing services revenue growth of +9.1% YoY — the slowest in over six years and the third consecutive quarterly deceleration. Headline EPS beat consensus at $1.65 vs $1.50 estimates, but analysts are focusing on the services margin trajectory.', source: 'Reuters', link: 'https://example.com/aapl-q1', - categories: 'earnings', + categories: 'earnings,us,important,negative', }, { time: new Date(now - 2 * HOUR_MS).toISOString(), @@ -20,7 +20,7 @@ export const demoNewsArticles: NewsArticle[] = [ 'Nvidia rallied after hyperscaler capex comments suggested continued demand through 2027. Microsoft, Meta, and Alphabet collectively guided to $200B+ in 2026 AI capex.', source: 'Bloomberg', link: 'https://example.com/nvda-capex', - categories: 'markets', + categories: 'markets,us,positive', }, { time: new Date(now - 4 * HOUR_MS).toISOString(), @@ -29,7 +29,7 @@ export const demoNewsArticles: NewsArticle[] = [ 'May FOMC minutes echoed Chair Powell\'s post-meeting framing. No commitment on July cut; rate path remains data-dependent with attention on services CPI and shelter components.', source: 'WSJ', link: 'https://example.com/fomc-minutes', - categories: 'macro', + categories: 'macro,rates,important', }, { time: new Date(now - 8 * HOUR_MS).toISOString(), @@ -47,7 +47,7 @@ export const demoNewsArticles: NewsArticle[] = [ 'S&P 500 ETF closed at $516.20, a new all-time high. Internals improved — 78% of constituents above their 50-day MA, up from 64% last week.', source: 'CNBC', link: 'https://example.com/spy-record', - categories: 'markets', + categories: 'markets,us,positive', }, { time: new Date(now - 22 * HOUR_MS).toISOString(), @@ -56,6 +56,38 @@ export const demoNewsArticles: NewsArticle[] = [ 'Long-bond ETF TLT down 1.4% as the 10-year Treasury yield tested 4.60% intraday. May supply calendar and softer-than-expected demand at the 7Y auction cited.', source: 'Reuters', link: 'https://example.com/tlt-yields', - categories: 'rates', + categories: 'bonds,rates,us,negative', + }, + { + time: new Date(now - 1 * HOUR_MS).toISOString(), + title: 'CSI 300 advances as financials and industrials strengthen', + content: 'Mainland benchmarks moved higher in afternoon trade as large-cap financial and industrial names attracted fresh buying.', + source: 'Wind', + link: 'https://example.com/csi-300-advance', + categories: 'markets,cn,positive', + }, + { + time: new Date(now - 3 * HOUR_MS).toISOString(), + title: 'Hang Seng TECH rebounds with internet platforms', + content: 'Hong Kong technology shares recovered from the morning low as platform companies led a broader risk-on move.', + source: 'SCMP', + link: 'https://example.com/hstech-rebound', + categories: 'markets,hk,positive', + }, + { + time: new Date(now - 6 * HOUR_MS).toISOString(), + title: 'Dollar eases as traders reassess the next Fed move', + content: 'The dollar index pulled back while major currency pairs consolidated ahead of the next US inflation release.', + source: 'Reuters', + link: 'https://example.com/dollar-fed', + categories: 'macro,fx,negative', + }, + { + time: new Date(now - 10 * HOUR_MS).toISOString(), + title: 'WTI crude steadies after volatile inventory session', + content: 'Oil prices stabilized as traders weighed a larger inventory draw against a softer near-term demand outlook.', + source: 'Bloomberg', + link: 'https://example.com/wti-inventories', + categories: 'futures,commodities,energy,negative', }, ] diff --git a/ui/src/demo/handlers/newsList.spec.ts b/ui/src/demo/handlers/newsList.spec.ts new file mode 100644 index 000000000..85d1b0a75 --- /dev/null +++ b/ui/src/demo/handlers/newsList.spec.ts @@ -0,0 +1,54 @@ +// @vitest-environment jsdom + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { setupServer } from 'msw/node' + +import { demoNewsArticles } from '../fixtures/news' +import { newsListHandlers } from './newsList' + +const server = setupServer(...newsListHandlers) +const baseUrl = window.location.origin + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('demo News handlers', () => { + it('uses an explicit range as an exclusive-start, inclusive-end interval', async () => { + const start = demoNewsArticles.find((article) => article.title.startsWith('Hang Seng TECH'))! + const end = demoNewsArticles.find((article) => article.title.startsWith('NVDA'))! + const response = await fetch(`${baseUrl}/api/news?startTime=${encodeURIComponent(start.time)}&endTime=${encodeURIComponent(end.time)}`) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.lookback).toBeNull() + expect(body.items.map((article: { title: string }) => article.title)).toEqual(['NVDA gains 2.8% on data center capex commentary']) + }) + + it('applies production-supported lookbacks and literal text and source filters before limit', async () => { + const response = await fetch(`${baseUrl}/api/news?lookback=24h&source=Reuters&keyword=traders&limit=1`) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.count).toBe(1) + expect(body.items[0].title).toBe('Dollar eases as traders reassess the next Fed move') + expect(body.items[0].source).toBe('Reuters') + }) + + it('accepts the production-supported lookback values and orders results chronologically', async () => { + const response = await fetch(`${baseUrl}/api/news?lookback=2h`) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.lookback).toBe('2h') + expect(body.items.map((article: { title: string }) => article.title)).toEqual([ + 'CSI 300 advances as financials and industrials strengthen', + 'Apple Q1 services revenue grows 9.1%, slowest since 2019', + ]) + }) + + it('rejects empty timestamps like the production route', async () => { + const response = await fetch(`${baseUrl}/api/news?startTime=`) + expect(response.status).toBe(400) + }) +}) diff --git a/ui/src/demo/handlers/newsList.ts b/ui/src/demo/handlers/newsList.ts index 87862a95b..bcfe95f06 100644 --- a/ui/src/demo/handlers/newsList.ts +++ b/ui/src/demo/handlers/newsList.ts @@ -1,23 +1,101 @@ import { http, HttpResponse } from 'msw' -import { demoNewsArticles } from '../fixtures/news' + import type { NewsListResponse } from '../../api/types' +import { demoNewsArticles } from '../fixtures/news' + +const DEFAULT_LOOKBACK = '24h' +const DEFAULT_LIMIT = 50 +const MAX_LIMIT = 200 +const LOOKBACK_MS: Record = { + '1h': 60 * 60_000, + '2h': 2 * 60 * 60_000, + '12h': 12 * 60 * 60_000, + '24h': 24 * 60 * 60_000, + '1d': 24 * 60 * 60_000, + '2d': 2 * 24 * 60 * 60_000, + '7d': 7 * 24 * 60 * 60_000, + '30d': 30 * 24 * 60 * 60_000, +} + +function parseLookback(value: string): number | null { + return Object.hasOwn(LOOKBACK_MS, value) ? LOOKBACK_MS[value] : null +} + +function parseTime(value: string | null): number | null { + if (!value?.trim()) return null + const time = Date.parse(value) + return Number.isFinite(time) ? time : null +} + +function parseLimit(value: string | null): number { + if (!value?.trim()) return DEFAULT_LIMIT + const parsed = Number(value) + if (!Number.isFinite(parsed)) return DEFAULT_LIMIT + return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(parsed))) +} -/** - * News demo handler. - * - * `/api/news` returns `NewsListResponse = { items, count, lookback }` per - * ui/src/api/types.ts — NOT { articles, hasMore }. NewsPage does - * `setArticles(res.items)`; the wrong shape leaves articles=undefined and - * crashes the page on `[...articles].reverse()` in render. - */ +function includesText(article: { title: string; content: string; categories: string | null }, value: string): boolean { + const haystack = [article.title, article.content, article.categories ?? ''].join('\n').toLowerCase() + return haystack.includes(value) +} + +function badRequest(message: string) { + return HttpResponse.json({ error: message }, { status: 400 }) +} + +/** Mirrors the production News list contract, including range and text filters. */ export const newsListHandlers = [ http.get('/api/news', ({ request }) => { - const lookback = new URL(request.url).searchParams.get('lookback') ?? '24h' + const params = new URL(request.url).searchParams + const lookback = params.get('lookback') || DEFAULT_LOOKBACK + const startText = params.get('startTime') + const hasStart = startText !== null + const lookbackMs = hasStart ? null : parseLookback(lookback) + if (!hasStart && lookbackMs == null) return badRequest(`Invalid lookback "${lookback}"`) + + const endText = params.get('endTime') + const parsedEnd = parseTime(endText) + if (endText !== null && parsedEnd == null) return badRequest(`Invalid endTime "${endText}"`) + const endTime = parsedEnd ?? Date.now() + const parsedStart = parseTime(startText) + if (hasStart && parsedStart == null) return badRequest(`Invalid startTime "${startText}"`) + const startTime = parsedStart ?? endTime - (lookbackMs ?? 0) + if (startTime >= endTime) return badRequest('startTime must be before endTime') + + const source = params.get('source') + const sourceFilters = source + ?.split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + const keyword = params.get('keyword')?.trim().toLowerCase() ?? '' + const symbol = params.get('symbol')?.trim().toLowerCase() ?? '' + const limit = parseLimit(params.get('limit')) + + const filtered = demoNewsArticles.filter((article) => { + const articleTime = Date.parse(article.time) + if (!(articleTime > startTime && articleTime <= endTime)) return false + if (sourceFilters?.length && !sourceFilters.includes((article.source ?? '').toLowerCase())) return false + if (keyword && !includesText(article, keyword)) return false + if (symbol && !includesText(article, symbol)) return false + return true + }) + const items = filtered + .sort(compareNewsArticles) + .slice(-limit) + .map((article) => ({ ...article, image: article.image ?? null })) const body: NewsListResponse = { - items: demoNewsArticles, - count: demoNewsArticles.length, - lookback, + items, + count: items.length, + lookback: startText ? null : lookback, } return HttpResponse.json(body) }), ] + +function compareNewsArticles(a: { time: string; title: string }, b: { time: string; title: string }): number { + const aTime = Date.parse(a.time) + const bTime = Date.parse(b.time) + if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime + if (Number.isFinite(aTime) !== Number.isFinite(bTime)) return Number.isFinite(aTime) ? -1 : 1 + return a.title < b.title ? -1 : a.title > b.title ? 1 : 0 +} diff --git a/ui/src/hooks/useNewsFeed.spec.ts b/ui/src/hooks/useNewsFeed.spec.ts new file mode 100644 index 000000000..54ed5a41d --- /dev/null +++ b/ui/src/hooks/useNewsFeed.spec.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { NewsListResponse } from '../api/types' + +const mocks = vi.hoisted(() => ({ list: vi.fn() })) +vi.mock('../api', () => ({ api: { news: { list: mocks.list } } })) + +import { useNewsFeed } from './useNewsFeed' + +function response(title: string): NewsListResponse { + return { + items: [{ time: '2026-07-29T10:00:00.000Z', title, content: title, source: 'Reuters', link: null, categories: 'markets' }], + count: 1, + lookback: '24h', + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +beforeEach(() => { mocks.list.mockReset() }) +afterEach(() => { cleanup(); vi.useRealTimers(); vi.clearAllMocks() }) + +describe('useNewsFeed request lifecycle', () => { + it('aborts the previous request when a refresh supersedes it', async () => { + const first = deferred() + mocks.list.mockReturnValueOnce(first.promise).mockResolvedValueOnce(response('fresh')) + const hook = renderHook(() => useNewsFeed({ lookback: '24h', limit: 200 })) + + await waitFor(() => expect(mocks.list).toHaveBeenCalledOnce()) + const firstSignal = mocks.list.mock.calls[0][1] as AbortSignal + + act(() => { hook.result.current.refresh() }) + await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(2)) + + expect(firstSignal.aborted).toBe(true) + await waitFor(() => expect(hook.result.current.articles[0]?.title).toBe('fresh')) + hook.unmount() + }) + it('allows a slow initial request to finish across polling intervals', async () => { + vi.useFakeTimers() + const pending = deferred() + mocks.list.mockReturnValue(pending.promise) + const hook = renderHook(() => useNewsFeed({ lookback: '24h' })) + await act(async () => { await vi.advanceTimersByTimeAsync(120_000) }) + expect(mocks.list).toHaveBeenCalledOnce() + await act(async () => { pending.resolve(response('Slow but complete')); await pending.promise }) + expect(hook.result.current.loading).toBe(false) + expect(hook.result.current.articles[0]?.title).toBe('Slow but complete') + }) +}) diff --git a/ui/src/hooks/useNewsFeed.ts b/ui/src/hooks/useNewsFeed.ts new file mode 100644 index 000000000..37456fd38 --- /dev/null +++ b/ui/src/hooks/useNewsFeed.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { api } from '../api' +import type { NewsQuery } from '../api/news' +import type { NewsArticle } from '../api/types' + +const REFRESH_MS = 60_000 + +type Load = (isRefresh: boolean) => void + +export interface UseNewsFeed { + articles: NewsArticle[] + sources: string[] + loading: boolean + refreshing: boolean + loadError: string | null + refresh: () => void + retry: () => void +} + +function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause) +} + +function mergeSources(current: string[], articles: NewsArticle[]): string[] { + const next = new Set(current) + for (const article of articles) { + if (article.source) next.add(article.source) + } + return [...next] +} + +export function useNewsFeed(params: NewsQuery): UseNewsFeed { + const [articles, setArticles] = useState([]) + const [sources, setSources] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [loadError, setLoadError] = useState(null) + const mounted = useRef(false) + const queryEpoch = useRef(0) + const requestEpoch = useRef(0) + const hasLoaded = useRef(false) + const loadRef = useRef(() => undefined) + + const { + lookback, + limit, + source, + startTime, + endTime, + keyword, + symbol, + } = params + + useEffect(() => { + mounted.current = true + const epoch = ++queryEpoch.current + let disposed = false + let activeController: AbortController | null = null + const query: NewsQuery = { + ...(lookback === undefined ? {} : { lookback }), + ...(limit === undefined ? {} : { limit }), + ...(source === undefined ? {} : { source }), + ...(startTime === undefined ? {} : { startTime }), + ...(endTime === undefined ? {} : { endTime }), + ...(keyword === undefined ? {} : { keyword }), + ...(symbol === undefined ? {} : { symbol }), + } + + setArticles([]) + setLoading(true) + setRefreshing(false) + setLoadError(null) + hasLoaded.current = false + + const load: Load = (isRefresh) => { + activeController?.abort() + const controller = new AbortController() + activeController = controller + const request = ++requestEpoch.current + if (mounted.current && !disposed && epoch === queryEpoch.current) { + setLoadError(null) + if (isRefresh) setRefreshing(true) + else setLoading(true) + } + + void api.news.list(query, controller.signal).then((response) => { + if (!mounted.current || disposed || epoch !== queryEpoch.current || request !== requestEpoch.current) return + hasLoaded.current = true + setArticles(response.items) + setSources((current) => mergeSources(current, response.items)) + setLoadError(null) + }).catch((cause: unknown) => { + if (controller.signal.aborted) return + if (!mounted.current || disposed || epoch !== queryEpoch.current || request !== requestEpoch.current) return + setLoadError(errorMessage(cause)) + }).finally(() => { + if (!mounted.current || disposed || epoch !== queryEpoch.current || request !== requestEpoch.current) return + setLoading(false) + setRefreshing(false) + if (activeController === controller) activeController = null + }) + } + + loadRef.current = (isRefresh) => load(isRefresh) + load(false) + const interval = setInterval(() => { if (!activeController) load(true) }, REFRESH_MS) + + return () => { + disposed = true + activeController?.abort() + mounted.current = false + queryEpoch.current += 1 + requestEpoch.current += 1 + clearInterval(interval) + loadRef.current = () => undefined + } + }, [lookback, limit, source, startTime, endTime, keyword, symbol]) + + const refresh = useCallback(() => { + loadRef.current(hasLoaded.current) + }, []) + + return { articles, sources, loading, refreshing, loadError, refresh, retry: refresh } +} diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index ad22bf1ba..d31332f24 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2463,6 +2463,57 @@ export const en = { }, }, news: { + allNews: 'All news', + groupEquities: 'Equity markets', + groupTopics: 'Topics', + groupAssets: 'Other assets', + groupMacro: 'Macro & wealth', + pageDescription: 'A dense market-news stream with fast filtering across channels, dates, symbols, and keywords.', + filtersLabel: 'News filters', + startDate: 'Start date', + endDate: 'End date', + symbolFilter: 'Symbol', + keywordFilter: 'Keyword', + search: 'Search', + clear: 'Clear', + dateRangeError: 'Start date must be on or before end date.', + imageAlt: 'Thumbnail for {{title}}', + importantTag: 'IMPORTANT', + showMore: 'Show more', + showLess: 'Show less', + resultLimit: 'Showing {{count}} results', + liveStream: 'Live stream', + viewsLabel: 'News views', + categoriesLabel: 'News categories', + viewLatest: 'Latest', + viewImportant: 'Important', + viewPositive: 'Positive', + viewNegative: 'Negative', + viewWatchlist: 'Watchlist', + viewCategories: 'Categories', + categoryThemes: 'Themes', + categoryAShares: 'A-shares', + categoryChiNext: 'ChiNext', + categoryStar: 'STAR Market', + categoryBse: 'Beijing Stock Exchange', + categoryNeeq: 'NEEQ', + categoryHk: 'Hong Kong Stocks', + categoryChinaConcepts: 'China Concepts', + categoryUs: 'US Stocks', + categoryIpo: 'New Listings', + categoryIndustries: 'Industries', + categoryFunds: 'Funds', + categoryBonds: 'Bonds', + categoryFutures: 'Futures', + categoryMacro: 'Macro', + categoryFx: 'FX', + categoryWealth: 'Wealth', + categoryOptions: 'Options', + categoryWarrants: 'Warrants', + streamLabel: 'News stream', + refresh: 'Refresh news', + noSummary: 'No summary is available. Open the original article for the full report.', + lookback1h: '1 hour', lookback12h: '12 hours', lookback24h: '24 hours', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index 84549de64..1fe750da9 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -2431,6 +2431,57 @@ export const ja: Resources = { }, }, news: { + allNews: 'すべてのニュース', + groupEquities: '株式市場', + groupTopics: 'テーマ・業種', + groupAssets: 'その他の資産', + groupMacro: 'マクロ・資産形成', + pageDescription: 'チャンネル、日付、銘柄、キーワードで素早く絞り込める高密度な市場ニュースストリーム。', + filtersLabel: 'ニュースフィルター', + startDate: '開始日', + endDate: '終了日', + symbolFilter: '銘柄', + keywordFilter: 'キーワード', + search: '検索', + clear: 'クリア', + dateRangeError: '開始日は終了日以前にしてください。', + imageAlt: '{{title}} のサムネイル', + importantTag: '重要', + showMore: 'もっと見る', + showLess: '折りたたむ', + resultLimit: '{{count}} 件を表示', + liveStream: 'ライブ速報', + viewsLabel: 'ニュース表示', + categoriesLabel: 'ニュース分類', + viewLatest: '最新', + viewImportant: '重要', + viewPositive: '好材料', + viewNegative: '悪材料', + viewWatchlist: 'ウォッチリスト', + viewCategories: '分類', + categoryThemes: 'テーマ', + categoryAShares: 'A株', + categoryChiNext: '創業板', + categoryStar: '科創板', + categoryBse: '北京証券取引所', + categoryNeeq: '新三板', + categoryHk: '香港株', + categoryChinaConcepts: '中国関連株', + categoryUs: '米国株', + categoryIpo: '新規上場', + categoryIndustries: '業種', + categoryFunds: 'ファンド', + categoryBonds: '債券', + categoryFutures: '先物', + categoryMacro: 'マクロ', + categoryFx: '為替', + categoryWealth: '資産運用', + categoryOptions: 'オプション', + categoryWarrants: 'ワラント', + streamLabel: 'ニュース速報', + refresh: 'ニュースを更新', + noSummary: '概要はありません。全文は元記事を開いて確認してください。', + lookback1h: '1 時間', lookback12h: '12 時間', lookback24h: '24 時間', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index 5ac74806c..ef0f2b511 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -2438,6 +2438,57 @@ export const zhHant: Resources = { }, }, news: { + allNews: '全部資訊', + groupEquities: '股票市場', + groupTopics: '主題與產業', + groupAssets: '其他資產', + groupMacro: '總體與財富', + pageDescription: '高密度市場新聞流,支援按頻道、日期、標的和關鍵字快速篩選。', + filtersLabel: '新聞篩選', + startDate: '開始日期', + endDate: '結束日期', + symbolFilter: '標的', + keywordFilter: '關鍵字', + search: '搜尋', + clear: '清除', + dateRangeError: '開始日期必須早於或等於結束日期。', + imageAlt: '{{title}} 縮圖', + importantTag: '重要', + showMore: '顯示更多', + showLess: '顯示更少', + resultLimit: '顯示 {{count}} 筆結果', + liveStream: '即時快訊', + viewsLabel: '新聞檢視', + categoriesLabel: '新聞分類', + viewLatest: '最新', + viewImportant: '重要', + viewPositive: '正面', + viewNegative: '負面', + viewWatchlist: '自選股', + viewCategories: '分類', + categoryThemes: '題材', + categoryAShares: 'A股', + categoryChiNext: '創業板', + categoryStar: '科創板', + categoryBse: '北交所', + categoryNeeq: '新三板', + categoryHk: '港股', + categoryChinaConcepts: '中概股', + categoryUs: '美股', + categoryIpo: '新股', + categoryIndustries: '行業', + categoryFunds: '基金', + categoryBonds: '債券', + categoryFutures: '期貨', + categoryMacro: '總體經濟', + categoryFx: '外匯', + categoryWealth: '理財', + categoryOptions: '期權', + categoryWarrants: '權證', + streamLabel: '新聞快訊', + refresh: '重新整理新聞', + noSummary: '此則資訊沒有摘要,請開啟原文查看完整內容。', + lookback1h: '1 小時', lookback12h: '12 小時', lookback24h: '24 小時', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index c3ab0854e..d837c401b 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -2430,6 +2430,57 @@ export const zh: Resources = { }, }, news: { + allNews: '全部资讯', + groupEquities: '股票市场', + groupTopics: '主题与行业', + groupAssets: '其他资产', + groupMacro: '宏观与财富', + pageDescription: '高密度市场新闻流,支持按频道、日期、标的和关键词快速筛选。', + filtersLabel: '新闻筛选', + startDate: '开始日期', + endDate: '结束日期', + symbolFilter: '标的', + keywordFilter: '关键词', + search: '搜索', + clear: '清除', + dateRangeError: '开始日期必须早于或等于结束日期。', + imageAlt: '{{title}} 缩略图', + importantTag: '重要', + showMore: '显示更多', + showLess: '显示更少', + resultLimit: '显示 {{count}} 条结果', + liveStream: '实时快讯', + viewsLabel: '新闻视图', + categoriesLabel: '新闻分类', + viewLatest: '最新', + viewImportant: '重要', + viewPositive: '正面', + viewNegative: '负面', + viewWatchlist: '自选股', + viewCategories: '分类', + categoryThemes: '题材', + categoryAShares: 'A股', + categoryChiNext: '创业板', + categoryStar: '科创板', + categoryBse: '北交所', + categoryNeeq: '新三板', + categoryHk: '港股', + categoryChinaConcepts: '中概股', + categoryUs: '美股', + categoryIpo: '新股', + categoryIndustries: '行业', + categoryFunds: '基金', + categoryBonds: '债券', + categoryFutures: '期货', + categoryMacro: '宏观', + categoryFx: '外汇', + categoryWealth: '理财', + categoryOptions: '期权', + categoryWarrants: '权证', + streamLabel: '新闻快讯', + refresh: '刷新新闻', + noSummary: '该条资讯没有摘要,请打开原文查看完整内容。', + lookback1h: '1 小时', lookback12h: '12 小时', lookback24h: '24 小时', diff --git a/ui/src/index.css b/ui/src/index.css index 160c74f17..5fe66c943 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1725,3 +1725,19 @@ html[lang="ja"] .oa-onboarding-title { transition-duration: 0.01ms !important; } } + +/* Base UI owns measurement and mount lifetime; hidden panels leave the tab order. */ +.oa-collapsible-panel { + height: var(--collapsible-panel-height); + overflow: hidden; + opacity: 1; + transition: height 180ms var(--motion-ease-out), opacity 180ms var(--motion-ease-out); +} +.oa-collapsible-panel[data-starting-style], +.oa-collapsible-panel[data-ending-style] { + height: 0; + opacity: 0; +} +@media (prefers-reduced-motion: reduce) { + .oa-collapsible-panel { transition: none; } +} diff --git a/ui/src/pages/NewsPage.navigation.spec.tsx b/ui/src/pages/NewsPage.navigation.spec.tsx new file mode 100644 index 000000000..c2a35cfe0 --- /dev/null +++ b/ui/src/pages/NewsPage.navigation.spec.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { BrowserRouter, useNavigate } from 'react-router-dom' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { UrlAdopter } from '../tabs/UrlAdopter' +import { useWorkspace } from '../tabs/store' +import type { ViewSpec } from '../tabs/types' +import { NewsPage } from './NewsPage' +import { i18n } from '../i18n' + +vi.mock('../api', () => ({ api: { news: { list: vi.fn(async () => ({ + items: [ + { time: '2026-09-06T00:00:00Z', title: 'US positive', content: 'x', source: 'test', link: null, categories: 'us,positive' }, + { time: '2026-09-06T00:00:00Z', title: 'US negative', content: 'x', source: 'test', link: null, categories: 'us,negative' }, + { time: '2026-09-06T00:00:00Z', title: 'Macro negative', content: 'x', source: 'test', link: null, categories: 'macro,negative' }, + ], count: 3, lookback: '24h', +})) } } })) +vi.mock('../hooks/useAliceProject', () => ({ + useAliceProject: () => ({ project: { product: 'trader' }, loading: false }), +})) +// Keep the real Router, adopter, and tab store; avoid loading unrelated page modules. +vi.mock('../tabs/registry', () => ({ getView: () => ({ + toUrl: (spec: ViewSpec) => spec.kind === 'news' + ? '/market/news?' + new URLSearchParams(spec.params) + : '/chat', +}) })) + +function Host() { + const navigate = useNavigate() + const spec = useWorkspace((state) => state.tree.kind === 'leaf' + ? state.tabs[state.tree.group.activeTabId ?? '']?.spec : undefined) + return <> + + {spec?.kind === 'news' && } + +} + +beforeEach(async () => { + window.localStorage.clear() + useWorkspace.setState({ + tabs: {}, + tree: { kind: 'leaf', group: { id: 'g1', tabIds: [], activeTabId: null } }, + focusedGroupId: 'g1', selectedSidebar: null, + }) + await i18n.changeLanguage('en') +}) +afterEach(cleanup) + +it('restores a saved News selection and changes views on the news route after returning', async () => { + window.history.replaceState({}, '', '/market/news?category=us&view=positive') + render() + await screen.findByRole('heading', { name: 'US positive' }) + const saved = Object.values(useWorkspace.getState().tabs).find((tab) => tab.spec.kind === 'news')! + + fireEvent.click(screen.getByRole('button', { name: 'Leave news' })) + expect(screen.queryByRole('heading', { name: 'US positive' })).toBeNull() + act(() => useWorkspace.getState().openOrFocus(saved.spec)) + await screen.findByRole('heading', { name: 'US positive' }) + await waitFor(() => expect(window.location.search).toBe('?category=us&view=positive')) + expect(screen.queryByRole('heading', { name: 'US negative' })).toBeNull() + expect(screen.queryByRole('heading', { name: 'Macro negative' })).toBeNull() + + fireEvent.click(within(screen.getByRole('navigation', { name: 'News views' })).getByRole('button', { name: 'Latest' })) + expect(window.location.pathname).toBe('/market/news') + expect(window.location.search).toBe('?category=us') + expect(await screen.findByRole('heading', { name: 'US negative' })).toBeTruthy() + expect(screen.queryByRole('heading', { name: 'Macro negative' })).toBeNull() + expect(Object.values(useWorkspace.getState().tabs).filter((tab) => tab.spec.kind === 'news')).toHaveLength(1) +}) diff --git a/ui/src/pages/NewsPage.spec.tsx b/ui/src/pages/NewsPage.spec.tsx index f191f99c7..c994c1036 100644 --- a/ui/src/pages/NewsPage.spec.tsx +++ b/ui/src/pages/NewsPage.spec.tsx @@ -1,324 +1,244 @@ // @vitest-environment jsdom -import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' +import { act, cleanup, fireEvent, render as renderView, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter, useSearchParams } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - +import type { NewsListResponse } from '../api' import { i18n } from '../i18n' import { NewsPage } from './NewsPage' -const mocks = vi.hoisted(() => ({ - list: vi.fn(), +const mocks = vi.hoisted(() => ({ list: vi.fn(), watchlist: [] as Array<{ assetClass: string; symbol: string; addedAt: number }> })) +vi.mock('../api', () => ({ api: { news: { list: mocks.list } } })) +vi.mock('../tabs/watchlist-store', () => ({ + useWatchlist: (selector: (state: { entries: typeof mocks.watchlist }) => unknown) => selector({ entries: mocks.watchlist }), })) - -function newsResponse(title: string, lookback = '24h') { - return { - items: [{ - time: '2026-07-29T10:00:00.000Z', - title, - content: `${title} content`, - source: 'Reuters', - link: null, - categories: null, - }], - count: 1, - lookback, - } +function RoutedNewsPage() { + const [search] = useSearchParams() + return +} +function render(node: ReactNode, entry = '/market/news') { + return renderView({node}) } +function newsResponse(title: string, lookback = '24h'): NewsListResponse { + return { items: [{ time: '2026-07-29T10:00:00.000Z', title, content: `${title} content`, source: 'Reuters', link: null, categories: 'markets,us' }], count: 1, lookback } +} function deferred() { let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, resolve, reject } + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } } -vi.mock('../api', () => ({ - api: { - news: { - list: mocks.list, - }, - }, -})) - beforeEach(async () => { mocks.list.mockReset() + mocks.watchlist = [] await i18n.changeLanguage('en') mocks.list.mockResolvedValue({ items: [ - { - time: '2026-07-29T08:00:00.000Z', - title: 'Middle update', - content: 'Middle content', - source: 'Reuters', - link: null, - categories: null, - }, - { - time: '2026-07-29T10:00:00.000Z', - title: 'Newest update', - content: 'Newest content', - source: 'Bloomberg', - link: 'https://example.com/newest', - categories: null, - }, - { - time: '2026-07-29T06:00:00.000Z', - title: 'Oldest update', - content: 'Oldest content', - source: 'CNBC', - link: null, - categories: null, - }, - ], - count: 3, - lookback: '24h', - }) -}) - -afterEach(() => { - cleanup() - vi.clearAllMocks() - vi.restoreAllMocks() -}) - -describe('NewsPage ordering', () => { - it('shows the newest article first regardless of API response order', async () => { - render() - - const newest = await screen.findByText('Newest update') - const middle = screen.getByText('Middle update') - const oldest = screen.getByText('Oldest update') - - expect(newest.compareDocumentPosition(middle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(middle.compareDocumentPosition(oldest) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + { time: '2026-07-29T08:00:00.000Z', title: 'Middle update', content: 'Middle content', source: 'Reuters', link: null, categories: 'markets,us' }, + { time: '2026-07-29T10:00:00.000Z', title: 'Newest update', content: 'Newest content', source: 'Bloomberg', link: 'https://example.com/newest', categories: 'markets,us' }, + { time: '2026-07-29T06:00:00.000Z', title: 'Oldest update', content: 'Oldest content', source: 'CNBC', link: null, categories: 'macro,rates' }, + ], count: 3, lookback: '24h', }) }) - -describe('NewsPage article disclosures', () => { - it('uses a native disclosure button that expands with the keyboard', async () => { - const user = userEvent.setup() - render() - - const disclosure = await screen.findByRole('button', { name: 'Newest update' }) - expect(disclosure.tagName).toBe('BUTTON') - expect(disclosure.getAttribute('aria-expanded')).toBe('false') - const panelId = disclosure.getAttribute('aria-controls') - expect(panelId).toBeTruthy() - expect(screen.queryByRole('link', { name: 'Open original' })).toBeNull() - - disclosure.focus() - await user.keyboard('{Enter}') - - expect(disclosure.getAttribute('aria-expanded')).toBe('true') - expect(screen.getByRole('region').id).toBe(panelId) - const originalLink = screen.getByRole('link', { name: 'Open original' }) - expect(originalLink.getAttribute('href')).toBe('https://example.com/newest') - expect(originalLink.className).toContain('min-h-10') - - await user.keyboard(' ') - expect(disclosure.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByRole('link', { name: 'Open original' })).toBeNull() +afterEach(() => { cleanup(); vi.clearAllMocks(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +describe('NewsPage inline stream', () => { + it('orders complete stories newest-first with their own source and original link', async () => { + render() + await screen.findByRole('heading', { name: 'Newest update' }) + const rows = screen.getAllByRole('listitem') + expect(rows.map((row) => within(row).getByRole('heading').textContent)).toEqual(['Newest update', 'Middle update', 'Oldest update']) + expect(within(rows[0]).getByText('Newest content')).toBeTruthy() + fireEvent.click(within(rows[0]).getByRole('button', { name: i18n.t('news.showMore') })) + expect(within(rows[0]).getByText('Newest content')).toBeTruthy() + fireEvent.click(within(rows[0]).getByRole('button', { name: i18n.t('news.showLess') })) + expect(within(rows[0]).getByText('Newest content')).toBeTruthy() + expect(within(rows[0]).getByText('Bloomberg')).toBeTruthy() + expect(within(rows[0]).getByRole('link', { name: 'Open original' }).getAttribute('href')).toBe('https://example.com/newest') + expect(within(rows[1]).getByText('Middle content')).toBeTruthy() + expect(within(rows[1]).queryByRole('link', { name: 'Open original' })).toBeNull() }) - - it('labels the feed filters and exposes loading state on the article surface', async () => { - render() - - expect(await screen.findByRole('combobox', { name: 'News time range' })).toBeTruthy() - expect(screen.getByRole('combobox', { name: 'News source' })).toBeTruthy() - - const article = await screen.findByRole('button', { name: 'Newest update' }) - expect(article.className).toContain('sm:py-3.5') - expect(article.closest('[aria-busy]')?.getAttribute('aria-busy')).toBe('false') - expect(screen.getByTestId('news-feed').className).not.toContain('rounded-xl') - expect(screen.getByTestId('news-feed').className).not.toContain('shadow-') + it('groups the timeline by local calendar day across midnight', async () => { + const beforeMidnight = new Date(2025, 0, 12, 23, 55) + const afterMidnight = new Date(2025, 0, 13, 0, 5) + mocks.list.mockResolvedValueOnce({ items: [ + { ...newsResponse('Before midnight').items[0], time: beforeMidnight.toISOString() }, + { ...newsResponse('After midnight').items[0], time: afterMidnight.toISOString() }, + ], count: 2, lookback: '24h' }) + render() + await screen.findByRole('heading', { name: 'After midnight' }) + const day = new Intl.DateTimeFormat('en', { dateStyle: 'medium' }) + const latestDay = screen.getByRole('list', { name: day.format(afterMidnight) }) + const previousDay = screen.getByRole('list', { name: day.format(beforeMidnight) }) + expect(within(latestDay).getByRole('heading').textContent).toBe('After midnight') + expect(within(previousDay).getByRole('heading').textContent).toBe('Before midnight') }) - it('uses compact rows without content and preview rows only when content exists', async () => { - mocks.list.mockResolvedValue({ - items: [ - { - time: '2026-07-29T10:00:00.000Z', - title: 'Compact transcript', - content: '', - source: 'SeekingAlpha', - link: 'https://example.com/transcript', - categories: 'markets,us', - }, - { - time: '2026-07-29T09:00:00.000Z', - title: 'Reported story', - content: 'A useful editorial summary that should be visible in the feed.', - source: 'Reuters', - link: null, - categories: 'markets,asia', - }, - ], - count: 2, - lookback: '24h', + it('reveals stories once per bottom intersection and resets when the view changes', async () => { + let notify!: (isIntersecting: boolean) => void + vi.stubGlobal('IntersectionObserver', class { + constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) { + notify = (isIntersecting) => callback([{ isIntersecting }]) + } + observe() {} + disconnect() {} }) + const items = Array.from({ length: 81 }, (_, index) => ({ + ...newsResponse(`Story ${index}`).items[0], + categories: 'us,positive', + time: new Date(2026, 6, 29, 10, index).toISOString(), + })) + mocks.list.mockResolvedValueOnce({ items, count: items.length, lookback: '24h' }) + render() + await screen.findByRole('heading', { name: 'Story 80' }) + expect(screen.getAllByRole('listitem')).toHaveLength(40) + act(() => notify(false)) + expect(screen.queryByRole('heading', { name: 'Story 40' })).toBeNull() + act(() => { notify(true); notify(true) }) + expect(screen.getAllByRole('listitem')).toHaveLength(80) + const previousView = notify + const stream = screen.getByRole('region', { name: i18n.t('news.streamLabel') }) + stream.scrollTop = 5000 + fireEvent.click(within(screen.getByRole('navigation', { name: 'News views' })).getByRole('button', { name: 'Positive' })) + act(() => previousView(true)) + expect(screen.getAllByRole('listitem')).toHaveLength(40) + expect(stream.scrollTop).toBe(0) + act(() => notify(true)) + expect(screen.getAllByRole('listitem')).toHaveLength(80) + act(() => notify(true)) + expect(screen.getAllByRole('listitem')).toHaveLength(81) + expect(screen.getByRole('heading', { name: 'Story 0' })).toBeTruthy() + }) - render() - - const compact = (await screen.findByRole('button', { name: 'Compact transcript' })).closest('article') - const preview = screen.getByRole('button', { name: 'Reported story' }).closest('article') - expect(compact?.getAttribute('data-density')).toBe('compact') - expect(preview?.getAttribute('data-density')).toBe('preview') - expect(compact?.textContent).not.toContain('A useful editorial summary') - expect(screen.getByText('A useful editorial summary that should be visible in the feed.')).toBeTruthy() + it('applies the category from market navigation without rendering another navigator', async () => { + render(, '/market/news?category=macro') + await screen.findByRole('heading', { name: 'Oldest update' }) + expect(screen.queryByRole('heading', { name: 'Newest update' })).toBeNull() + expect(screen.queryByRole('navigation', { name: 'News categories' })).toBeNull() + }) - const source = compact?.querySelector('span.font-semibold') - expect(source?.textContent).toBe('SeekingAlpha') - expect(source?.className).not.toContain('bg-primary') - expect(screen.getByText('markets, us').className).toContain('hidden') + it('filters explicit importance, sentiment, and watchlist symbols without substring matches', async () => { + mocks.watchlist = [{ assetClass: 'equity', symbol: 'AAPL', addedAt: 1 }] + mocks.list.mockResolvedValue({ items: [ + { ...newsResponse('AAPL supplier setback').items[0], categories: 'markets,us,important,negative' }, + { ...newsResponse('NVDA advances after results').items[0], categories: 'markets,us,positive' }, + { ...newsResponse('XAAPL unrelated ticker').items[0], categories: 'markets,us' }, + ], count: 3, lookback: '24h' }) + render() + await screen.findByRole('heading', { name: /AAPL supplier setback/ }) + const views = screen.getByRole('navigation', { name: 'News views' }) + fireEvent.click(within(views).getByRole('button', { name: 'Important' })) + expect(screen.queryByRole('heading', { name: 'NVDA advances after results' })).toBeNull() + fireEvent.click(within(views).getByRole('button', { name: 'Positive' })) + expect(screen.getByRole('heading', { name: 'NVDA advances after results' })).toBeTruthy() + expect(screen.queryByRole('heading', { name: /AAPL supplier setback/ })).toBeNull() + fireEvent.click(within(views).getByRole('button', { name: 'Negative' })) + expect(screen.getByRole('heading', { name: /AAPL supplier setback/ })).toBeTruthy() + fireEvent.click(within(views).getByRole('button', { name: 'Watchlist' })) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByRole('heading', { name: /AAPL supplier setback/ })).toBeTruthy() }) - it('groups several calendar days without disturbing newest-first order', async () => { - mocks.list.mockResolvedValue({ - items: [ - { - time: '2020-07-28T09:00:00.000Z', - title: 'Older day', - content: '', - source: 'Reuters', - link: null, - categories: null, - }, - { - time: '2020-07-29T08:00:00.000Z', - title: 'Newer day second', - content: '', - source: 'Reuters', - link: null, - categories: null, - }, - { - time: '2020-07-29T10:00:00.000Z', - title: 'Newer day first', - content: '', - source: 'Reuters', - link: null, - categories: null, - }, - ], - count: 3, - lookback: '24h', - }) + it('submits text filters deliberately and clears them without losing the stream', async () => { + render() + await screen.findByRole('heading', { name: 'Newest update' }) + const keyword = screen.getByRole('textbox', { name: 'Keyword' }) + fireEvent.change(keyword, { target: { value: 'earnings' } }) + expect(mocks.list).toHaveBeenCalledTimes(1) + mocks.list.mockResolvedValueOnce(newsResponse('Search result')) + fireEvent.click(screen.getByRole('button', { name: 'Search' })) + expect(await screen.findByRole('heading', { name: 'Search result' })).toBeTruthy() + expect(mocks.list.mock.lastCall?.[0]).toMatchObject({ keyword: 'earnings' }) + mocks.list.mockResolvedValueOnce(newsResponse('Unfiltered update')) + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + expect(await screen.findByRole('heading', { name: 'Unfiltered update' })).toBeTruthy() + expect((keyword as HTMLInputElement).value).toBe('') + expect(mocks.list.mock.lastCall?.[0].keyword).toBeUndefined() + }) - render() + it('rejects reversed dates without requesting or clearing the current feed', async () => { + render() + await screen.findByRole('heading', { name: 'Newest update' }) + fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2026-07-30' } }) + fireEvent.change(screen.getByLabelText('End date'), { target: { value: '2026-07-29' } }) + fireEvent.click(screen.getByRole('button', { name: 'Search' })) + expect(screen.getByRole('alert')).toBeTruthy() + expect(mocks.list).toHaveBeenCalledTimes(1) + expect(screen.getByRole('heading', { name: 'Newest update' })).toBeTruthy() + }) - const first = await screen.findByText('Newer day first') - const second = screen.getByText('Newer day second') - const older = screen.getByText('Older day') - expect(document.querySelectorAll('[data-news-day]')).toHaveLength(2) - expect(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(second.compareDocumentPosition(older) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(first.closest('[data-news-day]')).toBe(second.closest('[data-news-day]')) - expect(second.closest('[data-news-day]')).not.toBe(older.closest('[data-news-day]')) - const newestDay = new Intl.DateTimeFormat('en', { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(new Date('2020-07-29T10:00:00.000Z')) - expect(screen.getByRole('heading', { name: newestDay })).toBeTruthy() + it('keeps a broken thumbnail from obscuring a story and rejects executable links', async () => { + mocks.list.mockResolvedValue({ ...newsResponse('Unsafe link story'), items: [{ ...newsResponse('Unsafe link story').items[0], link: 'javascript:alert(1)', image: 'https://example.com/story.jpg' }] }) + render() + await screen.findByRole('heading', { name: 'Unsafe link story' }) + expect(screen.queryByRole('link')).toBeNull() + expect(screen.getByText('Unsafe link story content')).toBeTruthy() + fireEvent.error(screen.getByRole('img')) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByText('Unsafe link story content')).toBeTruthy() }) }) describe('NewsPage request recovery', () => { - it('reports an initial failure instead of presenting it as an empty feed, then retries', async () => { - mocks.list - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce(newsResponse('Recovered update')) - - render() - - const error = await screen.findByRole('alert') - expect(error.textContent).toContain('Couldn’t load News') - expect(error.textContent).toContain('OpenAlice backend') + it('reports initial failure and retries without calling it an empty feed', async () => { + mocks.list.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(newsResponse('Recovered update')) + render() + await screen.findByRole('alert') expect(screen.queryByText('No articles')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) - - expect(await screen.findByText('Recovered update')).toBeTruthy() - expect(mocks.list).toHaveBeenCalledTimes(2) + expect(await screen.findByRole('heading', { name: 'Recovered update' })).toBeTruthy() expect(screen.queryByRole('alert')).toBeNull() }) - it('clears mismatched articles when a new filter fails', async () => { - render() - expect(await screen.findByText('Newest update')).toBeTruthy() - + it('clears mismatched articles when a new source filter fails', async () => { + render() + await screen.findByRole('heading', { name: 'Newest update' }) mocks.list.mockRejectedValueOnce(new Error('filter unavailable')) - fireEvent.change(screen.getByRole('combobox', { name: 'News source' }), { - target: { value: 'Reuters' }, - }) - - expect(await screen.findByRole('alert')).toBeTruthy() - expect(screen.queryByText('Newest update')).toBeNull() + fireEvent.change(screen.getByRole('combobox', { name: 'News source' }), { target: { value: 'Reuters' } }) + await screen.findByRole('alert') + expect(screen.queryByRole('heading', { name: 'Newest update' })).toBeNull() expect(screen.queryByText('No articles')).toBeNull() }) - it('keeps the last successful feed visible when a background refresh fails', async () => { + it('retains the successful stream on refresh failure and recovers', async () => { let refresh: (() => void) | undefined vi.spyOn(globalThis, 'setInterval').mockImplementation((handler, delay) => { if (delay === 60_000) refresh = handler as () => void return {} as ReturnType }) - - render() - expect(await screen.findByText('Newest update')).toBeTruthy() + render() + await screen.findByRole('heading', { name: 'Newest update' }) expect(refresh).toBeTypeOf('function') mocks.list.mockRejectedValueOnce(new Error('refresh unavailable')) - - await act(async () => { - refresh?.() - await Promise.resolve() - await Promise.resolve() - }) - - const status = await screen.findByRole('status') - expect(status.textContent).toContain('showing the last news received') - expect(screen.getByText('Newest update')).toBeTruthy() - expect(screen.queryByRole('alert')).toBeNull() - + await act(async () => { refresh?.(); await Promise.resolve(); await Promise.resolve() }) + const notice = await screen.findByRole('status') + expect(screen.getByRole('heading', { name: 'Newest update' })).toBeTruthy() mocks.list.mockResolvedValueOnce(newsResponse('Refreshed update')) - fireEvent.click(within(status).getByRole('button', { name: 'Retry' })) - - expect(await screen.findByText('Refreshed update')).toBeTruthy() + fireEvent.click(within(notice).getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('heading', { name: 'Refreshed update' })).toBeTruthy() expect(screen.queryByRole('status')).toBeNull() }) - it('lets the latest filter response win when an older request finishes last', async () => { - render() - expect(await screen.findByText('Newest update')).toBeTruthy() - - const slow = deferred>() - const fast = deferred>() - mocks.list - .mockImplementationOnce(() => slow.promise) - .mockImplementationOnce(() => fast.promise) - + it('lets the latest query win when an older request finishes last', async () => { + render() + await screen.findByRole('heading', { name: 'Newest update' }) + const slow = deferred() + const fast = deferred() + mocks.list.mockImplementationOnce(() => slow.promise).mockImplementationOnce(() => fast.promise) const lookback = screen.getByRole('combobox', { name: 'News time range' }) fireEvent.change(lookback, { target: { value: '1h' } }) await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(2)) fireEvent.change(lookback, { target: { value: '7d' } }) await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(3)) - - await act(async () => { - fast.resolve(newsResponse('Latest response', '7d')) - await fast.promise - }) - expect(await screen.findByText('Latest response')).toBeTruthy() - - await act(async () => { - slow.resolve(newsResponse('Stale response', '1h')) - await slow.promise - }) - expect(screen.getByText('Latest response')).toBeTruthy() - expect(screen.queryByText('Stale response')).toBeNull() + await act(async () => { fast.resolve(newsResponse('Latest response', '7d')); await fast.promise }) + expect(await screen.findByRole('heading', { name: 'Latest response' })).toBeTruthy() + await act(async () => { slow.resolve(newsResponse('Stale response', '1h')); await slow.promise }) + expect(screen.getByRole('heading', { name: 'Latest response' })).toBeTruthy() + expect(screen.queryByRole('heading', { name: 'Stale response' })).toBeNull() }) }) diff --git a/ui/src/pages/NewsPage.tsx b/ui/src/pages/NewsPage.tsx index ebb31b603..119fe1516 100644 --- a/ui/src/pages/NewsPage.tsx +++ b/ui/src/pages/NewsPage.tsx @@ -1,16 +1,19 @@ -import { useState, useEffect, useCallback, useId, useMemo, useRef } from 'react' +import { memo, useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent } from 'react' +import { ChevronDown, CircleAlert, RefreshCw, Search } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { CircleAlert } from 'lucide-react' -import { formatRelativeTime } from '../lib/intl' -import { api, type NewsArticle } from '../api' + +import type { NewsArticle } from '../api' +import type { NewsQuery } from '../api/news' import { PageHeader } from '../components/PageHeader' +import { useNavigate } from 'react-router-dom' +import type { ViewSpec } from '../tabs/types' import { EmptyState, Skeleton } from '../components/StateViews' -import { Button, buttonVariants } from '../components/ui/button' import { inputClass } from '../components/form' - -// ==================== Helpers ==================== - - +import { Button } from '../components/ui/button' +import { useNewsFeed } from '../hooks/useNewsFeed' +import { useWatchlist } from '../tabs/watchlist-store' +import { cn } from '../lib/utils' +import { NEWS_CATEGORIES } from '../components/market/news-categories.js' const LOOKBACK_OPTIONS = [ { value: '1h', labelKey: 'news.lookback1h' }, { value: '12h', labelKey: 'news.lookback12h' }, @@ -18,389 +21,336 @@ const LOOKBACK_OPTIONS = [ { value: '7d', labelKey: 'news.lookback7d' }, ] as const -type FetchMode = 'replace' | 'refresh' - -// ==================== Article Row ==================== - -interface ArticleGroup { - key: string - label: string - articles: NewsArticle[] -} +const NEWS_VIEWS = [ + { id: 'latest', labelKey: 'news.viewLatest', tags: [] }, + { id: 'important', labelKey: 'news.viewImportant', tags: ['important', 'breaking', 'urgent', 'top'] }, + { id: 'positive', labelKey: 'news.viewPositive', tags: ['positive', 'bullish', 'benefit'] }, + { id: 'negative', labelKey: 'news.viewNegative', tags: ['negative', 'bearish', 'risk'] }, + { id: 'watchlist', labelKey: 'news.viewWatchlist', tags: [] }, +] as const -function localDayKey(date: Date): string { - return [ - date.getFullYear(), - String(date.getMonth() + 1).padStart(2, '0'), - String(date.getDate()).padStart(2, '0'), - ].join('-') +type NewsViewId = typeof NEWS_VIEWS[number]['id'] +const INITIAL_FILTERS = { startDate: '', endDate: '', symbol: '', keyword: '' } +const NEWS_PAGE_SIZE = 40 +const NEWS_TAG_DEFINITIONS = new Map( + [...NEWS_VIEWS, ...NEWS_CATEGORIES].flatMap((definition) => definition.tags.map((tag) => [tag, definition] as const)), +) +const MARKET_FLAGS: Partial> = { + 'a-shares': 'cn', hk: 'hk', us: 'us', } -function articleDayLabel(date: Date, locale: string): string { - const today = new Date() - const targetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() - const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() - const dayDistance = Math.round((targetDay - todayStart) / 86_400_000) - if (dayDistance >= -1 && dayDistance <= 1) { - return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(dayDistance, 'day') +export function NewsPage({ spec }: { spec: Extract }) { + const { t, i18n } = useTranslation() + const navigate = useNavigate() + const selection = NEWS_VIEWS.find((view) => view.id === spec.params.view)?.id ?? 'latest' + const category = NEWS_CATEGORIES.find((item) => item.id === spec.params.category) + const setSelection = (view: NewsViewId) => { + const next = new URLSearchParams() + if (spec.params.category) next.set('category', spec.params.category) + if (view !== 'latest') next.set('view', view) + navigate({ pathname: '/market/news', search: next.toString() }) } - return new Intl.DateTimeFormat(locale, { - weekday: 'short', - month: 'short', - day: 'numeric', - ...(date.getFullYear() === today.getFullYear() ? {} : { year: 'numeric' }), - }).format(date) -} + const [draft, setDraft] = useState(INITIAL_FILTERS) + const [query, setQuery] = useState({ lookback: '24h', limit: 200 }) + const [dateError, setDateError] = useState(false) + const { articles, sources, loading, refreshing, loadError, retry } = useNewsFeed(query) + const watchlist = useWatchlist((state) => state.entries) + const locale = i18n.resolvedLanguage ?? i18n.language -function groupArticlesByDay(articles: NewsArticle[], locale: string): ArticleGroup[] { - const groups = new Map() - for (const article of articles) { - const date = new Date(article.time) - const valid = !Number.isNaN(date.getTime()) - const key = valid ? localDayKey(date) : article.time - const group = groups.get(key) ?? { - key, - label: valid ? articleDayLabel(date, locale) : article.time, - articles: [], + const visibleArticles = useMemo(() => { + const scoped = category ? articles.filter((article) => category.tags.some((tag) => articleTags(article).includes(tag))) : articles + const sorted = [...scoped].sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()) + if (selection === 'latest') return sorted + if (selection === 'watchlist') { + const symbols = watchlist.map((entry) => entry.symbol.trim().toLowerCase()).filter(Boolean) + return sorted.filter((article) => { + const words = [article.title, article.content, article.categories ?? ''].join(' ').toLowerCase().split(/[^\p{L}\p{N}.^-]+/u) + return symbols.some((symbol) => words.includes(symbol)) + }) } - group.articles.push(article) - groups.set(key, group) - } - return [...groups.values()] -} + const definition = NEWS_VIEWS.find((item) => item.id === selection) + return definition ? sorted.filter((article) => definition.tags.some((tag) => articleTags(article).includes(tag))) : sorted + }, [articles, category, selection, watchlist]) + const [visibleCount, setVisibleCount] = useState(NEWS_PAGE_SIZE) + const activeVisibleCount = Math.min(visibleCount, visibleArticles.length) + const hasMore = activeVisibleCount < visibleArticles.length + const streamRef = useRef(null) + const nextBatchRef = useRef(null) + const articleDays = useMemo(() => { + const days = new Map() + for (const article of visibleArticles.slice(0, activeVisibleCount)) { + const day = new Date(article.time).toDateString() + const entries = days.get(day) + if (entries) entries.push(article) + else days.set(day, [article]) + } + return [...days] + }, [visibleArticles, activeVisibleCount]) -function ArticleRow({ article }: { article: NewsArticle }) { - const { t } = useTranslation() - const [expanded, setExpanded] = useState(false) - const disclosureId = useId().replace(/:/g, '') - const titleId = `news-title-${disclosureId}` - const panelId = `news-details-${disclosureId}` - const hasPreview = article.content.trim().length > 0 - const categories = article.categories?.replaceAll(',', ', ') + useEffect(() => { + setVisibleCount(NEWS_PAGE_SIZE) + if (streamRef.current) streamRef.current.scrollTop = 0 + }, [category?.id, query, selection]) + + useEffect(() => { + const root = streamRef.current + const target = nextBatchRef.current + if (!root || !target || !hasMore) return + let active = true + const observer = new IntersectionObserver((entries) => { + if (!active || !entries.some((entry) => entry.isIntersecting)) return + active = false + observer.disconnect() + setVisibleCount((count) => count + NEWS_PAGE_SIZE) + }, { root, rootMargin: '0px 0px 160px 0px' }) + observer.observe(target) + return () => { + active = false + observer.disconnect() + } + }, [activeVisibleCount, hasMore]) + + const submit = (event: FormEvent) => { + event.preventDefault() + if (draft.startDate && draft.endDate && draft.startDate > draft.endDate) { + setDateError(true) + return + } + setDateError(false) + const start = draft.startDate ? new Date(`${draft.startDate}T00:00:00`) : null + const end = draft.endDate ? new Date(`${draft.endDate}T00:00:00`) : null + if (end) end.setDate(end.getDate() + 1) + setQuery({ + ...query, + startTime: start ? new Date(start.getTime() - 1).toISOString() : undefined, + endTime: end ? new Date(end.getTime() - 1).toISOString() : undefined, + symbol: draft.symbol.trim() || undefined, + keyword: draft.keyword.trim() || undefined, + }) + } + const clear = () => { + setDraft(INITIAL_FILTERS) + setQuery({ lookback: '24h', limit: 200 }) + setDateError(false) + } + const selectTag = useCallback((tag: string) => { + setDraft((current) => ({ ...current, keyword: tag })) + setQuery((current) => ({ ...current, keyword: tag })) + }, []) return ( -
- )} + +
+
+ + + +
+ setDraft({ ...draft, symbol: event.target.value })} + className={`${inputClass} h-8 min-w-0 flex-1 basis-[130px] px-2 py-1 text-xs sm:max-w-[170px]`} /> + setDraft({ ...draft, keyword: event.target.value })} + className={`${inputClass} h-8 min-w-0 flex-1 basis-[140px] px-2 py-1 text-xs`} /> + + +
+ {dateError &&

{t('news.dateRangeError')}

} +
+ + + {t('news.articleCount', { count: visibleArticles.length })} + {articles.length === 200 && {t('news.resultLimit', { count: 200 })}} + +
+
+ {loadError && articles.length > 0 && } +
+
+ {loading && articles.length === 0 ? : loadError && articles.length === 0 ? ( + + ) : visibleArticles.length === 0 ? : ( <> - {categories} +
+ {articleDays.map(([dayKey, items]) => { + const day = formatNewsDay(items[0].time, locale) + return
+

{day}

+
+ {items.map((article) => )} +
+
+ })} +
+ {hasMore && -
- - - - - +
- - {!expanded && hasPreview && ( -

- {article.content} -

- )} - - - {expanded && ( -
- {hasPreview && ( -

{article.content}

- )} - {article.link && ( - - {t('news.openOriginal')} - - - - - - - )} -
- )} - +
) } -// ==================== Page ==================== -export function NewsPage() { - const { t, i18n } = useTranslation() - const [articles, setArticles] = useState([]) - const [lookback, setLookback] = useState('24h') - const [sourceFilter, setSourceFilter] = useState('') - const [loading, setLoading] = useState(true) - const [refreshing, setRefreshing] = useState(false) - const [loadError, setLoadError] = useState(false) - const [sources, setSources] = useState([]) - const requestGeneration = useRef(0) - const orderedArticles = useMemo( - () => [...articles].sort( - (a, b) => new Date(b.time).getTime() - new Date(a.time).getTime(), - ), - [articles], - ) - const locale = i18n.resolvedLanguage ?? i18n.language - const articleGroups = useMemo( - () => groupArticlesByDay(orderedArticles, locale), - [locale, orderedArticles], +const NewsStreamRow = memo(function NewsStreamRow({ article, locale, onTag }: { article: NewsArticle; locale: string; onTag: (tag: string) => void }) { + const { t } = useTranslation() + const [expanded, setExpanded] = useState(false) + const [failedImage, setFailedImage] = useState(null) + const summaryId = useId() + const link = safeNewsUrl(article.link) + const image = safeNewsUrl(article.image, true) + const labels = new Map() + for (const tag of (article.categories ?? '').split(/[;,]/).map((value) => value.trim()).filter(Boolean)) { + const definition = NEWS_TAG_DEFINITIONS.get(tag.toLowerCase()) + const label = definition ? t(definition.labelKey) : tag + if (!labels.has(label)) labels.set(label, { tag, flag: definition ? MARKET_FLAGS[definition.id] : undefined }) + } + const content = article.content.trim() + const hasImage = Boolean(image && failedImage !== image) + const source = link ? ( + + {article.source ?? t('news.openOriginal')} + + ) : article.source ? {article.source} : null + return ( +
+
+ + +
+
+
+

+ {link ? {article.title} : article.title} +

+ {content &&

+ {content} +

} +
+ {[...labels].map(([label, { tag, flag }]) => ( + + ))} + {(content || !hasImage && source) &&
+ {content && } + {!hasImage && source} +
} +
+
+ {hasImage &&
+ {t('news.imageAlt', setFailedImage(image ?? null)} className="aspect-[3/2] w-full rounded-sm border border-border object-cover" /> + {source &&
{source}
} +
} +
+
) +}) - const fetchArticles = useCallback(async ( - lb: string, - src: string, - mode: FetchMode = 'refresh', - ) => { - const request = ++requestGeneration.current - if (mode === 'replace') { - setArticles([]) - setLoadError(false) - setLoading(true) - } else { - setRefreshing(true) - } - - try { - const res = await api.news.list({ - lookback: lb, - limit: 200, - source: src || undefined, - }) - if (request !== requestGeneration.current) return - - setArticles(res.items) - setLoadError(false) - const seen = new Set() - for (const item of res.items) { - if (item.source) seen.add(item.source) - } - setSources((prev) => { - const merged = new Set([...prev, ...seen]) - return [...merged].sort() - }) - } catch (err) { - if (request === requestGeneration.current) { - setLoadError(true) - console.warn('Failed to load news:', err) - } - } finally { - if (request === requestGeneration.current) { - setLoading(false) - setRefreshing(false) - } - } - }, []) - - useEffect(() => { - void fetchArticles(lookback, sourceFilter, 'replace') - }, [lookback, sourceFilter, fetchArticles]) - - useEffect(() => { - const id = setInterval(() => { - void fetchArticles(lookback, sourceFilter, 'refresh') - }, 60_000) - return () => clearInterval(id) - }, [lookback, sourceFilter, fetchArticles]) - - useEffect(() => () => { - requestGeneration.current += 1 - }, []) +function NewsStreamSkeleton() { + return +} - const retry = useCallback(() => { - void fetchArticles( - lookback, - sourceFilter, - articles.length === 0 ? 'replace' : 'refresh', - ) - }, [articles.length, fetchArticles, lookback, sourceFilter]) +function NewsLoadError({ refreshing, onRetry }: { refreshing: boolean; onRetry: () => void }) { + const { t } = useTranslation() + return
+ +

{t('news.loadErrorTitle')}

+

{t('news.loadErrorDescription')}

+ +
+} - return ( -
- +function NewsStaleNotice({ refreshing, onRetry }: { refreshing: boolean; onRetry: () => void }) { + const { t } = useTranslation() + return
+ + {t('news.stale')} + +
+} -
-
- {/* Controls */} -
- +function articleKey(article: NewsArticle): string { return `${article.time}-${article.link ?? article.title}` } +function articleTags(article: NewsArticle): string[] { return (article.categories ?? '').split(/[;,]/).map((tag) => tag.trim().toLowerCase()).filter(Boolean) } - +function safeNewsUrl(value: string | null | undefined, allowLocal = false): string | undefined { + if (!value) return undefined + if (allowLocal && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\')) return value + try { + const url = new URL(value) + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined + } catch { return undefined } +} - - {t('news.articleCount', { count: articles.length })} - -
+const dateFormatterCache = new Map() - {loadError && articles.length > 0 && ( - - )} +function getDateFormatter(locale: string, kind: 'time' | 'date' | 'published'): Intl.DateTimeFormat { + const key = `${locale}:${kind}` + const cached = dateFormatterCache.get(key) + if (cached) return cached + const options: Intl.DateTimeFormatOptions = kind === 'time' + ? { hour: '2-digit', minute: '2-digit', hour12: false } + : kind === 'date' + ? { dateStyle: 'medium' } + : { dateStyle: 'medium', timeStyle: 'short' } + const formatter = new Intl.DateTimeFormat(locale, options) + dateFormatterCache.set(key, formatter) + return formatter +} - {/* Article list */} -
- {loading && articles.length === 0 ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
- - -
- ))} -
- ) : loadError && articles.length === 0 ? ( - - ) : articles.length === 0 ? ( - - ) : ( -
1 ? 'space-y-5 pb-1' : ''}> - {articleGroups.map((group) => ( -
- {articleGroups.length > 1 && ( -
-

{group.label}

- - {t('news.articleCount', { count: group.articles.length })} - -
- )} -
- {group.articles.map((article) => ( - - ))} -
-
- ))} -
- )} -
-
-
-
- ) +function formatNewsDay(value: string, locale: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + if (date.toDateString() === new Date().toDateString()) { + return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(0, 'day') + } + return getDateFormatter(locale, 'date').format(date) } -function NewsLoadError({ - refreshing, - onRetry, -}: { - refreshing: boolean - onRetry: () => void -}) { - const { t } = useTranslation() - return ( -
- -

- {t('news.loadErrorTitle')} -

-

- {t('news.loadErrorDescription')} -

- -
- ) +function formatNewsTime(value: string, locale: string): string { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : getDateFormatter(locale, 'time').format(date) } -function NewsStaleNotice({ - refreshing, - onRetry, -}: { - refreshing: boolean - onRetry: () => void -}) { - const { t } = useTranslation() - return ( -
- - {t('news.stale')} - -
- ) +function formatPublishedTime(value: string, locale: string): string { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : getDateFormatter(locale, 'published').format(date) } diff --git a/ui/src/tabs/UrlAdopter.tsx b/ui/src/tabs/UrlAdopter.tsx index 37db06d24..39e43e80f 100644 --- a/ui/src/tabs/UrlAdopter.tsx +++ b/ui/src/tabs/UrlAdopter.tsx @@ -76,7 +76,7 @@ export function UrlAdopter() { } /> } /> } /> - } /> + } /> {/* Static `boards` segment outranks /market/:assetClass/:symbol in react-router's specificity scoring, so order here doesn't matter — but keep it above the dynamic route for readability. */} @@ -247,6 +247,13 @@ function AdoptIssueDetail() { return } +function AdoptNewsSelection() { + const [search] = useSearchParams() + const category = search.get('category') || undefined + const view = search.get('view') || undefined + return +} + function AdoptTracked() { const [search] = useSearchParams() const entity = search.get('entity')?.trim() || undefined diff --git a/ui/src/tabs/__tests__/store.spec.ts b/ui/src/tabs/__tests__/store.spec.ts index 2cfe31097..e428dfa1f 100644 --- a/ui/src/tabs/__tests__/store.spec.ts +++ b/ui/src/tabs/__tests__/store.spec.ts @@ -99,6 +99,19 @@ describe('openOrFocus', () => { expect(group.activeTabId).toBe(group.tabIds[1]) }) + it('keeps news selections in one tab while updating its URL params', () => { + const store = useWorkspace.getState() + store.openOrFocus({ kind: 'news', params: { category: 'us', view: 'important' } }) + const newsId = getFocusedGroup(useWorkspace.getState())?.activeTabId + store.openOrFocus(tab('AAPL')) + store.openOrFocus({ kind: 'news', params: { category: 'macro' } }) + + const state = useWorkspace.getState() + expect(getFocusedGroup(state)?.tabIds).toHaveLength(2) + expect(getFocusedGroup(state)?.activeTabId).toBe(newsId) + expect(getFocusedTab(state)?.spec).toEqual({ kind: 'news', params: { category: 'macro' } }) + }) + it('updates Tracked selection without creating another Tracked tab', () => { const store = useWorkspace.getState() store.openOrFocus({ kind: 'tracked', params: { entity: 'stock-vst' } }) diff --git a/ui/src/tabs/registry.tsx b/ui/src/tabs/registry.tsx index 529e77bd8..bcea7d82e 100644 --- a/ui/src/tabs/registry.tsx +++ b/ui/src/tabs/registry.tsx @@ -72,7 +72,7 @@ interface ViewProps { } export type ViewLifecycle = 'active-only' | 'keep-mounted' -export type ViewShell = 'chat' | 'auto-quant' | 'prediction' +export type ViewShell = 'chat' | 'auto-quant' | 'prediction' | 'market' export interface ViewModule { kind: K @@ -200,13 +200,13 @@ const officeModule: ViewModule<'office'> = { Component: () => , } -function MarketArea({ children }: { children: ReactNode }) { +export function MarketArea({ children }: { children: ReactNode }) { return ( } + sidebar={({ closeMobileDrawer }) => } > {children} @@ -216,45 +216,38 @@ function MarketArea({ children }: { children: ReactNode }) { const newsModule: ViewModule<'news'> = { kind: 'news', title: () => 'News', - toUrl: () => '/market/news', - Component: () => ( - - - - ), + toUrl: ({ params }) => { + const search = new URLSearchParams() + if (params.category) search.set('category', params.category) + if (params.view) search.set('view', params.view) + return '/market/news' + (search.size ? '?' + search : '') + }, + shell: 'market', + Component: ({ spec }) => , } const marketListModule: ViewModule<'market-list'> = { kind: 'market-list', title: () => 'Market', toUrl: () => '/market', - Component: () => ( - - - - ), + shell: 'market', + Component: () => , } const marketRotationModule: ViewModule<'market-rotation'> = { kind: 'market-rotation', title: () => 'Sector Rotation', toUrl: () => '/market/rotation', - Component: () => ( - - - - ), + shell: 'market', + Component: () => , } const marketBoardModule: ViewModule<'market-board'> = { kind: 'market-board', title: (spec) => MARKET_BOARD_TITLES[spec.params.board], toUrl: (spec) => `/market/boards/${spec.params.board}`, - Component: (props) => ( - - - - ), + shell: 'market', + Component: (props) => , } const marketDetailModule: ViewModule<'market-detail'> = { @@ -263,11 +256,8 @@ const marketDetailModule: ViewModule<'market-detail'> = { toUrl: (spec) => `/market/${spec.params.assetClass}/${encodeURIComponent(spec.params.symbol)}` + (spec.params.source ? `?source=${encodeURIComponent(spec.params.source)}` : ''), - Component: (props) => ( - - - - ), + shell: 'market', + Component: (props) => , } const settingsCategoryTitle: Record< diff --git a/ui/src/tabs/types.ts b/ui/src/tabs/types.ts index e55978b26..521f1f970 100644 --- a/ui/src/tabs/types.ts +++ b/ui/src/tabs/types.ts @@ -39,7 +39,7 @@ export type ViewSpec = | { kind: 'tracked-issue-detail'; params: { wsId: string; id: string } } | { kind: 'automation'; params: { section: 'runs' | 'api' } } | { kind: 'office'; params: Record } - | { kind: 'news'; params: Record } + | { kind: 'news'; params: { category?: string; view?: string } } | { kind: 'market-list'; params: Record } | { kind: 'market-rotation'; params: Record } | { kind: 'market-board'; params: { board: 'movers' | 'calendar' | 'macro' | 'term-structure' | 'global-macro' | 'shipping' | 'fed' } } @@ -156,7 +156,7 @@ export function specEquals(a: ViewSpec, b: ViewSpec): boolean { * rather than creating a separate editor identity for every anchor. */ export function specTabIdentityEquals(a: ViewSpec, b: ViewSpec): boolean { - if (a.kind === 'tracked' && b.kind === 'tracked') return true + if (a.kind === b.kind && (a.kind === 'tracked' || a.kind === 'news')) return true return specEquals(a, b) }