Fixed date issues.

This commit is contained in:
caspar 2026-03-21 09:26:43 -04:00
parent 413cd17472
commit 7179cd3842
7 changed files with 157 additions and 145 deletions

View File

@ -1,3 +1,72 @@
# SoftTraderFrontend
The frontend for a lite Bloomberg terminal.
## Features
- **Dashboard** — Live clock, NYSE market status indicator (open/premarket/after-hours/closed), rotating stock cards for watched symbols, and news headlines
- **Watchlist** — Manage a personal list of stock symbols and topics, with swipeable rows and quick navigation to details
- **Symbol Details** — Live quotes with price change, volume, open/close data, collapsible stock chart (1D–3Y), news headlines with sentiment analysis, and configurable price alerts
- **Advanced Candlestick Chart** — Full candlestick charting with configurable timeframes and bar sizes, custom date ranges, and interactive crosshair
- **News & Sentiment** — Per-symbol news feed with pull-to-refresh and an aggregated sentimentality bar
- **Weather Widget** — Current conditions with configurable location and temperature unit (F/C)
- **Settings** — Dark/light theme toggle, weather location, temperature unit, 12h/24h time format
## Tech Stack
- **Framework:** [Next.js 16](https://nextjs.org/) (App Router)
- **UI:** [React 19](https://react.dev/), [Tailwind CSS 4](https://tailwindcss.com/), [shadcn/ui](https://ui.shadcn.com/) (Radix primitives)
- **Charts:** [Recharts](https://recharts.org/) (line/area), custom D3-based candlestick chart
- **Backend API:** `api.dongfeng-systems.org` (quotes, news, user interests, weather)
- **Analytics:** Vercel Analytics
- **Language:** TypeScript
## Project Structure
```
app/ # Next.js App Router pages
page.tsx # Home / dashboard
interests/ # Watchlist
symbol-details/ # Individual stock detail view
complex-chart/ # Advanced candlestick chart
interest-details/ # Topic/interest news feed
add-item/ # Add symbol or topic
settings/ # User settings
components/
layout/ # StockClockLayout, Sidebar
pages/ # Page-level components (FrontPage, InterestsPage, SettingsPage)
widgets/ # StockCard, NewsHeadlines, WeatherWidget, SentimentalityBar, etc.
stock-chart/ # Line/area stock chart
candlestick-chart/ # D3-based candlestick chart
ui/ # shadcn/ui primitives
providers/ # ThemeProvider
lib/ # API clients and utilities
stockApi.ts # Live quotes, historical data
newsApi.ts # News feed
userInterestsApi.ts # Watchlist/interests CRUD
weatherApi.ts # Weather data and preferences
```
## Getting Started
### Prerequisites
- Node.js 18+
### Install and Run
```bash
npm install
npm run dev
```
The app will be available at [http://localhost:3000](http://localhost:3000).
### Scripts
| Command | Description |
| --------------- | ------------------------ |
| `npm run dev` | Start development server |
| `npm run build` | Production build |
| `npm run start` | Start production server |
| `npm run lint` | Run ESLint |

View File

@ -10,17 +10,15 @@ import { CandlestickGraph } from '@/components/candlestick-chart'
const TIMEFRAMES = ['Custom', '1D', '5D', '10D', '1M', '3M', '6M', 'YTD', '1Y', '2Y', '3Y'] as const
type Timeframe = typeof TIMEFRAMES[number]
// Bar size options per timeframe — each bar size must be able to cover the
// visible range within IB API max duration limits:
// 1m→1D, 2m→2D, 5m→1W, 15m→2W, 30m/1h/2h/4h→1M, 1D/1W/1M→1Y+
// Bar size options per timeframe
const BAR_SIZE_MAP: Record<Exclude<Timeframe, 'Custom'>, string[]> = {
'1D': ['1m', '2m', '5m', '15m'],
'5D': ['5m', '15m', '30m', '1h'],
'10D': ['15m', '30m', '1h', '2h'],
'1M': ['30m', '1h', '4h', '1D'],
'3M': ['1D', '1W'],
'6M': ['1D', '1W'],
'YTD': ['1D', '1W'],
'3M': ['1h', '4h', '1D', '1W'],
'6M': ['4h', '1D', '1W'],
'YTD': ['4h', '1D', '1W'],
'1Y': ['1D', '1W'],
'2Y': ['1D', '1W', '1M'],
'3Y': ['1W', '1M'],
@ -142,9 +140,7 @@ function countTradingDays(start: Date, end: Date): number {
}
/**
* Get bar size options for custom timeframe based on date range.
* Thresholds match IB API max duration limits per bar size:
* 5m→1W(5 trading), 15m→2W(10), 30m/1h/2h/4h→1M(~20), 1D+→1Y+
* Get bar size options for custom timeframe based on date range
*/
function getCustomBarSizeOptions(startDate: Date, endDate: Date): { options: string[], default: string } {
const tradingDays = countTradingDays(startDate, endDate)
@ -152,26 +148,25 @@ function getCustomBarSizeOptions(startDate: Date, endDate: Date): { options: str
if (tradingDays <= 1) {
return { options: ['1m', '2m', '5m', '15m'], default: '5m' }
} else if (tradingDays <= 5) {
return { options: ['5m', '15m', '30m', '1h'], default: '15m' }
} else if (tradingDays <= 10) {
return { options: ['15m', '30m', '1h', '2h'], default: '30m' }
return { options: ['5m', '15m', '30m', '1h'], default: '30m' }
} else if (tradingDays <= 20) {
return { options: ['30m', '1h', '4h', '1D'], default: '1D' }
return { options: ['15m', '30m', '1h', '4h'], default: '1h' }
} else if (tradingDays <= 90) {
return { options: ['1h', '4h', '1D'], default: '1D' }
} else if (tradingDays <= 365) {
return { options: ['4h', '1D', '1W'], default: '1D' }
} else if (tradingDays <= 365 * 3) {
return { options: ['1D', '1W', '1M'], default: '1W' }
} else {
return { options: ['1D', '1W'], default: '1D' }
return { options: ['1W', '1M'], default: '1M' }
}
}
/**
* Format date to YYYY-MM-DD for input.
* Uses local-time methods to match how parseInputDate creates dates,
* avoiding the day-shift that toISOString() causes in non-UTC timezones.
* Format date to YYYY-MM-DD for input
*/
function formatDateForInput(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
return date.toISOString().split('T')[0]
}
/**
@ -196,7 +191,6 @@ function ComplexChartContent() {
const start = calculateStartDate('5D', lastTradingDay)
return formatDateForInput(start)
})
const [savedChart, setSavedChart] = useState('')
// Get available bar sizes based on current timeframe/dates
const barSizeOptions = useMemo(() => {
@ -255,14 +249,6 @@ function ComplexChartContent() {
}
}
const handleGo = () => {
// Placeholder for Go button action
}
const handleSaveChart = () => {
// Placeholder for Save chart action
}
return (
<div className="h-full flex flex-col">
{/* Header */}
@ -310,31 +296,6 @@ function ComplexChartContent() {
onChange={(e) => handleEndDateChange(e.target.value)}
className="border border-border bg-input text-foreground text-sm px-3 py-1.5 cursor-pointer"
/>
<button
onClick={handleGo}
className="bg-primary text-primary-foreground text-sm font-medium px-4 py-1.5 hover:bg-primary/90 transition-colors"
>
Go
</button>
</div>
{/* Right side */}
<div className="flex items-center gap-3">
<select
value={savedChart}
onChange={(e) => setSavedChart(e.target.value)}
className="border border-border bg-input text-foreground text-sm px-3 py-1.5 cursor-pointer min-w-36"
>
<option value="">My Saved Charts</option>
</select>
<button
onClick={handleSaveChart}
className="bg-secondary text-secondary-foreground text-sm font-medium px-4 py-1.5 hover:bg-secondary/80 transition-colors border border-border"
>
Save Chart
</button>
</div>
</div>

View File

@ -153,8 +153,8 @@ function SymbolDetailsContent() {
))}
</div>
{/* Chart */}
<div className="p-4">
{/* Chart will be embedded here */}
<div className="p-4">
<StockChart symbol={symbol} range={selectedRange} />
</div>

View File

@ -832,16 +832,17 @@ export function CandlestickGraph({
{/* Notes overlay */}
{notes.map((note) => {
// Find the candle index for this note's time
const candleIndex = candles.findIndex(c => c.time === note.time);
if (candleIndex === -1) return null;
// Check if note is within the visible time range
if (candles.length === 0) return null;
// Check if note is in visible range
if (candleIndex < viewModel.visibleStartIndex || candleIndex > viewModel.visibleEndIndex) {
return null;
}
const startCandle = candles[viewModel.visibleStartIndex];
const endCandle = candles[viewModel.visibleEndIndex];
const x = getXForIndex(candleIndex);
if (!startCandle || !endCandle) return null;
if (note.time < startCandle.time || note.time > endCandle.time) return null;
// Use interpolated time positioning (works across all bar sizes)
const x = getXForTime(note.time);
const y = getYForPrice(note.price);
return (

View File

@ -54,94 +54,79 @@ function toDate(date: string | Date): Date {
}
/**
* Checks if two dates are the same calendar day (UTC).
* Must use UTC methods because date strings like "2026-03-20" parse as UTC midnight.
* Checks if two dates are the same calendar day.
*/
function isSameDay(date1: Date, date2: Date): boolean {
return (
date1.getUTCFullYear() === date2.getUTCFullYear() &&
date1.getUTCMonth() === date2.getUTCMonth() &&
date1.getUTCDate() === date2.getUTCDate()
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
}
/**
* Calculates the number of trading days between two dates (excludes weekends).
* Uses UTC methods because date strings parse as UTC midnight — local-time
* methods would shift the day-of-week check in non-UTC timezones.
*/
function calculateTradingDays(startDate: Date, endDate: Date): number {
let tradingDays = 0;
const current = new Date(startDate);
while (current <= endDate) {
const dayOfWeek = current.getUTCDay();
const dayOfWeek = current.getDay();
// 0 = Sunday, 6 = Saturday
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
tradingDays++;
}
current.setUTCDate(current.getUTCDate() + 1);
current.setDate(current.getDate() + 1);
}
return tradingDays;
}
/**
* IB API maximum duration in calendar days per bar size.
* Durations exceeding these limits cause 400 errors.
*/
function getMaxCalendarDays(barSize: string): number {
switch (barSize) {
case '1m': return 1;
case '2m': return 2;
case '5m': return 7;
case '15m': return 14;
case '30m': return 30;
case '1h': return 30;
case '2h': return 30;
case '4h': return 30;
default: return 3650; // daily+ bars support multi-year requests
}
}
/**
* Converts trading days to API duration format, clamped to IB API limits.
* Converts trading days to API duration format.
* Format: "{number}+{unit}" where unit is D (days), W (weeks), M (months), Y (years)
*
* The API duration uses calendar units, so trading days must be converted:
* ~5 trading days = 1 calendar week, ~21 trading days = 1 calendar month.
*
* Uses W/M units for 30m+ intraday bars because the IB API can reject
* D-unit durations that exceed per-bar-size thresholds.
* Uses floor() when converting to larger units to avoid overshooting the cap.
*/
function tradingDaysToDuration(tradingDays: number, barSize: string): string {
if (tradingDays <= 0) {
return '1+D';
}
// Convert trading days to calendar days and clamp to IB API limit
let calendarDays = Math.ceil(tradingDays * 7 / 5);
calendarDays = Math.min(calendarDays, getMaxCalendarDays(barSize));
// Convert trading days to calendar days (7 calendar days per 5 trading days)
const calendarDays = Math.ceil(tradingDays * 7 / 5);
// For 30m+ intraday bars, prefer W/M units over large D values
// For 30m and hourly bars, prefer W/M units over large D values.
// The IB API can reject D-unit durations that exceed per-bar-size
// thresholds even when the equivalent W/M duration would succeed.
if (['30m', '1h', '2h', '4h'].includes(barSize)) {
if (calendarDays <= 6) return `${calendarDays}+D`;
if (calendarDays <= 28) {
return `${Math.floor(calendarDays / 7)}+W`;
}
const weeks = Math.ceil(calendarDays / 7);
if (weeks <= 4) return `${weeks}+W`;
return '1+M';
}
// For other bars, use D when possible
if (calendarDays <= 365) {
// For short durations, use days
if (calendarDays <= 90) {
return `${calendarDays}+D`;
}
// Larger durations: use M or Y (floor to avoid overshooting the cap)
const months = Math.floor(calendarDays / 30);
if (months <= 12) return `${months}+M`;
return `${Math.floor(months / 12)}+Y`;
// For medium durations, use weeks
const weeks = Math.ceil(calendarDays / 7);
if (weeks <= 52) {
return `${weeks}+W`;
}
// For longer durations, use months or years
const months = Math.ceil(calendarDays / 30);
if (months <= 24) {
return `${months}+M`;
}
const years = Math.ceil(months / 12);
return `${years}+Y`;
}
// Extra trading days fetched before the visible range so indicators (EMA, MACD,
@ -201,8 +186,12 @@ async function fetchCandlesFromAPI(
endDateTime = endOfDay.toISOString();
// Default to 5m bars for intraday if a daily/weekly bar size is specified
const intradayBarSize = ['1D', '1W', '1M'].includes(barSize) ? '5m' : barSize;
// Cap warmup to IB API max duration (e.g. 2m bars max 2D, 1m bars max 1D)
duration = `${Math.min(3, getMaxCalendarDays(intradayBarSize))}+D`;
// Cap warmup days to IB API max duration per bar size
// (e.g. 2m bars max 2D, 1m bars max 1D)
const maxDays: Record<string, number> = {
'1m': 1, '2m': 2, '5m': 7, '15m': 14, '30m': 28,
};
duration = `${Math.min(3, maxDays[intradayBarSize] ?? 3)}+D`;
apiBarSize = BAR_SIZE_MAP[intradayBarSize] || 'FiveMins';
} else {
const tradingDays = calculateTradingDays(start, end);

View File

@ -32,10 +32,16 @@ function formatInLocalTime(
return new Intl.DateTimeFormat(undefined, opts).format(date)
}
function getUtcDateKey(timestamp: string) {
/**
* Get the trading day key for a timestamp based on US Eastern time
* (NYSE operates in Eastern time, so trading days should be determined by ET, not UTC)
*/
function getTradingDayKey(timestamp: string) {
const d = new Date(timestamp)
if (Number.isNaN(d.getTime())) return ''
return d.toISOString().slice(0, 10)
// Format in US Eastern time to get the correct trading day
return d.toLocaleDateString('en-CA', { timeZone: 'America/New_York' }) // 'en-CA' gives YYYY-MM-DD format
}
function sortByTimestamp<T extends { timestamp: string }>(rows: T[]) {
@ -46,18 +52,18 @@ function sortByTimestamp<T extends { timestamp: string }>(rows: T[]) {
function keepLatestTradingDay<T extends { timestamp: string }>(rows: T[]) {
const sorted = sortByTimestamp(rows)
const distinctDays = [...new Set(sorted.map((r) => getUtcDateKey(r.timestamp)).filter(Boolean))]
const distinctDays = [...new Set(sorted.map((r) => getTradingDayKey(r.timestamp)).filter(Boolean))]
if (!distinctDays.length) return sorted
const latest = distinctDays[distinctDays.length - 1]
return sorted.filter((r) => getUtcDateKey(r.timestamp) === latest)
return sorted.filter((r) => getTradingDayKey(r.timestamp) === latest)
}
function keepLatestNTradingDays<T extends { timestamp: string }>(rows: T[], n: number) {
const sorted = sortByTimestamp(rows)
const distinctDays = [...new Set(sorted.map((r) => getUtcDateKey(r.timestamp)).filter(Boolean))]
const distinctDays = [...new Set(sorted.map((r) => getTradingDayKey(r.timestamp)).filter(Boolean))]
const keep = new Set(distinctDays.slice(-n))
return sorted.filter((r) => keep.has(getUtcDateKey(r.timestamp)))
return sorted.filter((r) => keep.has(getTradingDayKey(r.timestamp)))
}
function normalizeRawRows(input: unknown[]): RawHistoricalLinePoint[] {

View File

@ -1,10 +1,6 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
@ -23,19 +19,9 @@
}
],
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}