Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions .github/instructions/snyk_rules.instructions.md

This file was deleted.

54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ This will start:
- Frontend: http://localhost:3000
- Backend API: http://localhost:3005 (health endpoint: `/health`)

Local development with Docker (hot reload)

Use the development compose file to run both services with live reload and all required env vars configured:

```bash
docker compose -f docker-compose.dev.yml up --build
```

This will start:

- Frontend (dev server): http://localhost:3000
- Backend API (dev): http://localhost:3005
- WebSocket (dev): ws://localhost:8080

Local development (without Docker)

- Frontend:
Expand All @@ -47,6 +61,46 @@ Tests
- Frontend E2E: run Cypress after starting the frontend with `npm run cypress:open` for local `npm run cypress:run` for CI/CD pipelines
- Backend unit test run in `server/market-trading-service` with `npm run test` (Jest), `npm run test:watch`, `npm run test:coverage` also available if working on test and see the coverage

## Assumptions & Trade-offs

### Assumptions

- Data model: simplified ticker domain (price, change, volume, 24h high/low); no corporate actions, splits, multi-currency, or latency-sensitive guarantees.

- Simulation: price history and live ticks are synthetic for UX validation, not financial accuracy.

- Environment: single-node backend with in-memory storage is acceptable for this challenge; no cross-process persistence required.

- Client: no auth is required to view market data.

- Contracts: WebSocket message shapes are minimal and stable: `{"type":"connected","payload":{"clientId"}}` and `{"type":"data","payload":{"ticker"}}`. REST endpoints are reachable for initial bootstrap/fallback.

- CI/CD via github actions to run test on PR

- Used Code Rabbit to review

### Trade-offs

- Real-time delivery vs simplicity: WebSocket for live updates plus a one-time REST bootstrap for fast first paint (slight duplication accepted for responsiveness).

- Subscription scope: initial subscribe-to-all for clarity; future optimization could subscribe only to visible/selected symbols.

- Update cadence: immediate per-tick broadcasts, no batching/debouncing; simple but more frames under heavy load. Batching window (e.g., 50–150 ms) can be added later.

- State storage: in-memory repository and subscription registry (per-process); not horizontally scalable without a shared store/pub-sub or sticky sessions.

- Consistency vs responsiveness: values rounded for display and updated on each `data` frame; suited for UI, not for reconciliation.

- Error handling: if REST fails, fall back to mock data; if WS drops, auto-reconnect and keep last-known data. Prioritizes resilience for demos.

- Client architecture: Next.js app with a WebSocket Provider context and React Query; SSR of live data is out of scope to keep the real-time logic client-side and testable.

- Testing: REST fallback and provider no-op defaults keep unit tests stable; deep WS integration tests are limited and can be added with a small WS mock.

- Selection model: selection tracked by symbol and derived from the latest ticker list to ensure live updates without stale references.

- Some of the Bonus features aren't covered due to time constrain.

License

This project is available under the repository LICENSE file.
2 changes: 2 additions & 0 deletions client/trading-dashboard/app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const TradingDashboard = () => {
});
};



const handleSelectTicker = (ticker: Ticker) => {
setSelectedTicker(ticker);
setSidebarOpen(false); // Close sidebar on mobile after selection
Expand Down
3 changes: 2 additions & 1 deletion client/trading-dashboard/app/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
import { WebSocketProvider } from "@/providers/WebSocketProvider";

export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
Expand All @@ -19,7 +20,7 @@ export default function Providers({ children }: { children: React.ReactNode }) {

return (
<QueryClientProvider client={queryClient}>
{children}
<WebSocketProvider>{children}</WebSocketProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
Expand Down
2 changes: 0 additions & 2 deletions client/trading-dashboard/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { BarChart3 } from "lucide-react";

const Footer = () => {
const year = new Date().getFullYear();
return (
Expand Down
19 changes: 13 additions & 6 deletions client/trading-dashboard/components/TickerGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import TickerCard from "./TickerCard";
import { Ticker } from "@/types";
import { useQuery } from "@tanstack/react-query";
import { API_BASE_URL, MOCK_TICKERS } from "@/constants";
import { LoadingSpinner } from "./ui/LoadingSpinner";
import { useWebSocketContext } from "@/providers/WebSocketProvider";

async function getPosts(): Promise<Ticker[]> {
const res = await fetch(`${API_BASE_URL}/tickers`);
Expand All @@ -18,14 +18,21 @@ async function getPosts(): Promise<Ticker[]> {
return tickers;
}
const TickerGrid = () => {
const { tickers: wsTickers } = useWebSocketContext();
const useFallback = !wsTickers || wsTickers.length === 0;
Comment on lines +21 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fallback logic ignores WebSocket connection status.

The useFallback condition only checks whether wsTickers has data, not whether the WebSocket is still connected. If the WebSocket disconnects after receiving initial data, the component will continue showing stale tickers without falling back to API polling.

Apply this diff to incorporate connection status:

-  const { tickers: wsTickers } = useWebSocketContext();
-  const useFallback = !wsTickers || wsTickers.length === 0;
+  const { tickers: wsTickers, connected } = useWebSocketContext();
+  const useFallback = !connected || !wsTickers || wsTickers.length === 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { tickers: wsTickers } = useWebSocketContext();
const useFallback = !wsTickers || wsTickers.length === 0;
const { tickers: wsTickers, connected } = useWebSocketContext();
const useFallback = !connected || !wsTickers || wsTickers.length === 0;
🤖 Prompt for AI Agents
In client/trading-dashboard/components/TickerGrid.tsx around lines 21-22, the
current useFallback only checks wsTickers presence and ignores the WebSocket
connection state; update the code to also read the connection flag (e.g.,
wsConnected or isConnected) from useWebSocketContext and set useFallback to true
when there are no tickers OR when the socket is not connected (for example:
useFallback = !wsTickers || wsTickers.length === 0 || !wsConnected), ensuring
you safely destructure the connection boolean from the context and handle
undefined values.

const { data, error } = useQuery<Ticker[], Error>({
queryKey: ["tickers"],
queryFn: getPosts,
refetchInterval: 1000,
refetchInterval: useFallback ? 1000 : false,
placeholderData: MOCK_TICKERS,
enabled: useFallback,
});

if (error) {
const tickersToShow: Ticker[] = useFallback
? data ?? MOCK_TICKERS
: wsTickers;

if (error && useFallback) {
console.error("Error fetching tickers:", error);
}

Expand All @@ -45,9 +52,9 @@ const TickerGrid = () => {
data-testid="ticker-grid"
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
{data &&
data.length > 0 &&
data?.map((ticker) => (
{tickersToShow &&
tickersToShow.length > 0 &&
tickersToShow.map((ticker) => (
<TickerCard key={ticker.symbol} {...ticker} />
))}
</div>
Expand Down
15 changes: 12 additions & 3 deletions client/trading-dashboard/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
// All constants
import { Ticker } from "@/types";

export const API_BASE_URL =
`${process.env.NEXT_PUBLIC_MARKET_TRADING_URL}/api` ||
"http://localhost:3005/api";
// Ensure sane defaults even when env vars are missing at build time
const PUBLIC_API_BASE =
process.env.NEXT_PUBLIC_MARKET_TRADING_URL &&
process.env.NEXT_PUBLIC_MARKET_TRADING_URL.trim() !== ""
? process.env.NEXT_PUBLIC_MARKET_TRADING_URL
: "http://localhost:3005";
export const API_BASE_URL = `${PUBLIC_API_BASE}/api`;

export const WS_URL =
process.env.NEXT_PUBLIC_WS_URL && process.env.NEXT_PUBLIC_WS_URL.trim() !== ""
? process.env.NEXT_PUBLIC_WS_URL
: "ws://localhost:8080";

export const MOCK_TICKERS: Ticker[] = [
{
Expand Down
3 changes: 2 additions & 1 deletion client/trading-dashboard/example.env
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
NEXT_PUBLIC_BASE_URL=http://localhost:3000
NEXT_PUBLIC_MARKET_TRADING_URL=http://localhost:3005
NEXT_PUBLIC_MARKET_TRADING_URL=http://localhost:3005
NEXT_PUBLIC_WS_URL=ws://localhost:8080
49 changes: 36 additions & 13 deletions client/trading-dashboard/hooks/useTradingData.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Ticker, HistoricalData } from "@/types";
import { API_BASE_URL, MOCK_TICKERS } from "@/constants";
import { generateMockHistory } from "@/lib/utils";
import { useWebSocketContext } from "@/providers/WebSocketProvider";

export const useTradingData = () => {
const [selectedTicker, setSelectedTicker] = useState<Ticker | null>(null);
const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
const [chartDays, setChartDays] = useState<number>(7);
const [error, setError] = useState<string | null>(null);
const priceStatus = useRef<{ [key: string]: "up" | "down" | "neutral" }>({});
const previousTickers = useRef<Ticker[]>([]);

// Fetch tickers with automatic polling every 2 seconds for tickers
const { data: tickers = [], isLoading: tickersLoading } = useQuery({
const { tickers: wsTickers, priceStatus: wsPriceStatus } =
useWebSocketContext();

// REST fallback only if WS not yet providing data
const useFallback = !wsTickers || wsTickers.length === 0;
const { data: restTickers = [], isLoading: restLoading } = useQuery({
queryKey: ["tickers"],
queryFn: async () => {
try {
Expand All @@ -29,12 +34,21 @@ export const useTradingData = () => {
return MOCK_TICKERS;
}
},
refetchInterval: 2000,
enabled: useFallback,
refetchInterval: useFallback ? 2000 : false,
staleTime: 0,
});

// Track price changes for animations
const tickers: Ticker[] = useFallback ? restTickers : wsTickers;

// Track price changes for animations (fallback when WS not in use)
useEffect(() => {
if (!useFallback) {
// When using WS, price status is provided by the provider
priceStatus.current = wsPriceStatus;
return;
}

if (previousTickers.current.length > 0 && tickers.length > 0) {
tickers.forEach((newTicker) => {
const oldTicker = previousTickers.current.find(
Expand All @@ -50,14 +64,20 @@ export const useTradingData = () => {
});
}
previousTickers.current = tickers;
}, [tickers]);
}, [tickers, useFallback, wsPriceStatus]);

// Set initial selected ticker
// Set initial selected symbol
useEffect(() => {
if (!selectedTicker && tickers.length > 0) {
setSelectedTicker(tickers[0]);
if (!selectedSymbol && tickers.length > 0) {
setSelectedSymbol(tickers[0].symbol);
}
}, [tickers, selectedTicker]);
}, [tickers, selectedSymbol]);

// Derive the selected ticker from the latest tickers list so it always stays fresh
const selectedTicker: Ticker | null = useMemo(() => {
if (!selectedSymbol) return null;
return tickers.find((t) => t.symbol === selectedSymbol) ?? null;
}, [tickers, selectedSymbol]);

// Fetch chart data when ticker or days change
const { data: chartData = [], isLoading: chartLoading } = useQuery({
Expand All @@ -83,11 +103,14 @@ export const useTradingData = () => {
return {
tickers,
selectedTicker,
setSelectedTicker,
setSelectedTicker: (ticker: Ticker) => setSelectedSymbol(ticker.symbol),
chartData,
chartDays,
setChartDays,
loading: { tickers: tickersLoading, chart: chartLoading },
loading: {
tickers: useFallback ? restLoading : false,
chart: chartLoading,
},
error,
priceStatus: priceStatus.current,
};
Expand Down
8 changes: 7 additions & 1 deletion client/trading-dashboard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/trading-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"next": "16.0.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-use-websocket": "^4.13.0",
"recharts": "^3.4.1",
"tailwind-merge": "^3.4.0"
},
Expand Down
Loading