From e9d255226bd36b38762cc28f3288663233b3eacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Wed, 27 May 2026 14:47:50 +0300 Subject: [PATCH 01/46] chore: bump version to 0.65.0 and introduce HomeHub component - Updated package version in package.json to 0.65.0. - Added HomeHub component to serve as a central dashboard for market insights, including AI stock ideas, market pulse, and exploration links. - Integrated HomeHub into the HomePageClient, replacing the previous layout for improved user experience. - Enhanced StockOfTheDayPanel and WorldMarkets components to align with the new HomeHub structure. - Updated styles and layout for better responsiveness and visual consistency across components. --- __tests__/performance.test.ts | 1 + app/(main)/home-page-client.tsx | 146 +---- app/(main)/stock-of-the-day/page.tsx | 4 +- components/FearGreedGauge.tsx | 544 ++++++++---------- components/HomeHub.tsx | 223 +++++++ components/StockOfTheDayPanel.tsx | 293 ++++++---- components/WorldMarkets.tsx | 97 ++-- components/__tests__/HomeHub.test.tsx | 43 ++ .../__tests__/StockOfTheDayPanel.test.tsx | 26 + lib/home-ui.ts | 18 + package.json | 2 +- 11 files changed, 828 insertions(+), 569 deletions(-) create mode 100644 components/HomeHub.tsx create mode 100644 components/__tests__/HomeHub.test.tsx create mode 100644 components/__tests__/StockOfTheDayPanel.test.tsx create mode 100644 lib/home-ui.ts diff --git a/__tests__/performance.test.ts b/__tests__/performance.test.ts index cda78f4..8595cc8 100644 --- a/__tests__/performance.test.ts +++ b/__tests__/performance.test.ts @@ -147,6 +147,7 @@ describe("Lazy loading behavior (Req 15.2)", () => { "AdBanner", "LazySection", "AIPredictionPanel", + "HomeHub", "StockOfTheDayPanel", ]; diff --git a/app/(main)/home-page-client.tsx b/app/(main)/home-page-client.tsx index 1e5743d..ce23b43 100644 --- a/app/(main)/home-page-client.tsx +++ b/app/(main)/home-page-client.tsx @@ -1,7 +1,6 @@ "use client"; import dynamic from "next/dynamic"; -import Link from "next/link"; import { useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { @@ -23,6 +22,7 @@ import { EXPLANATIONS_PROVIDER_CHANGED_EVENT } from "@/lib/explanation-provider" import { fetchAIPredictionForCurrentProvider } from "@/lib/local-ollama-ai-prediction"; import { fetchStockOfTheDayForCurrentProvider } from "@/lib/local-ollama-stock-of-the-day"; import { AIPredictionPanel } from "@/components/AIPredictionPanel"; +import { HomeHub } from "@/components/HomeHub"; import { StockOfTheDayPanel } from "@/components/StockOfTheDayPanel"; const OverviewTab = dynamic( () => import("@/components/OverviewTab").then((m) => m.OverviewTab), @@ -98,97 +98,6 @@ type TabType = | "forecasts" | "seasonals"; -const QUICK_LINKS = [ - { - id: "sectors", - label: "Sectors", - href: "/sectors", - description: "Compare sector performance", - icon: ( - - ), - }, - { - id: "heatmaps", - label: "Heatmaps", - href: "/heatmaps", - description: "Visual market overview", - icon: ( - - ), - }, - { - id: "screener", - label: "Screener", - href: "/screener", - description: "Filter and find assets", - icon: ( - - ), - }, - { - id: "calendars", - label: "Calendars", - href: "/calendars", - description: "Earnings, dividends & IPOs", - icon: ( - - ), - }, -] as const; - export function HomePageClient() { const router = useRouter(); const searchParams = useSearchParams(); @@ -533,40 +442,25 @@ export function HomePageClient() { )} {!selectedSymbol && ( -
-
-
- {QUICK_LINKS.map((link) => ( - - - {link.icon} - - - {link.label} - - - {link.description} - - - ))} -
- - -
- -
- -
+
+ + router.push(`/?symbol=${encodeURIComponent(symbol)}`) + } + fearGreed={} + worldMarkets={} + stockOfTheDay={ + + } + />
)} diff --git a/app/(main)/stock-of-the-day/page.tsx b/app/(main)/stock-of-the-day/page.tsx index d816749..e54368e 100644 --- a/app/(main)/stock-of-the-day/page.tsx +++ b/app/(main)/stock-of-the-day/page.tsx @@ -89,10 +89,10 @@ export default function StockOfTheDayPage() { return (
-

+

Stock of the day

-

+

AI-ranked daily opportunity across stocks and select liquid assets.

diff --git a/components/FearGreedGauge.tsx b/components/FearGreedGauge.tsx index 793ce59..a480b0d 100644 --- a/components/FearGreedGauge.tsx +++ b/components/FearGreedGauge.tsx @@ -13,6 +13,12 @@ import { useTheme } from "@/lib/theme-context"; import { FearGreedData } from "@/types"; import { LoadingSpinner } from "@/components/LoadingSpinner"; import { ErrorMessage } from "@/components/ErrorMessage"; +import { + HOME_INSTRUMENT_PANEL, + HOME_PANEL_TITLE, + HOME_RANGE_BUTTON_ACTIVE, + HOME_RANGE_BUTTON_IDLE, +} from "@/lib/home-ui"; export interface FearGreedGaugeProps { data?: FearGreedData; @@ -102,11 +108,8 @@ export function FearGreedGauge({ data: externalData }: FearGreedGaugeProps) { // --- Loading state --- if (loading) { return ( -
- +
+
); } @@ -114,10 +117,7 @@ export function FearGreedGauge({ data: externalData }: FearGreedGaugeProps) { // --- Error state --- if (error) { return ( -
+
fetchData()} />
); @@ -159,39 +159,33 @@ export function FearGreedGauge({ data: externalData }: FearGreedGaugeProps) { const nx = cx + needleLen * Math.cos(needleAngle); const ny = cy - needleLen * Math.sin(needleAngle); + const chartStroke = isDark ? "#a8a29e" : "#57534e"; + return ( -
- {/* Header with tooltip */} -
-

- Fear & Greed Index -

+
+
+
+

Fear & Greed Index

+

+ CNN sentiment gauge — 0 is extreme fear, 100 is extreme greed. +

+
setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} > {showTooltip && (
{TOOLTIP_TEXT}
@@ -199,283 +193,255 @@ export function FearGreedGauge({ data: externalData }: FearGreedGaugeProps) {
- {/* SVG Gauge */} -
setShowTooltip(true)} - onMouseLeave={() => setShowTooltip(false)} - > - - {/* Colored arc segments */} - {ranges.map((seg, i) => ( - - ))} - - {/* Tick marks at 0, 25, 50, 75, 100 */} - {[0, 25, 50, 75, 100].map((tick) => { - const a = Math.PI - (tick / 100) * Math.PI; - const inner = r - 14; - const outer = r + 14; - return ( - - ); - })} +
+
+
+ + {ranges.map((seg, i) => ( + + ))} - {/* Tick labels */} - {[0, 25, 50, 75, 100].map((tick) => { - const a = Math.PI - (tick / 100) * Math.PI; - const labelR = r + 26; - return ( - - {tick} - - ); - })} + {[0, 25, 50, 75, 100].map((tick) => { + const a = Math.PI - (tick / 100) * Math.PI; + const inner = r - 14; + const outer = r + 14; + return ( + + ); + })} - {/* Needle */} - - - -
+ {[0, 25, 50, 75, 100].map((tick) => { + const a = Math.PI - (tick / 100) * Math.PI; + const labelR = r + 26; + return ( + + {tick} + + ); + })} - {/* Current value & label */} -
- - {Math.round(value)} - -

- {label} -

-
+ + + +
- {/* Range legend */} -
- {[ - { label: "Extreme Fear", color: "#dc2626" }, - { label: "Fear", color: "#f97316" }, - { label: "Neutral", color: "#eab308" }, - { label: "Greed", color: "#84cc16" }, - { label: "Extreme Greed", color: "#22c55e" }, - ].map((r) => ( - +
+

+ Today +

- {r.label} - - ))} -
- - {/* Historical timeline */} - {data.history && data.history.length > 0 && ( -
-
-

- Historical Timeline -

- {!externalData && ( -
- {(["1W", "1M", "3M", "1Y", "5Y", "YTD", "Max"] as const).map( - (range) => ( - - ) - )} -
- )} -
-
- setHoveredPoint(null)} - onMouseMove={(e) => { - const svg = e.currentTarget; - const rect = svg.getBoundingClientRect(); - const relX = (e.clientX - rect.left) / rect.width; - const idx = Math.round(relX * (data.history.length - 1)); - const clamped = Math.max( - 0, - Math.min(data.history.length - 1, idx) - ); - const point = data.history[clamped]; - if (point) { - // Snap X to the data point's position - const snapX = - (clamped / Math.max(data.history.length - 1, 1)) * - rect.width; - // Snap Y to the data value (0 at bottom, 100 at top) - const snapY = ((100 - point.value) / 100) * rect.height; - setHoveredPoint({ - x: snapX, - y: snapY, - value: point.value, - date: new Date(point.date), - }); - } - }} + {Math.round(value)} + +

- {/* Background bands (top = 100/Extreme Greed, bottom = 0/Extreme Fear) */} - - - - - - - {/* Line chart */} - { - const x = - (i / Math.max(data.history.length - 1, 1)) * - Math.max(data.history.length * 4, 100); - const y = 100 - h.value; - return `${x},${y}`; - }) - .join(" ")} - /> - + {label} +

+
+ {[ + { label: "Extreme Fear", color: "#dc2626" }, + { label: "Fear", color: "#f97316" }, + { label: "Neutral", color: "#eab308" }, + { label: "Greed", color: "#84cc16" }, + { label: "Extreme Greed", color: "#22c55e" }, + ].map((rangeItem) => ( + + + {rangeItem.label} + + ))} +
+
+
- {/* Hover crosshair lines */} - {hoveredPoint && ( - <> + {data.history && data.history.length > 0 && ( +
+
+

+ Historical timeline +

+ {!externalData && (
+ {(["1W", "1M", "3M", "1Y", "5Y", "YTD", "Max"] as const).map( + (range) => ( + + ) + )} +
+ )} +
+
+ setHoveredPoint(null)} + onMouseMove={(e) => { + const svg = e.currentTarget; + const rect = svg.getBoundingClientRect(); + const relX = (e.clientX - rect.left) / rect.width; + const idx = Math.round(relX * (data.history.length - 1)); + const clamped = Math.max( + 0, + Math.min(data.history.length - 1, idx) + ); + const point = data.history[clamped]; + if (point) { + // Snap X to the data point's position + const snapX = + (clamped / Math.max(data.history.length - 1, 1)) * + rect.width; + // Snap Y to the data value (0 at bottom, 100 at top) + const snapY = ((100 - point.value) / 100) * rect.height; + setHoveredPoint({ + x: snapX, + y: snapY, + value: point.value, + date: new Date(point.date), + }); + } + }} + > + {/* Background bands (top = 100/Extreme Greed, bottom = 0/Extreme Fear) */} + + + + + + + {/* Line chart */} + { + const x = + (i / Math.max(data.history.length - 1, 1)) * + Math.max(data.history.length * 4, 100); + const y = 100 - h.value; + return `${x},${y}`; + }) + .join(" ")} /> + + + {/* Hover crosshair lines */} + {hoveredPoint && ( + <> +
+
+ + )} + + {hoveredPoint && (
- - )} + className="pointer-events-none absolute z-10 whitespace-nowrap rounded border border-stone-700 bg-stone-900 px-2 py-1 text-xs text-stone-100 shadow-lg" + style={{ + left: Math.min(hoveredPoint.x, 200), + top: Math.max(hoveredPoint.y - 32, 0), + }} + data-testid="fear-greed-chart-tooltip" + > + {hoveredPoint.date.toLocaleDateString()} —{" "} + + {hoveredPoint.value} + {" "} + ({getLabel(hoveredPoint.value)}) +
+ )} - {/* Hover tooltip */} - {hoveredPoint && ( -
- {hoveredPoint.date.toLocaleDateString()} —{" "} - - {hoveredPoint.value} - {" "} - ({getLabel(hoveredPoint.value)}) +
+ + 100 + + + 0 +
- )} - - {/* Y-axis labels */} -
- - 100 +
+
+ + {new Date(data.history[0].date).toLocaleDateString()} - - 0 + + {new Date( + data.history[data.history.length - 1].date + ).toLocaleDateString()}
- {/* Date range */} -
- - {new Date(data.history[0].date).toLocaleDateString()} - - - {new Date( - data.history[data.history.length - 1].date - ).toLocaleDateString()} - -
-
- )} + )} +
); } diff --git a/components/HomeHub.tsx b/components/HomeHub.tsx new file mode 100644 index 0000000..188056b --- /dev/null +++ b/components/HomeHub.tsx @@ -0,0 +1,223 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; +import { SearchBar } from "@/components/SearchBar"; +import { HOME_SECTION_LABEL } from "@/lib/home-ui"; +import { SITE_NAME, SITE_TAGLINE } from "@/lib/site-seo"; + +const EXPLORE_LINKS = [ + { + id: "sectors", + label: "Sectors", + href: "/sectors", + description: "Compare sector performance", + featured: true, + icon: ( + + ), + }, + { + id: "heatmaps", + label: "Heatmaps", + href: "/heatmaps", + description: "Visual market overview", + featured: false, + icon: ( + + ), + }, + { + id: "screener", + label: "Screener", + href: "/screener", + description: "Filter and find assets", + featured: false, + icon: ( + + ), + }, + { + id: "calendars", + label: "Calendars", + href: "/calendars", + description: "Earnings, dividends & IPOs", + featured: false, + icon: ( + + ), + }, +] as const; + +const SECTION_LABEL_CLASS = HOME_SECTION_LABEL; + +export interface HomeHubProps { + onSymbolSelect: (symbol: string) => void; + fearGreed: ReactNode; + worldMarkets: ReactNode; + stockOfTheDay: ReactNode; +} + +function ExploreArrow({ className }: { className?: string }) { + return ( + + ); +} + +export function HomeHub({ + onSymbolSelect, + fearGreed, + worldMarkets, + stockOfTheDay, +}: HomeHubProps) { + const featured = EXPLORE_LINKS.find((link) => link.featured)!; + const secondary = EXPLORE_LINKS.filter((link) => !link.featured); + + return ( +
+
+

Market dashboard

+

+ {SITE_NAME} +

+

+ {SITE_TAGLINE}. Search a ticker for charts, fundamentals, and optional + AI stance — or browse tools below. +

+
+ +
+
+ +
+

+ Explore +

+
+ + + {featured.icon} + +
+

+ Start here +

+

+ {featured.label} +

+

+ {featured.description} +

+
+ + + + {secondary.map((link) => ( + + + {link.icon} + + + + + {link.label} + + + + + {link.description} + + + + ))} +
+
+ +
+

+ Market pulse +

+ {fearGreed} + {worldMarkets} +
+ +
+

+ AI outlook +

+ {stockOfTheDay} +
+
+ ); +} diff --git a/components/StockOfTheDayPanel.tsx b/components/StockOfTheDayPanel.tsx index f6aa569..2c53d94 100644 --- a/components/StockOfTheDayPanel.tsx +++ b/components/StockOfTheDayPanel.tsx @@ -4,6 +4,11 @@ import Link from "next/link"; import type { PricingTier, StockOfTheDay, StockOfTheDayResult } from "@/types"; import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux"; import { ConfidenceInfoTooltip } from "@/components/ConfidenceInfoTooltip"; +import { + HOME_INSTRUMENT_PANEL, + HOME_PANEL_TITLE, + HOME_PRIMARY_BUTTON, +} from "@/lib/home-ui"; import { isMissingByokApiKeyMessage } from "@/lib/missing-byok-api-key"; interface StockOfTheDayPanelProps { @@ -11,127 +16,217 @@ interface StockOfTheDayPanelProps { loading: boolean; locked: boolean; error?: string | null; - /** When locked, used to explain which upgrade path applies. */ pricingTier?: PricingTier | null; + /** When true, omits outer section spacing (used inside HomeHub). */ + embedded?: boolean; + /** When false, skips the in-panel title (HomeHub provides "AI outlook"). */ + showTitle?: boolean; } -export function StockOfTheDayPanel({ - item, - loading, - locked, - error, +function LockedGate({ pricingTier, -}: StockOfTheDayPanelProps) { - const renderPick = (title: string, pick: StockOfTheDay) => ( -
+ compactTitle, +}: { + pricingTier?: PricingTier | null; + compactTitle?: boolean; +}) { + return ( +
+ {compactTitle &&

Daily AI stock ideas

} +

+ {getAiSubscriptionGateMessage(pricingTier ?? undefined)} +

+
+ + View AI plans + +
+
+ ); +} + +function StanceLabel({ + recommendation, +}: { + recommendation: StockOfTheDay["recommendation"]; +}) { + const isBuy = recommendation === "buy"; + return ( + + {recommendation} + + ); +} + +function PickCard({ + title, + pick, + variant, +}: { + title: string; + pick: StockOfTheDay; + variant: "buy" | "sell"; +}) { + const borderClass = + variant === "buy" + ? "border-l-emerald-600 dark:border-l-emerald-500" + : "border-l-rose-600 dark:border-l-rose-500"; + + return ( +
-
-

+

+

{title}

-

- {pick.symbol} - {pick.name} +

+ {pick.symbol} + + {" "} + · {pick.name} +

- - {pick.recommendation} - +
-

+

Confidence {Math.round(pick.confidence * 100)}%

-
    - {pick.rationale.map((reason) => ( -
  • - {reason}
  • +
      + {pick.rationale.map((reason, index) => ( +
    1. + + {index + 1}. + + {reason} +
    2. ))} -
-
+ +
); +} - return ( -
-
-
-
-

- AI stock ideas of the day -

- {item && ( - - Generated {new Date(item.generatedAt).toLocaleDateString()} - - )} -
+export function StockOfTheDayPanel({ + item, + loading, + locked, + error, + pricingTier, + embedded = false, + showTitle = true, +}: StockOfTheDayPanelProps) { + const showLockedOverlay = locked && Boolean(item); + const showLockedGateOnly = locked && !item && !loading; - {loading && ( -

- Computing today's top pick... -

- )} + const shell = ( +
+ {showLockedGateOnly ? ( + + ) : ( + <> +
+ {(showTitle || item) && ( +
+ {showTitle && ( +
+

Daily AI stock ideas

+

+ One buy and one sell candidate from your configured model. +

+
+ )} + {item && ( +

+ Generated {new Date(item.generatedAt).toLocaleDateString()} +

+ )} +
+ )} - {!loading && item && ( -
- {renderPick("Stock of the day to buy", item.buy)} - {renderPick("Stock of the day to sell", item.sell)} -
- )} + {loading && ( +

+ Computing today's picks... +

+ )} - {!loading && !item && !locked && error && ( -
-

{error}

- {isMissingByokApiKeyMessage(error) && ( -
-

- Add your API key on the Profile page under API keys, then - pick the same provider as your explanation model. -

- - Open profile - + {!loading && item && ( +
+
+
- )} -
- )} +
+ +
+
+ )} - {!loading && !item && !locked && !error && ( -

- No stock-of-the-day result yet. Refresh to try again. -

- )} -
+ {!loading && !item && !locked && error && ( +
+

{error}

+ {isMissingByokApiKeyMessage(error) && ( +
+

+ Add your API key on the Profile page under API keys, then + pick the same provider as your explanation model. +

+ + Open profile + +
+ )} +
+ )} - {locked && ( -
-

- {getAiSubscriptionGateMessage(pricingTier ?? undefined)} -

- - View AI plans - + {!loading && !item && !locked && !error && ( +

+ No stock-of-the-day result yet. Refresh to try again. +

+ )}
- )} -
-
+ + {showLockedOverlay && ( +
+

+ {getAiSubscriptionGateMessage(pricingTier ?? undefined)} +

+ + View AI plans + +
+ )} + + )} +
); + + if (embedded) { + return shell; + } + + return
{shell}
; } diff --git a/components/WorldMarkets.tsx b/components/WorldMarkets.tsx index 889ca52..2f1127c 100644 --- a/components/WorldMarkets.tsx +++ b/components/WorldMarkets.tsx @@ -10,13 +10,26 @@ */ import { useState, useEffect, useCallback, useRef } from "react"; -import { useTheme } from "@/lib/theme-context"; import { MarketIndex } from "@/types"; import { LoadingSpinner } from "@/components/LoadingSpinner"; import { ErrorMessage } from "@/components/ErrorMessage"; - -const REGIONS = ["Americas", "Europe", "Asia-Pacific"] as const; -type Region = (typeof REGIONS)[number]; +import { HOME_INSTRUMENT_PANEL, HOME_PANEL_TITLE } from "@/lib/home-ui"; + +type Region = "Americas" | "Europe" | "Asia-Pacific"; + +const REGIONS: ReadonlyArray<{ + id: Region; + label: string; + gridClass: string; +}> = [ + { id: "Americas", label: "Americas", gridClass: "lg:col-span-6" }, + { id: "Europe", label: "Europe", gridClass: "lg:col-span-3" }, + { + id: "Asia-Pacific", + label: "Asia-Pacific", + gridClass: "lg:col-span-3", + }, +]; const DEFAULT_REFRESH_INTERVAL = 60_000; // 60 seconds @@ -48,9 +61,6 @@ export function WorldMarkets({ data: externalData, refreshInterval = DEFAULT_REFRESH_INTERVAL, }: WorldMarketsProps) { - const { resolvedTheme } = useTheme(); - const isDark = resolvedTheme === "dark"; - const [data, setData] = useState(externalData ?? null); const [loading, setLoading] = useState(!externalData); const [error, setError] = useState(null); @@ -112,21 +122,17 @@ export function WorldMarkets({ if (loading) { return (
- +
); } - // --- Error state --- if (error) { return ( -
+
-

- World Markets -

+

World Markets

+

+ Major indices by region — refreshed every minute. +

-
+
{REGIONS.map((region) => { - const indices = grouped[region]; + const indices = grouped[region.id]; if (indices.length === 0) return null; return (
-

- {region} +

+ {region.label} + {region.id === "Americas" && ( + + · primary session + + )}

-
    +
      {indices.map((idx) => { const isPositive = idx.changePercent >= 0; const colorClass = isPositive - ? "text-green-500" - : "text-red-500"; + ? "text-emerald-600 dark:text-emerald-400" + : "text-rose-600 dark:text-rose-400"; return (
    • -

      +

      {idx.name}

      -

      +

      {idx.symbol}

      {formatValue(idx.value)} diff --git a/components/__tests__/HomeHub.test.tsx b/components/__tests__/HomeHub.test.tsx new file mode 100644 index 0000000..389c65a --- /dev/null +++ b/components/__tests__/HomeHub.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { HomeHub } from "../HomeHub"; + +vi.mock("@/components/SearchBar", () => ({ + SearchBar: ({ + placeholder, + onSelect, + }: { + placeholder?: string; + onSelect?: (symbol: string) => void; + }) => ( + onSelect?.("AAPL")} + /> + ), +})); + +describe("HomeHub", () => { + it("renders hero, explore links, and section labels", () => { + render( + } + worldMarkets={

      } + stockOfTheDay={
      } + /> + ); + + expect(document.getElementById("section-home")).toBeInTheDocument(); + expect(screen.getByText(/Market dashboard/i)).toBeInTheDocument(); + expect(screen.getByText("Explore")).toBeInTheDocument(); + expect(screen.getByText("Market pulse")).toBeInTheDocument(); + expect(screen.getByText("AI outlook")).toBeInTheDocument(); + expect(screen.getByText("Compare sector performance")).toBeInTheDocument(); + expect(screen.getByText("Visual market overview")).toBeInTheDocument(); + expect(screen.getByTestId("fear-greed")).toBeInTheDocument(); + expect(screen.getByTestId("world-markets-slot")).toBeInTheDocument(); + expect(screen.getByTestId("stock-of-day-slot")).toBeInTheDocument(); + }); +}); diff --git a/components/__tests__/StockOfTheDayPanel.test.tsx b/components/__tests__/StockOfTheDayPanel.test.tsx new file mode 100644 index 0000000..1dea372 --- /dev/null +++ b/components/__tests__/StockOfTheDayPanel.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { StockOfTheDayPanel } from "../StockOfTheDayPanel"; + +describe("StockOfTheDayPanel locked state", () => { + it("renders a readable gate when locked without data (home free tier)", () => { + render( + + ); + + expect( + screen.getByText(/Your current plan does not include AI/i) + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /View AI plans/i }) + ).toBeInTheDocument(); + expect(screen.getByText("Daily AI stock ideas")).toBeInTheDocument(); + }); +}); diff --git a/lib/home-ui.ts b/lib/home-ui.ts new file mode 100644 index 0000000..2b0bfb4 --- /dev/null +++ b/lib/home-ui.ts @@ -0,0 +1,18 @@ +/** Shared surfaces for the home dashboard (Market pulse, AI panels, etc.). */ +export const HOME_INSTRUMENT_PANEL = + "rounded-xl border border-stone-200/90 bg-white/90 p-4 sm:p-6 dark:border-stone-700 dark:bg-stone-800/60"; + +export const HOME_PANEL_TITLE = + "text-base font-semibold tracking-tight text-stone-900 dark:text-stone-100"; + +export const HOME_SECTION_LABEL = + "text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400"; + +export const HOME_PRIMARY_BUTTON = + "inline-flex items-center rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-stone-50 transition-colors hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-stone-200"; + +export const HOME_RANGE_BUTTON_ACTIVE = + "bg-stone-900 text-stone-50 dark:bg-stone-100 dark:text-stone-900"; + +export const HOME_RANGE_BUTTON_IDLE = + "bg-stone-100 text-stone-600 hover:bg-stone-200 dark:bg-stone-900 dark:text-stone-300 dark:hover:bg-stone-800"; diff --git a/package.json b/package.json index 88aefc6..8612b3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "the-open-stock", - "version": "0.64.0", + "version": "0.65.0", "private": true, "scripts": { "dev": "bun --bun next dev", From 957b6ccebee080a450aea7adb50e6107fac6a04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Wed, 27 May 2026 14:48:18 +0300 Subject: [PATCH 02/46] fix: update color classes in WorldMarkets tests and e2e specs - Changed color class assertions in WorldMarkets test from "text-green-500" to "text-emerald-600" for positive changes. - Updated negative change assertions from "text-red-500" to "text-rose-600" in both WorldMarkets test and e2e specs to reflect new styling. - Removed outdated Playwright report files and cleaned up test results to ensure accuracy and relevance. --- components/__tests__/WorldMarkets.test.tsx | 8 +- e2e/world-markets.spec.ts | 4 +- ...44cac39328854ded046e6f65c9fa60f742aada3.md | 21 - playwright-report/index.html | 24304 +--------------- test-results/.last-run.json | 14 +- .../error-context.md | 21 - .../error-context.md | 21 - .../error-context.md | 21 - .../error-context.md | 21 - .../error-context.md | 21 - .../error-context.md | 21 - .../error-context.md | 21 - .../error-context.md | 21 - .../error-context.md | 21 - .../error-context.md | 21 - .../error-context.md | 21 - 16 files changed, 77 insertions(+), 24505 deletions(-) delete mode 100644 playwright-report/data/f44cac39328854ded046e6f65c9fa60f742aada3.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-2a820-rview-tab-active-by-default-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-3159d-hanging-time-range-on-chart-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-51074--header-with-name-and-price-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-52f2a-sponsive-on-mobile-viewport-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-7130d-key-metrics-in-Overview-tab-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-7fd39-ld-switch-tabs-when-clicked-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-8229d-display-all-navigation-tabs-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-8cb09-e-with-correct-color-coding-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-98b83-how-tooltip-on-metric-hover-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-99b9b-ice-section-in-Overview-tab-chromium/error-context.md delete mode 100644 test-results/symbol-detail-Symbol-Detai-b9e34-price-chart-in-Overview-tab-chromium/error-context.md diff --git a/components/__tests__/WorldMarkets.test.tsx b/components/__tests__/WorldMarkets.test.tsx index 04f0a0e..a38ba11 100644 --- a/components/__tests__/WorldMarkets.test.tsx +++ b/components/__tests__/WorldMarkets.test.tsx @@ -117,19 +117,19 @@ describe("WorldMarkets", () => { // Positive: SPX (+0.87%) const spxChange = screen.getByTestId("change-SPX"); - expect(spxChange.className).toContain("text-green-500"); + expect(spxChange.className).toContain("text-emerald-600"); // Negative: DJI (-0.31%) const djiChange = screen.getByTestId("change-DJI"); - expect(djiChange.className).toContain("text-red-500"); + expect(djiChange.className).toContain("text-rose-600"); // Negative: FTSE (-0.27%) const ftseChange = screen.getByTestId("change-FTSE"); - expect(ftseChange.className).toContain("text-red-500"); + expect(ftseChange.className).toContain("text-rose-600"); // Positive: N225 (+0.81%) const n225Change = screen.getByTestId("change-N225"); - expect(n225Change.className).toContain("text-green-500"); + expect(n225Change.className).toContain("text-emerald-600"); }); it("should show loading state when fetching data", () => { diff --git a/e2e/world-markets.spec.ts b/e2e/world-markets.spec.ts index ae7fb7b..7dc35d9 100644 --- a/e2e/world-markets.spec.ts +++ b/e2e/world-markets.spec.ts @@ -45,8 +45,8 @@ test.describe("World Markets", () => { await expect(worldMarkets).toBeVisible({ timeout: 10000 }); // Check that at least one element has the green or red color class - const greenCount = await worldMarkets.locator(".text-green-500").count(); - const redCount = await worldMarkets.locator(".text-red-500").count(); + const greenCount = await worldMarkets.locator(".text-emerald-600").count(); + const redCount = await worldMarkets.locator(".text-rose-600").count(); expect(greenCount + redCount).toBeGreaterThan(0); }); diff --git a/playwright-report/data/f44cac39328854ded046e6f65c9fa60f742aada3.md b/playwright-report/data/f44cac39328854ded046e6f65c9fa60f742aada3.md deleted file mode 100644 index 95d5f3c..0000000 --- a/playwright-report/data/f44cac39328854ded046e6f65c9fa60f742aada3.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/playwright-report/index.html b/playwright-report/index.html index d947c0e..1bf2380 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -1,24251 +1,85 @@ - - + + + + - - - + + + Playwright Test Report - - +`.trimStart();async function zv({testInfo:l,metadata:u,errorContext:c,errors:f,buildCodeFrame:r,stdout:o,stderr:h}){var S;const v=new Set(f.filter(O=>O.message&&!O.message.includes(` +`)).map(O=>O.message));for(const O of f)for(const X of v.keys())(S=O.message)!=null&&S.includes(X)&&v.delete(X);const y=f.filter(O=>!(!O.message||!O.message.includes(` +`)&&!v.has(O.message)));if(!y.length)return;const A=[Qv,"# Test info","",l];o&&A.push("","# Stdout","","```",Jf(o),"```"),h&&A.push("","# Stderr","","```",Jf(h),"```"),A.push("","# Error details");for(const O of y)A.push("","```",Jf(O.message||""),"```");c&&A.push(c);const E=await r(y[y.length-1]);return E&&A.push("","# Test source","","```ts",E,"```"),u!=null&&u.gitDiff&&A.push("","# Local changes","","```diff",u.gitDiff,"```"),A.join(` +`)}const Yv=new RegExp("([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))","g");function Jf(l){return l.replace(Yv,"")}function Lv(l,u){var f;const c=new Map;for(const r of l){const o=r.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);if(!o)continue;const[,h,v,y=""]=o,A=h+y;let E=c.get(A);E||(E={name:A,anchors:[`attachment-${h}`]},c.set(A,E)),E.anchors.push(`attachment-${u.attachments.indexOf(r)}`),v==="actual"&&(E.actual={attachment:r}),v==="expected"&&(E.expected={attachment:r,title:"Expected"}),v==="previous"&&(E.expected={attachment:r,title:"Previous"}),v==="diff"&&(E.diff={attachment:r})}for(const[r,o]of c)!o.actual||!o.expected?c.delete(r):(l.delete(o.actual.attachment),l.delete(o.expected.attachment),l.delete((f=o.diff)==null?void 0:f.attachment));return[...c.values()]}const Gv=({test:l,result:u,testRunMetadata:c,options:f})=>{const{screenshots:r,videos:o,traces:h,otherAttachments:v,diffs:y,errors:A,otherAttachmentAnchors:E,screenshotAnchors:S,errorContext:O}=ct.useMemo(()=>{const B=u.attachments.filter(N=>!N.name.startsWith("_")),b=new Set(B.filter(N=>N.contentType.startsWith("image/"))),p=[...b].map(N=>`attachment-${B.indexOf(N)}`),x=B.filter(N=>N.contentType.startsWith("video/")),R=B.filter(N=>N.name==="trace"),U=B.find(N=>N.name==="error-context"),Z=new Set(B);[...b,...x,...R].forEach(N=>Z.delete(N));const F=[...Z].map(N=>`attachment-${B.indexOf(N)}`),j=Lv(b,u),D=u.errors.map(N=>N.message);return{screenshots:[...b],videos:x,traces:R,otherAttachments:Z,diffs:j,errors:D,otherAttachmentAnchors:F,screenshotAnchors:p,errorContext:U}},[u]),X=P5(async()=>{if(f!=null&&f.noCopyPrompt)return;const B=u.attachments.find(R=>R.name==="stdout"),b=u.attachments.find(R=>R.name==="stderr"),p=B!=null&&B.body&&B.contentType==="text/plain"?B.body:void 0,x=b!=null&&b.body&&b.contentType==="text/plain"?b.body:void 0;return await zv({testInfo:[`- Name: ${l.path.join(" >> ")} >> ${l.title}`,`- Location: ${l.location.file}:${l.location.line}:${l.location.column}`].join(` +`),metadata:c,errorContext:O!=null&&O.path?await fetch(O.path).then(R=>R.text()):O==null?void 0:O.body,errors:u.errors,buildCodeFrame:async R=>R.codeframe,stdout:p,stderr:x})},[l,O,c,u],void 0);return m.jsxs("div",{className:"test-result",children:[!!A.length&&m.jsxs(ke,{header:"Errors",children:[X&&m.jsx("div",{style:{position:"absolute",right:"16px",padding:"10px",zIndex:1},children:m.jsx(Nv,{prompt:X})}),A.map((B,b)=>{const p=Xv(B,y);return m.jsxs(m.Fragment,{children:[m.jsx(wr,{code:B},"test-result-error-message-"+b),p&&m.jsx(Bv,{diff:p})]})})]}),!!u.steps.length&&m.jsx(ke,{header:"Test Steps",children:u.steps.map((B,b)=>m.jsx(cm,{step:B,result:u,test:l,depth:0},`step-${b}`))}),y.map((B,b)=>m.jsx(Si,{id:B.anchors,children:m.jsx(ke,{dataTestId:"test-results-image-diff",header:`Image mismatch: ${B.name}`,revealOnAnchorId:B.anchors,children:m.jsx(um,{diff:B})})},`diff-${b}`)),!!r.length&&m.jsx(ke,{header:"Screenshots",revealOnAnchorId:S,children:r.map((B,b)=>m.jsxs(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:[m.jsx("a",{href:Ve(B.path),children:m.jsx("img",{className:"screenshot",src:Ve(B.path)})}),m.jsx(nc,{attachment:B,result:u})]},`screenshot-${b}`))}),!!h.length&&m.jsx(Si,{id:"attachment-trace",children:m.jsx(ke,{header:"Traces",revealOnAnchorId:"attachment-trace",children:m.jsxs("div",{children:[m.jsx("a",{href:Ve(nm(h)),children:m.jsx("img",{className:"screenshot",src:Cv,style:{width:192,height:117,marginLeft:20}})}),h.map((B,b)=>m.jsx(nc,{attachment:B,result:u,linkName:h.length===1?"trace":`trace-${b+1}`},`trace-${b}`))]})})}),!!o.length&&m.jsx(Si,{id:"attachment-video",children:m.jsx(ke,{header:"Videos",revealOnAnchorId:"attachment-video",children:o.map(B=>m.jsxs("div",{children:[m.jsx("video",{controls:!0,children:m.jsx("source",{src:Ve(B.path),type:B.contentType})}),m.jsx(nc,{attachment:B,result:u})]},B.path))})}),!!v.size&&m.jsx(ke,{header:"Attachments",revealOnAnchorId:E,dataTestId:"attachments",children:[...v].map((B,b)=>m.jsx(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:m.jsx(nc,{attachment:B,result:u,openInNewTab:B.contentType.startsWith("text/html")})},`attachment-link-${b}`))})]})};function Xv(l,u){const c=l.split(` +`)[0];if(!(!c.includes("toHaveScreenshot")&&!c.includes("toMatchSnapshot")))return u.find(f=>l.includes(f.name))}const cm=({test:l,step:u,result:c,depth:f})=>{const r=se();return m.jsx(Tv,{title:m.jsxs("div",{"aria-label":u.title,className:"step-title-container",children:[hc(u.error||u.duration===-1?"failed":u.skipped?"skipped":"passed"),m.jsxs("span",{className:"step-title-text",children:[m.jsx("span",{children:u.title}),u.count>1&&m.jsxs(m.Fragment,{children:[" ✕ ",m.jsx("span",{className:"test-result-counter",children:u.count})]}),u.location&&m.jsxs("span",{className:"test-result-path",children:["— ",u.location.file,":",u.location.line]})]}),m.jsx("span",{className:"step-spacer"}),u.attachments.length>0&&m.jsx("a",{className:"step-attachment-link",title:"reveal attachment",href:Ve(Cn({test:l,result:c,anchor:`attachment-${u.attachments[0]}`},r)),onClick:o=>{o.stopPropagation()},children:Ih()}),m.jsx("span",{className:"step-duration",children:Ol(u.duration)})]}),loadChildren:u.steps.length||u.snippet?()=>{const o=u.snippet?[m.jsx(wr,{testId:"test-snippet",code:u.snippet},"line")]:[],h=u.steps.map((v,y)=>m.jsx(cm,{step:v,depth:f+1,result:c,test:l},y));return o.concat(h)}:void 0,depth:f})},Vv=({projectNames:l,test:u,testRunMetadata:c,run:f,next:r,prev:o,options:h})=>{const[v,y]=ct.useState(f),A=se(),E=u.annotations.filter(S=>!S.type.startsWith("_"))??[];return m.jsxs(m.Fragment,{children:[m.jsx(Or,{title:u.title,leftSuperHeader:m.jsx("div",{className:"test-case-path",children:u.path.join(" › ")}),rightSuperHeader:m.jsxs(m.Fragment,{children:[m.jsx("div",{className:Ze(!o&&"hidden"),children:m.jsx(Tn,{href:Cn({test:o},A),children:"« previous"})}),m.jsx("div",{style:{width:10}}),m.jsx("div",{className:Ze(!r&&"hidden"),children:m.jsx(Tn,{href:Cn({test:r},A),children:"next »"})})]})}),m.jsxs("div",{className:"hbox",style:{lineHeight:"24px"},children:[m.jsx("div",{className:"test-case-location",children:m.jsxs(Sr,{value:`${u.location.file}:${u.location.line}`,children:[u.location.file,":",u.location.line]})}),m.jsx("div",{style:{flex:"auto"}}),m.jsx(tm,{test:u,trailingSeparator:!0}),m.jsx("div",{className:"test-case-duration",children:Ol(u.duration)})]}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:l,activeProjectName:u.projectName,otherLabels:u.tags}),u.results.length===0&&E.length!==0&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:E.map((S,O)=>m.jsx(z2,{annotation:S},O))}),m.jsx(Sv,{tabs:u.results.map((S,O)=>({id:String(O),title:m.jsxs("div",{style:{display:"flex",alignItems:"center"},children:[hc(S.status)," ",Zv(O),u.results.length>1&&m.jsx("span",{className:"test-case-run-duration",children:Ol(S.duration)})]}),render:()=>{const X=S.annotations.filter(B=>!B.type.startsWith("_"));return m.jsxs(m.Fragment,{children:[!!X.length&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:X.map((B,b)=>m.jsx(z2,{annotation:B},b))}),m.jsx(Gv,{test:u,result:S,testRunMetadata:c,options:h})]})}}))||[],selectedTab:String(v),setSelectedTab:S=>y(+S)})]})};function z2({annotation:{type:l,description:u}}){return m.jsxs("div",{className:"test-case-annotation",children:[m.jsx("span",{style:{fontWeight:"bold"},children:l}),u&&m.jsxs(Sr,{value:u,children:[": ",Di(u)]})]})}function Zv(l){return l?`Retry #${l}`:"Run"}const sm=({file:l,projectNames:u,isFileExpanded:c,setFileExpanded:f,footer:r})=>{const o=se();return m.jsx(im,{expanded:c?c(l.fileId):void 0,noInsets:!0,setExpanded:f?(h=>f(l.fileId,h)):void 0,header:m.jsx("span",{className:"chip-header-allow-selection",children:l.fileName}),footer:r,children:l.tests.map(h=>m.jsxs("div",{className:Ze("test-file-test","test-file-test-outcome-"+h.outcome),children:[m.jsxs("div",{className:"hbox",style:{alignItems:"flex-start"},children:[m.jsxs("div",{className:"hbox",children:[m.jsx("span",{className:"test-file-test-status-icon",children:hc(h.outcome)}),m.jsxs("span",{children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),children:m.jsx("span",{className:"test-file-title",children:[...h.path,h.title].join(" › ")})}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:u,activeProjectName:h.projectName,otherLabels:h.tags})]})]}),m.jsx("span",{"data-testid":"test-duration",style:{minWidth:"50px",textAlign:"right"},children:Ol(h.duration)})]}),m.jsx("div",{className:"test-file-details-row",children:m.jsxs("div",{className:"test-file-details-row-items",children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),className:"test-file-path-link",children:m.jsxs("span",{className:"test-file-path",children:[h.location.file,":",h.location.line]})}),m.jsx(qv,{test:h}),m.jsx(Iv,{test:h}),m.jsx(tm,{test:h,dim:!0})]})})]},`test-${h.testId}`))})};function qv({test:l}){const u=se();for(const c of l.results)for(const f of c.attachments)if(f.contentType.startsWith("image/")&&f.name.match(/-(expected|actual|diff)/))return m.jsx(Tr,{href:Cn({test:l,result:c,anchor:`attachment-${c.attachments.indexOf(f)}`},u),title:"View images",dim:!0,children:k5()})}function Iv({test:l}){const u=se(),c=l.results.find(f=>f.attachments.some(r=>r.name==="video"));return c?m.jsx(Tr,{href:Cn({test:l,result:c,anchor:"attachment-video"},u),title:"View video",dim:!0,children:J5()}):void 0}class Kv extends ct.Component{constructor(){super(...arguments);yn(this,"state",{error:null,errorInfo:null})}componentDidCatch(c,f){this.setState({error:c,errorInfo:f})}render(){var c,f,r;return this.state.error||this.state.errorInfo?m.jsxs("div",{className:"metadata-view p-3",children:[m.jsx("p",{children:"An error was encountered when trying to render metadata."}),m.jsx("p",{children:m.jsxs("pre",{style:{overflow:"scroll"},children:[(c=this.state.error)==null?void 0:c.message,m.jsx("br",{}),(f=this.state.error)==null?void 0:f.stack,m.jsx("br",{}),(r=this.state.errorInfo)==null?void 0:r.componentStack]})})]}):this.props.children}}const kv=l=>m.jsx(Kv,{children:m.jsx(Jv,{metadata:l.metadata})}),Jv=l=>{const u=l.metadata,c=se().has("show-metadata-other")?Object.entries(l.metadata).filter(([r])=>!fm.has(r)):[];if(u.ci||u.gitCommit||c.length>0)return m.jsxs("div",{className:"metadata-view",children:[u.ci&&!u.gitCommit&&m.jsx(Fv,{info:u.ci}),u.gitCommit&&m.jsx(Wv,{ci:u.ci,commit:u.gitCommit}),c.length>0&&m.jsxs(m.Fragment,{children:[(u.gitCommit||u.ci)&&m.jsx("div",{className:"metadata-separator"}),m.jsx("div",{className:"metadata-section metadata-properties",role:"list",children:c.map(([r,o])=>{const h=typeof o!="object"||o===null||o===void 0?String(o):JSON.stringify(o),v=h.length>1e3?h.slice(0,1e3)+"…":h;return m.jsx("div",{className:"copyable-property",role:"listitem",children:m.jsxs(Sr,{value:h,children:[m.jsx("span",{style:{fontWeight:"bold"},title:r,children:r}),": ",m.jsx("span",{title:v,children:Di(v)})]})},r)})})]})]})},Fv=({info:l})=>{const u=l.prTitle||`Commit ${l.commitHash}`,c=l.prHref||l.commitHref;return m.jsx("div",{className:"metadata-section",role:"list",children:m.jsx("div",{role:"listitem",children:m.jsx("a",{href:Ve(c),target:"_blank",rel:"noopener noreferrer",title:u,children:u})})})},Wv=({ci:l,commit:u})=>{const c=(l==null?void 0:l.prTitle)||u.subject,f=(l==null?void 0:l.prHref)||(l==null?void 0:l.commitHref),r=` <${u.author.email}>`,o=`${u.author.name}${r}`,h=Intl.DateTimeFormat(void 0,{dateStyle:"medium"}).format(u.committer.time),v=Intl.DateTimeFormat(void 0,{dateStyle:"full",timeStyle:"long"}).format(u.committer.time);return m.jsxs("div",{className:"metadata-section",role:"list",children:[m.jsxs("div",{role:"listitem",children:[f&&m.jsx("a",{href:Ve(f),target:"_blank",rel:"noopener noreferrer",title:c,children:c}),!f&&m.jsx("span",{title:c,children:c})]}),m.jsxs("div",{role:"listitem",className:"hbox",children:[m.jsx("span",{className:"mr-1",children:o}),m.jsxs("span",{title:v,children:[" on ",h]})]})]})},fm=new Set(["ci","gitCommit","gitDiff","actualWorkers"]),_v=l=>{const u=Object.entries(l).filter(([c])=>!fm.has(c));return!l.ci&&!l.gitCommit&&!u.length},Pv=({files:l,expandedFiles:u,setExpandedFiles:c,projectNames:f})=>{const r=ct.useMemo(()=>{const o=[];let h=0;for(const v of l)h+=v.tests.length,o.push({file:v,defaultExpanded:h<200});return o},[l]);return m.jsx(m.Fragment,{children:r.length>0?r.map(({file:o,defaultExpanded:h})=>m.jsx(sm,{file:o,projectNames:f,isFileExpanded:v=>{const y=u.get(v);return y===void 0?h:!!y},setFileExpanded:(v,y)=>{const A=new Map(u);A.set(v,y),c(A)}},`file-${o.fileId}`)):m.jsx("div",{className:"chip-header test-file-no-files",children:"No tests found"})})},Y2=({report:l,filteredStats:u,metadataVisible:c,toggleMetadataVisible:f})=>{if(!l)return null;const r=l.projectNames.length===1&&!!l.projectNames[0],o=!r&&!u,h=!_v(l.metadata)&&m.jsxs("div",{className:Ze("metadata-toggle",!o&&"metadata-toggle-second-line"),role:"button",onClick:f,title:c?"Hide metadata":"Show metadata",children:[c?Ni():Cl(),"Metadata"]}),v=m.jsxs("div",{className:"test-file-header-info",children:[r&&m.jsxs("div",{"data-testid":"project-name",children:["Project: ",l.projectNames[0]]}),u&&m.jsxs("div",{"data-testid":"filtered-tests-count",children:["Filtered: ",u.total," ",!!u.total&&"("+Ol(u.duration)+")"]}),o&&h]}),y=m.jsxs(m.Fragment,{children:[m.jsx("div",{"data-testid":"overall-time",style:{marginRight:"10px"},children:l?new Date(l.startTime).toLocaleString():""}),m.jsxs("div",{"data-testid":"overall-duration",children:["Total time: ",Ol(l.duration??0)]})]});return m.jsxs(m.Fragment,{children:[m.jsx(Or,{title:l.options.title,leftSuperHeader:v,rightSuperHeader:y}),!o&&h,c&&m.jsx(kv,{metadata:l.metadata}),!!l.errors.length&&m.jsx(ke,{header:"Errors",dataTestId:"report-errors",children:l.errors.map((A,E)=>m.jsx(wr,{code:A},"test-report-error-message-"+E))})]})},rm=l=>{const u=Math.round(l/1e3),c=Math.floor(u/60),f=u%60;return c===0?`${f}s`:`${c}m ${f}s`},$v=({entries:l})=>{const f=Math.max(...l.map(D=>D.label.length))*10,o={top:20,right:20,bottom:40,left:Math.min(800*.5,Math.max(50,f))},h=800-o.left-o.right,v=Math.min(...l.map(D=>D.startTime)),y=Math.max(...l.map(D=>D.startTime+D.duration));let A,E;const S=y-v;S<60*1e3?(A=10*1e3,E=!0):S<300*1e3?(A=30*1e3,E=!0):S<1800*1e3?(A=300*1e3,E=!1):(A=600*1e3,E=!1);const O=Math.ceil(v/A)*A,X=(D,N)=>{const K=new Date(D).toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",second:E?"2-digit":void 0});if(N)return K;if(K.endsWith(" AM")||K.endsWith(" PM"))return K.slice(0,-3)},b=(y-v)*1.1,p=Math.ceil(b/A)*A,x=h/p,R=20,U=8,Z=l.length*(R+U),F=[];for(let D=O;D<=v+p;D+=A){const N=D-v;F.push({x:N*x,label:X(D,D===O)})}const j=Z+o.top+o.bottom;return m.jsx("svg",{viewBox:`0 0 800 ${j}`,preserveAspectRatio:"xMidYMid meet",style:{width:"100%",height:"auto"},role:"img",children:m.jsxs("g",{transform:`translate(${o.left}, ${o.top})`,role:"presentation",children:[F.map(({x:D,label:N},K)=>m.jsxs("g",{"aria-hidden":"true",children:[m.jsx("line",{x1:D,y1:0,x2:D,y2:Z,stroke:"var(--color-border-muted)",strokeWidth:"1"}),m.jsx("text",{x:D,y:Z+20,textAnchor:"middle",dominantBaseline:"middle",fontSize:"12",fill:"var(--color-fg-muted)",children:N})]},K)),l.map((D,N)=>{const K=D.startTime-v,J=D.duration*x,k=K*x,nt=N*(R+U),P=["var(--color-scale-blue-2)","var(--color-scale-blue-3)","var(--color-scale-blue-4)"],st=P[N%P.length];return m.jsxs("g",{role:"listitem","aria-label":D.tooltip,children:[m.jsx("rect",{className:"gantt-bar",x:k,y:nt,width:J,height:R,fill:st,rx:"2",tabIndex:0,children:m.jsx("title",{children:D.tooltip})}),m.jsx("text",{x:k+J+6,y:nt+R/2,dominantBaseline:"middle",fontSize:"12",fill:"var(--color-fg-muted)","aria-hidden":"true",children:rm(D.duration)}),m.jsx("text",{x:-10,y:nt+R/2,textAnchor:"end",dominantBaseline:"middle",fontSize:"12",fill:"var(--color-fg-muted)","aria-hidden":"true",children:D.label})]},N)}),m.jsx("line",{x1:0,y1:0,x2:0,y2:Z,stroke:"var(--color-fg-muted)",strokeWidth:"1","aria-hidden":"true"}),m.jsx("line",{x1:0,y1:Z,x2:h,y2:Z,stroke:"var(--color-fg-muted)",strokeWidth:"1","aria-hidden":"true"})]})})};function ty({report:l,tests:u}){return m.jsxs(m.Fragment,{children:[m.jsx(ny,{report:l}),m.jsx(ey,{report:l,tests:u})]})}function ey({report:l,tests:u}){const[c,f]=ue.useState(50);return m.jsx(sm,{file:{fileId:"slowest",fileName:"Slowest Tests",tests:u.slice(0,c),stats:null},projectNames:l.json().projectNames,footer:cf(r=>r+50),children:[Ni(),"Show 50 more"]}):void 0})}function ny({report:l}){const u=l.json().machines;if(u.length===0)return null;const c=u.map(f=>{const r=f.tag.join(" "),o=new Date(f.startTime).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"});let h=`${r} started at ${o}, runs ${rm(f.duration)}`;return f.shardIndex&&(h+=` (shard ${f.shardIndex})`),{label:r,tooltip:h,startTime:f.startTime,duration:f.duration,shardIndex:f.shardIndex??1}}).sort((f,r)=>f.label.localeCompare(r.label)||f.shardIndex-r.shardIndex);return m.jsx(ke,{header:"Timeline",children:m.jsx($v,{entries:c})})}const ay=l=>!l.has("testId")&&!l.has("speedboard"),ly=l=>l.has("testId"),iy=l=>l.has("speedboard")&&!l.has("testId"),uy=({report:l})=>{var Z,F;const u=se(),[c,f]=ct.useState(new Map),[r,o]=ct.useState(u.get("q")||""),[h,v]=ct.useState(!1),y=u.has("speedboard"),[A]=_h("mergeFiles",!1),E=u.get("testId"),S=((Z=u.get("q"))==null?void 0:Z.toString())||"",O=S?"&q="+S:"",X=(F=l==null?void 0:l.json())==null?void 0:F.options.title,B=ct.useMemo(()=>{const j=new Map;for(const D of(l==null?void 0:l.json().files)||[])for(const N of D.tests)j.set(N.testId,D.fileId);return j},[l]),b=ct.useMemo(()=>rc.parse(r),[r]),p=ct.useMemo(()=>b.empty()?void 0:sy((l==null?void 0:l.json().files)||[],b),[l,b]),x=ct.useMemo(()=>y?oy(l,b):A?ry(l,b):fy(l,b),[l,b,A,y]),{prev:R,next:U}=ct.useMemo(()=>{const j=x.tests.findIndex(K=>K.testId===E),D=j>0?x.tests[j-1]:void 0,N=j{const j=D=>{if(D.target instanceof HTMLInputElement||D.target instanceof HTMLTextAreaElement||D.shiftKey||D.ctrlKey||D.metaKey||D.altKey)return;const N=new URLSearchParams(u);switch(D.key){case"a":D.preventDefault(),ca("#?");break;case"p":D.preventDefault(),N.delete("testId"),N.delete("speedboard"),ca(Na(N,"s:passed",!1));break;case"f":D.preventDefault(),N.delete("testId"),N.delete("speedboard"),ca(Na(N,"s:failed",!1));break;case"ArrowLeft":R&&(D.preventDefault(),N.delete("testId"),ca(Cn({test:R},N)+O));break;case"ArrowRight":U&&(D.preventDefault(),N.delete("testId"),ca(Cn({test:U},N)+O));break}};return document.addEventListener("keydown",j),()=>document.removeEventListener("keydown",j)},[R,U,O,S,u]),ct.useEffect(()=>{X?document.title=X:document.title="Playwright Test Report"},[X]),m.jsx("div",{className:"htmlreport vbox px-4 pb-4",children:m.jsxs("main",{children:[l&&m.jsx(Ev,{stats:l.json().stats,filterText:r,setFilterText:o}),m.jsxs(Kf,{predicate:ay,children:[m.jsx(Y2,{report:l==null?void 0:l.json(),filteredStats:p,metadataVisible:h,toggleMetadataVisible:()=>v(j=>!j)}),m.jsx(Pv,{files:x.files,expandedFiles:c,setExpandedFiles:f,projectNames:(l==null?void 0:l.json().projectNames)||[]})]}),m.jsxs(Kf,{predicate:iy,children:[m.jsx(Y2,{report:l==null?void 0:l.json(),filteredStats:p,metadataVisible:h,toggleMetadataVisible:()=>v(j=>!j)}),l&&m.jsx(ty,{report:l,tests:x.tests})]}),m.jsx(Kf,{predicate:ly,children:l&&m.jsx(cy,{report:l,next:U,prev:R,testId:E,testIdToFileIdMap:B})})]})})},cy=({report:l,testIdToFileIdMap:u,next:c,prev:f,testId:r})=>{const[o,h]=ct.useState("loading"),v=+(se().get("run")||"0");if(ct.useEffect(()=>{(async()=>{if(!r||typeof o=="object"&&r===o.testId)return;const S=u.get(r);if(!S){h("not-found");return}const O=await l.entry(`${S}.json`);h((O==null?void 0:O.tests.find(X=>X.testId===r))||"not-found")})()},[o,l,r,u]),o==="loading")return m.jsx("div",{className:"test-case-column"});if(o==="not-found")return m.jsxs("div",{className:"test-case-column",children:[m.jsx(Or,{title:"Test not found"}),m.jsxs("div",{className:"test-case-location",children:["Test ID: ",r]})]});const{projectNames:y,metadata:A,options:E}=l.json();return m.jsx("div",{className:"test-case-column",children:m.jsx(Vv,{projectNames:y,testRunMetadata:A,options:E,next:c,prev:f,test:o,run:v})})};function sy(l,u){const c={total:0,duration:0};for(const f of l){const r=f.tests.filter(o=>u.matches(o));c.total+=r.length;for(const o of r)c.duration+=o.duration}return c}function fy(l,u){const c={files:[],tests:[]};for(const f of(l==null?void 0:l.json().files)||[]){const r=f.tests.filter(o=>u.matches(o));r.length&&c.files.push({...f,tests:r}),c.tests.push(...r)}return c}function ry(l,u){const c=[],f=new Map;for(const o of(l==null?void 0:l.json().files)||[]){const h=o.tests.filter(v=>u.matches(v));for(const v of h){const y=v.path[0]??"";let A=f.get(y);A||(A={fileId:y,fileName:y,tests:[],stats:{total:0,expected:0,unexpected:0,flaky:0,skipped:0,ok:!0}},f.set(y,A),c.push(A));const E={...v,path:v.path.slice(1)};A.tests.push(E)}}c.sort((o,h)=>o.fileName.localeCompare(h.fileName));const r={files:c,tests:[]};for(const o of c)r.tests.push(...o.tests);return r}function oy(l,u){const f=((l==null?void 0:l.json().files)||[]).flatMap(r=>r.tests).filter(r=>u.matches(r));return f.sort((r,o)=>o.duration-r.duration),{files:[],tests:f}}const dy="data:image/svg+xml,%3csvg%20width='400'%20height='400'%20viewBox='0%200%20400%20400'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M136.444%20221.556C123.558%20225.213%20115.104%20231.625%20109.535%20238.032C114.869%20233.364%20122.014%20229.08%20131.652%20226.348C141.51%20223.554%20149.92%20223.574%20156.869%20224.915V219.481C150.941%20218.939%20144.145%20219.371%20136.444%20221.556ZM108.946%20175.876L61.0895%20188.484C61.0895%20188.484%2061.9617%20189.716%2063.5767%20191.36L104.153%20180.668C104.153%20180.668%20103.578%20188.077%2098.5847%20194.705C108.03%20187.559%20108.946%20175.876%20108.946%20175.876ZM149.005%20288.347C81.6582%20306.486%2046.0272%20228.438%2035.2396%20187.928C30.2556%20169.229%2028.0799%20155.067%2027.5%20145.928C27.4377%20144.979%2027.4665%20144.179%2027.5336%20143.446C24.04%20143.657%2022.3674%20145.473%2022.7077%20150.721C23.2876%20159.855%2025.4633%20174.016%2030.4473%20192.721C41.2301%20233.225%2076.8659%20311.273%20144.213%20293.134C158.872%20289.185%20169.885%20281.992%20178.152%20272.81C170.532%20279.692%20160.995%20285.112%20149.005%20288.347ZM161.661%20128.11V132.903H188.077C187.535%20131.206%20186.989%20129.677%20186.447%20128.11H161.661Z'%20fill='%232D4552'/%3e%3cpath%20d='M193.981%20167.584C205.861%20170.958%20212.144%20179.287%20215.465%20186.658L228.711%20190.42C228.711%20190.42%20226.904%20164.623%20203.57%20157.995C181.741%20151.793%20168.308%20170.124%20166.674%20172.496C173.024%20167.972%20182.297%20164.268%20193.981%20167.584ZM299.422%20186.777C277.573%20180.547%20264.145%20198.916%20262.535%20201.255C268.89%20196.736%20278.158%20193.031%20289.837%20196.362C301.698%20199.741%20307.976%20208.06%20311.307%20215.436L324.572%20219.212C324.572%20219.212%20322.736%20193.41%20299.422%20186.777ZM286.262%20254.795L176.072%20223.99C176.072%20223.99%20177.265%20230.038%20181.842%20237.869L274.617%20263.805C282.255%20259.386%20286.262%20254.795%20286.262%20254.795ZM209.867%20321.102C122.618%20297.71%20133.166%20186.543%20147.284%20133.865C153.097%20112.156%20159.073%2096.0203%20164.029%2085.204C161.072%2084.5953%20158.623%2086.1529%20156.203%2091.0746C150.941%20101.747%20144.212%20119.124%20137.7%20143.45C123.586%20196.127%20113.038%20307.29%20200.283%20330.682C241.406%20341.699%20273.442%20324.955%20297.323%20298.659C274.655%20319.19%20245.714%20330.701%20209.867%20321.102Z'%20fill='%232D4552'/%3e%3cpath%20d='M161.661%20262.296V239.863L99.3324%20257.537C99.3324%20257.537%20103.938%20230.777%20136.444%20221.556C146.302%20218.762%20154.713%20218.781%20161.661%20220.123V128.11H192.869C189.471%20117.61%20186.184%20109.526%20183.423%20103.909C178.856%2094.612%20174.174%20100.775%20163.545%20109.665C156.059%20115.919%20137.139%20129.261%20108.668%20136.933C80.1966%20144.61%2057.179%20142.574%2047.5752%20140.911C33.9601%20138.562%2026.8387%20135.572%2027.5049%20145.928C28.0847%20155.062%2030.2605%20169.224%2035.2445%20187.928C46.0272%20228.433%2081.663%20306.481%20149.01%20288.342C166.602%20283.602%20179.019%20274.233%20187.626%20262.291H161.661V262.296ZM61.0848%20188.484L108.946%20175.876C108.946%20175.876%20107.551%20194.288%2089.6087%20199.018C71.6614%20203.743%2061.0848%20188.484%2061.0848%20188.484Z'%20fill='%23E2574C'/%3e%3cpath%20d='M341.786%20129.174C329.345%20131.355%20299.498%20134.072%20262.612%20124.185C225.716%20114.304%20201.236%2097.0224%20191.537%2088.8994C177.788%2077.3834%20171.74%2069.3802%20165.788%2081.4857C160.526%2092.163%20153.797%20109.54%20147.284%20133.866C133.171%20186.543%20122.623%20297.706%20209.867%20321.098C297.093%20344.47%20343.53%20242.92%20357.644%20190.238C364.157%20165.917%20367.013%20147.5%20367.799%20135.625C368.695%20122.173%20359.455%20126.078%20341.786%20129.174ZM166.497%20172.756C166.497%20172.756%20180.246%20151.372%20203.565%20158C226.899%20164.628%20228.706%20190.425%20228.706%20190.425L166.497%20172.756ZM223.42%20268.713C182.403%20256.698%20176.077%20223.99%20176.077%20223.99L286.262%20254.796C286.262%20254.791%20264.021%20280.578%20223.42%20268.713ZM262.377%20201.495C262.377%20201.495%20276.107%20180.126%20299.422%20186.773C322.736%20193.411%20324.572%20219.208%20324.572%20219.208L262.377%20201.495Z'%20fill='%232EAD33'/%3e%3cpath%20d='M139.88%20246.04L99.3324%20257.532C99.3324%20257.532%20103.737%20232.44%20133.607%20222.496L110.647%20136.33L108.663%20136.933C80.1918%20144.611%2057.1742%20142.574%2047.5704%20140.911C33.9554%20138.563%2026.834%20135.572%2027.5001%20145.929C28.08%20155.063%2030.2557%20169.224%2035.2397%20187.929C46.0225%20228.433%2081.6583%20306.481%20149.005%20288.342L150.989%20287.719L139.88%20246.04ZM61.0848%20188.485L108.946%20175.876C108.946%20175.876%20107.551%20194.288%2089.6087%20199.018C71.6615%20203.743%2061.0848%20188.485%2061.0848%20188.485Z'%20fill='%23D65348'/%3e%3cpath%20d='M225.27%20269.163L223.415%20268.712C182.398%20256.698%20176.072%20223.99%20176.072%20223.99L232.89%20239.872L262.971%20124.281L262.607%20124.185C225.711%20114.304%20201.232%2097.0224%20191.532%2088.8994C177.783%2077.3834%20171.735%2069.3802%20165.783%2081.4857C160.526%2092.163%20153.797%20109.54%20147.284%20133.866C133.171%20186.543%20122.623%20297.706%20209.867%20321.097L211.655%20321.5L225.27%20269.163ZM166.497%20172.756C166.497%20172.756%20180.246%20151.372%20203.565%20158C226.899%20164.628%20228.706%20190.425%20228.706%20190.425L166.497%20172.756Z'%20fill='%231D8D22'/%3e%3cpath%20d='M141.946%20245.451L131.072%20248.537C133.641%20263.019%20138.169%20276.917%20145.276%20289.195C146.513%20288.922%20147.74%20288.687%20149%20288.342C152.302%20287.451%20155.364%20286.348%20158.312%20285.145C150.371%20273.361%20145.118%20259.789%20141.946%20245.451ZM137.7%20143.451C132.112%20164.307%20127.113%20194.326%20128.489%20224.436C130.952%20223.367%20133.554%20222.371%20136.444%20221.551L138.457%20221.101C136.003%20188.939%20141.308%20156.165%20147.284%20133.866C148.799%20128.225%20150.318%20122.978%20151.832%20118.085C149.393%20119.637%20146.767%20121.228%20143.776%20122.867C141.759%20129.093%20139.722%20135.898%20137.7%20143.451Z'%20fill='%23C04B41'/%3e%3c/svg%3e",Ff=N5,Rr=document.createElement("link");Rr.rel="shortcut icon";Rr.href=dy;document.head.appendChild(Rr);const hy=()=>{const[l,u]=ct.useState();return ct.useEffect(()=>{const c=new my;c.load().then(()=>{var f;(f=document.getElementById("playwrightReportBase64"))==null||f.remove(),u(c)})},[]),m.jsx(cv,{children:m.jsx(uy,{report:l})})};window.onload=()=>{gv(),X5.createRoot(document.querySelector("#root")).render(m.jsx(hy,{}))};class my{constructor(){yn(this,"_entries",new Map);yn(this,"_json")}async load(){const u=document.getElementById("playwrightReportBase64").textContent,c=new Ff.ZipReader(new Ff.Data64URIReader(u),{useWebWorkers:!1});for(const f of await c.getEntries())this._entries.set(f.filename,f);this._json=await this.entry("report.json")}json(){return this._json}async entry(u){const c=this._entries.get(u),f=new Ff.TextWriter;return await c.getData(f),JSON.parse(await f.getData())}} + + -
      +
      - + \ No newline at end of file diff --git a/test-results/.last-run.json b/test-results/.last-run.json index afaf5f8..6a46a1c 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,16 +1,6 @@ { "status": "failed", "failedTests": [ - "0cb34f497961675e9ddc-cde2b352ef5099a16784", - "0cb34f497961675e9ddc-c3a1ee9e683ae4cf3538", - "0cb34f497961675e9ddc-03039a97f495bd4ea385", - "0cb34f497961675e9ddc-64be8d34d83a9d2bae62", - "0cb34f497961675e9ddc-302118ee8ed4548b9fe7", - "0cb34f497961675e9ddc-050cabbf0b14eb4c16fb", - "0cb34f497961675e9ddc-476550736997b0672afe", - "0cb34f497961675e9ddc-94fce26cc5b7f1ba612e", - "0cb34f497961675e9ddc-7f458b77d6de5f123563", - "0cb34f497961675e9ddc-06ce8e24aee787cf2a7c", - "0cb34f497961675e9ddc-ac7c7ec0e89374865843" + "d318381359a9b96acb1e-232b9f04b4d5bfdc062b" ] -} +} \ No newline at end of file diff --git a/test-results/symbol-detail-Symbol-Detai-2a820-rview-tab-active-by-default-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-2a820-rview-tab-active-by-default-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-2a820-rview-tab-active-by-default-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-3159d-hanging-time-range-on-chart-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-3159d-hanging-time-range-on-chart-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-3159d-hanging-time-range-on-chart-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-51074--header-with-name-and-price-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-51074--header-with-name-and-price-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-51074--header-with-name-and-price-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-52f2a-sponsive-on-mobile-viewport-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-52f2a-sponsive-on-mobile-viewport-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-52f2a-sponsive-on-mobile-viewport-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-7130d-key-metrics-in-Overview-tab-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-7130d-key-metrics-in-Overview-tab-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-7130d-key-metrics-in-Overview-tab-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-7fd39-ld-switch-tabs-when-clicked-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-7fd39-ld-switch-tabs-when-clicked-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-7fd39-ld-switch-tabs-when-clicked-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-8229d-display-all-navigation-tabs-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-8229d-display-all-navigation-tabs-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-8229d-display-all-navigation-tabs-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-8cb09-e-with-correct-color-coding-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-8cb09-e-with-correct-color-coding-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-8cb09-e-with-correct-color-coding-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-98b83-how-tooltip-on-metric-hover-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-98b83-how-tooltip-on-metric-hover-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-98b83-how-tooltip-on-metric-hover-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-99b9b-ice-section-in-Overview-tab-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-99b9b-ice-section-in-Overview-tab-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-99b9b-ice-section-in-Overview-tab-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` diff --git a/test-results/symbol-detail-Symbol-Detai-b9e34-price-chart-in-Overview-tab-chromium/error-context.md b/test-results/symbol-detail-Symbol-Detai-b9e34-price-chart-in-Overview-tab-chromium/error-context.md deleted file mode 100644 index 95d5f3c..0000000 --- a/test-results/symbol-detail-Symbol-Detai-b9e34-price-chart-in-Overview-tab-chromium/error-context.md +++ /dev/null @@ -1,21 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e4]: - - heading "Error Loading Symbol" [level=2] [ref=e5] - - paragraph [ref=e6]: Failed to fetch symbol data - - button "Retry" [ref=e7] [cursor=pointer] - - generic [ref=e12] [cursor=pointer]: - - button "Open Next.js Dev Tools" [ref=e13]: - - img [ref=e14] - - generic [ref=e17]: - - button "Open issues overlay" [ref=e18]: - - generic [ref=e19]: - - generic [ref=e20]: "0" - - generic [ref=e21]: "1" - - generic [ref=e22]: Issue - - button "Collapse issues badge" [ref=e23]: - - img [ref=e24] - - alert [ref=e26] -``` From 3d74a08bf9bcf518534ade062a1533e910859661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Wed, 27 May 2026 14:54:52 +0300 Subject: [PATCH 03/46] chore: update .gitignore and .prettierignore for test artifacts - Added entries to .gitignore to exclude Playwright report, test results, and cache directories. - Created .prettierignore to prevent formatting of build artifacts and test-related files. - Removed outdated Playwright report index.html and last-run.json files to clean up the repository. --- .gitignore | 3 ++ .prettierignore | 14 ++++++ playwright-report/index.html | 85 ------------------------------------ test-results/.last-run.json | 6 --- 4 files changed, 17 insertions(+), 91 deletions(-) create mode 100644 .prettierignore delete mode 100644 playwright-report/index.html delete mode 100644 test-results/.last-run.json diff --git a/.gitignore b/.gitignore index 0c57ca6..8ae2a21 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,9 @@ pnpm-lock.yaml # Testing /coverage *.lcov +/playwright-report/ +/test-results/ +/playwright/.cache/ # Vercel .vercel diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..772ccd9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,14 @@ +# Build & dependencies +.next/ +out/ +node_modules/ +.vercel/ + +# Test & Playwright artifacts (generated locally / in CI) +coverage/ +playwright-report/ +test-results/ +.lighthouseci/ + +# Lockfiles Prettier doesn't need to touch +bun.lockb diff --git a/playwright-report/index.html b/playwright-report/index.html deleted file mode 100644 index 1bf2380..0000000 --- a/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
      - - - \ No newline at end of file diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index 6a46a1c..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "status": "failed", - "failedTests": [ - "d318381359a9b96acb1e-232b9f04b4d5bfdc062b" - ] -} \ No newline at end of file From a99d2a541a8362ed010bffcd93332bebcb43a02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Thu, 28 May 2026 09:13:27 +0300 Subject: [PATCH 04/46] feat: enhance PricingCard component with featured tier support - Added a new `featured` prop to the PricingCard component to highlight specific pricing tiers. - Updated styles and classes to visually distinguish featured tiers, including background and text color changes. - Refactored the PricingPage to separate featured and secondary tiers for improved layout and user experience. - Adjusted button labels and feature list styles based on the featured state for better clarity. --- components/PricingPage.tsx | 124 +++++++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 32 deletions(-) diff --git a/components/PricingPage.tsx b/components/PricingPage.tsx index 4737be8..6cf7ff1 100644 --- a/components/PricingPage.tsx +++ b/components/PricingPage.tsx @@ -31,6 +31,7 @@ interface PricingCardProps { isPopular?: boolean; onSelect: (tier: PricingTier) => void; isCurrentTier?: boolean; + featured?: boolean; } function PricingCard({ @@ -38,49 +39,84 @@ function PricingCard({ isPopular, onSelect, isCurrentTier, + featured, }: PricingCardProps) { const isFree = tier.price === 0; return (
      {isPopular && ( -
      - +
      + Most Popular
      )} {/* Header */} -
      -

      +
      +

      {tier.name}

      -

      +

      {tier.description}

      {/* Price */} -
      +
      {isFree ? ( - + Free ) : (
      - + €{tier.price} - + / mo
      @@ -91,13 +127,13 @@ function PricingCard({ {/* Feature list */} @@ -113,7 +149,13 @@ function PricingCard({ {tier.features.map((feature) => (
    • {CHECK_ICON} - + {feature}
    • @@ -135,6 +177,8 @@ export function PricingPage({ onSelectTier, }: PricingPageProps) { const [selectedTier, setSelectedTier] = useState(null); + const featuredTier = tiers.find((tier) => tier.tier === "ADS_FREE"); + const secondaryTiers = tiers.filter((tier) => tier.tier !== "ADS_FREE"); const handleSelect = (tier: PricingTier) => { setSelectedTier(tier); @@ -147,30 +191,46 @@ export function PricingPage({ aria-labelledby="pricing-heading" > {/* Header */} -
      +
      +

      + Pricing +

      Simple, transparent pricing

      -

      +

      Start free. Upgrade when you need AI features or an ad-free experience.

      {/* Tier cards */} -
      - {tiers.map((tier) => ( - - ))} +
      + {featuredTier && ( +
      + +
      + )} + +
      + {secondaryTiers.map((tier) => ( + + ))} +
      {/* Confirmation feedback */} From acc017685a0f2b39a4f43d611a4b1c0c38fc0947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Thu, 28 May 2026 09:46:13 +0300 Subject: [PATCH 05/46] feat: Shared panel primitives (Hallmark) --- components/AIPredictionPanel.tsx | 300 +++++++++++++++++++----------- components/InsightPanel.tsx | 78 ++++++++ components/StockOfTheDayPanel.tsx | 89 ++++----- 3 files changed, 312 insertions(+), 155 deletions(-) create mode 100644 components/InsightPanel.tsx diff --git a/components/AIPredictionPanel.tsx b/components/AIPredictionPanel.tsx index b62a525..c46b490 100644 --- a/components/AIPredictionPanel.tsx +++ b/components/AIPredictionPanel.tsx @@ -4,6 +4,11 @@ import Link from "next/link"; import type { AIPredictionReport, PricingTier } from "@/types"; import { AI_PREDICTION_SECTIONS } from "@/lib/ai-prediction"; import { ConfidenceInfoTooltip } from "@/components/ConfidenceInfoTooltip"; +import { + InsightPanel, + InsightPanelGate, + InsightPanelHeader, +} from "@/components/InsightPanel"; import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux"; import { isMissingByokApiKeyMessage } from "@/lib/missing-byok-api-key"; @@ -30,41 +35,97 @@ function RecommendationBadge({ return ( {recommendationValue.toUpperCase()} ); } -function FactorList({ +type FactorId = (typeof AI_PREDICTION_SECTIONS)[number]["id"]; + +const FACTOR_GROUPS: ReadonlyArray<{ + title: string; + sectionIds: FactorId[]; + tone?: "default" | "risk"; +}> = [ + { title: "Market setup", sectionIds: ["technical", "valuation"] }, + { + title: "Macro context", + sectionIds: ["sentiment", "macro", "globalMarkets"], + }, + { title: "Risk watch", sectionIds: ["risks"], tone: "risk" }, +]; + +function FactorGroup({ title, - items, - variant = "default", + sectionIds, + factors, + tone = "default", }: { title: string; - items: string[]; - variant?: "default" | "risk"; + sectionIds: FactorId[]; + factors: AIPredictionReport["factors"] | undefined; + tone?: "default" | "risk"; }) { - if (items.length === 0) return null; + const entries = sectionIds + .map((id) => ({ + section: AI_PREDICTION_SECTIONS.find((x) => x.id === id), + items: factors?.[id] ?? [], + })) + .filter((entry) => entry.section && entry.items.length > 0); + + if (entries.length === 0) return null; + + const shellClass = + tone === "risk" + ? "rounded-lg border border-amber-200/80 bg-amber-50/70 p-3 dark:border-amber-900/80 dark:bg-amber-950/30" + : "rounded-lg border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/30"; const headingClass = - variant === "risk" - ? "font-medium text-amber-800 dark:text-amber-200 mb-1" - : "font-medium text-gray-900 dark:text-gray-100 mb-1"; + tone === "risk" + ? "mb-2 text-sm font-semibold text-amber-900 dark:text-amber-200" + : "mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100"; return ( -
      +

      {title}

      -
        - {items.map((item, index) => ( -
      • - {item}
      • + +
        + {entries.map(({ section, items }) => ( +
        +

        + {section!.label} +

        +
          + {items.map((item, index) => ( +
        • + + {item} +
        • + ))} +
        +
        ))} -
      +
      ); } +function LockedGate({ pricingTier }: { pricingTier?: PricingTier | null }) { + return ( + + ); +} + export function AIPredictionPanel({ prediction, loading, @@ -74,109 +135,136 @@ export function AIPredictionPanel({ }: AIPredictionPanelProps) { const factors = prediction?.factors; const symbolSpecific = prediction?.symbolSpecific; + const showLockedOverlay = locked && Boolean(prediction); + const showLockedGateOnly = locked && !prediction && !loading; + const gateMessage = getAiSubscriptionGateMessage(pricingTier ?? undefined); return ( -
      +
      -
      -
      -

      - AI Prediction -

      - {prediction && ( -
      - - - - Confidence {Math.round(prediction.confidence * 100)}% - - - -
      - )} -
      + {showLockedGateOnly ? ( + + ) : ( + <> +
      + + + + + Confidence {Math.round(prediction.confidence * 100)}% + + + +
      + ) : undefined + } + /> - {loading && ( -

      - Generating AI prediction... -

      - )} - - {!loading && prediction && ( -
      -

      - {prediction.summary} -

      - - {AI_PREDICTION_SECTIONS.map((section) => ( - - ))} + {loading && ( +

      + Generating AI prediction... +

      + )} - {symbolSpecific && symbolSpecific.bullets.length > 0 && ( - + {!loading && prediction && ( +
      +
      + {prediction.summary} +
      + +
      + {FACTOR_GROUPS.map((group) => ( + + ))} +
      + + {symbolSpecific && symbolSpecific.bullets.length > 0 && ( +
      +

      + {symbolSpecific.title} +

      +
        + {symbolSpecific.bullets.map((item, index) => ( +
      • + + {item} +
      • + ))} +
      +
      + )} +
      )} -
      - )} - {!loading && !prediction && !locked && error && ( -
      -

      {error}

      - {isMissingByokApiKeyMessage(error) && ( -
      -

      - Add your API key on the Profile page under API keys, then - pick the same provider as your explanation model. -

      - - Open profile - + {!loading && !prediction && !locked && error && ( +
      +

      {error}

      + {isMissingByokApiKeyMessage(error) && ( +
      +

      + Add your API key on the Profile page under API keys, + then pick the same provider as your explanation model. +

      + + Open profile + +
      + )}
      )} -
      - )} - {!loading && !prediction && !locked && !error && ( -

      - No AI prediction returned yet. Try another symbol or refresh. -

      - )} -
      + {!loading && !prediction && !locked && !error && ( +

      + No AI prediction returned yet. Try another symbol or refresh. +

      + )} +
      - {locked && ( -
      -

      - {getAiSubscriptionGateMessage(pricingTier ?? undefined)} -

      - - Upgrade to unlock - -
      + {showLockedOverlay && ( +
      + +
      + )} + )}
      -
      + ); } diff --git a/components/InsightPanel.tsx b/components/InsightPanel.tsx new file mode 100644 index 0000000..2370704 --- /dev/null +++ b/components/InsightPanel.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; + +export function InsightPanel({ + children, + embedded = false, + className = "", +}: { + children: ReactNode; + embedded?: boolean; + className?: string; +}) { + const shell =
      {children}
      ; + if (embedded) return shell; + return
      {shell}
      ; +} + +export function InsightPanelHeader({ + title, + subtitle, + right, +}: { + title: ReactNode; + subtitle?: ReactNode; + right?: ReactNode; +}) { + return ( +
      +
      +

      {title}

      + {subtitle ? ( +

      {subtitle}

      + ) : null} +
      + {right ?
      {right}
      : null} +
      + ); +} + +export function InsightPanelGate({ + message, + ctaHref, + ctaLabel, + title, + overlay = false, + align = "start", + buttonClassName, +}: { + message: ReactNode; + ctaHref: string; + ctaLabel: string; + title?: ReactNode; + overlay?: boolean; + align?: "start" | "center"; + buttonClassName: string; +}) { + const alignmentClass = + align === "center" ? "items-center text-center" : "items-start"; + const shellClass = overlay + ? "absolute inset-0 rounded-xl px-6 py-8" + : "min-h-[11rem] py-2 sm:min-h-[12rem]"; + + return ( +
      + {title ?

      {title}

      : null} +

      {message}

      +
      + + {ctaLabel} + +
      +
      + ); +} diff --git a/components/StockOfTheDayPanel.tsx b/components/StockOfTheDayPanel.tsx index 2c53d94..0243bc4 100644 --- a/components/StockOfTheDayPanel.tsx +++ b/components/StockOfTheDayPanel.tsx @@ -4,6 +4,11 @@ import Link from "next/link"; import type { PricingTier, StockOfTheDay, StockOfTheDayResult } from "@/types"; import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux"; import { ConfidenceInfoTooltip } from "@/components/ConfidenceInfoTooltip"; +import { + InsightPanel, + InsightPanelGate, + InsightPanelHeader, +} from "@/components/InsightPanel"; import { HOME_INSTRUMENT_PANEL, HOME_PANEL_TITLE, @@ -23,28 +28,6 @@ interface StockOfTheDayPanelProps { showTitle?: boolean; } -function LockedGate({ - pricingTier, - compactTitle, -}: { - pricingTier?: PricingTier | null; - compactTitle?: boolean; -}) { - return ( -
      - {compactTitle &&

      Daily AI stock ideas

      } -

      - {getAiSubscriptionGateMessage(pricingTier ?? undefined)} -

      -
      - - View AI plans - -
      -
      - ); -} - function StanceLabel({ recommendation, }: { @@ -126,13 +109,20 @@ export function StockOfTheDayPanel({ }: StockOfTheDayPanelProps) { const showLockedOverlay = locked && Boolean(item); const showLockedGateOnly = locked && !item && !loading; + const gateMessage = getAiSubscriptionGateMessage(pricingTier ?? undefined); const shell = (
      {showLockedGateOnly ? ( - + ) : ( <>
      {(showTitle || item) && ( -
      - {showTitle && ( -
      -

      Daily AI stock ideas

      -

      - One buy and one sell candidate from your configured model. -

      -
      - )} - {item && ( -

      - Generated {new Date(item.generatedAt).toLocaleDateString()} -

      - )} +
      + + Generated{" "} + {new Date(item.generatedAt).toLocaleDateString()} +

      + ) : undefined + } + />
      )} @@ -210,13 +203,15 @@ export function StockOfTheDayPanel({
      {showLockedOverlay && ( -
      -

      - {getAiSubscriptionGateMessage(pricingTier ?? undefined)} -

      - - View AI plans - +
      +
      )} @@ -224,9 +219,5 @@ export function StockOfTheDayPanel({
      ); - if (embedded) { - return shell; - } - - return
      {shell}
      ; + return {shell}; } From c8f4f825b807ebb1b95f607c4e87bd6b36b3a5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Thu, 28 May 2026 09:55:39 +0300 Subject: [PATCH 06/46] feat: polish Navigation (Hallmark) --- components/Navigation.tsx | 46 ++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/components/Navigation.tsx b/components/Navigation.tsx index 91067ff..29238cc 100644 --- a/components/Navigation.tsx +++ b/components/Navigation.tsx @@ -34,32 +34,42 @@ export function Navigation({ return (

      -
      - {metrics.map((metric) => ( - + {metrics.map((metric, index) => ( + ))} -
      +
); } -interface MetricCardProps { +function MetricStripItem({ + metric, + isDark, + isLast, +}: { metric: Metric; isDark: boolean; -} - -function MetricCard({ metric, isDark }: MetricCardProps) { + isLast: boolean; +}) { const [showTooltip, setShowTooltip] = useState(false); return ( -
setShowTooltip(true)} - onMouseLeave={() => setShowTooltip(false)} +
  • -
    -
    -
    +
    setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > +
    + {metric.label} -
    -
    +
    + ? +
    - -
    - - {/* Tooltip */} - {showTooltip && ( -
    -
    - {/* Arrow */} -
    + {metric.value} + + {showTooltip && ( +
    {metric.tooltip}
    -
    - )} -
    + )} +
    +
  • ); } diff --git a/components/OverviewTab.tsx b/components/OverviewTab.tsx index 306c13f..ab37260 100644 --- a/components/OverviewTab.tsx +++ b/components/OverviewTab.tsx @@ -11,6 +11,11 @@ import { SymbolData, PriceData, TimeRange } from "@/types"; import { ChartComponent } from "@/components/ChartComponent"; import { KeyMetrics } from "@/components/KeyMetrics"; import { useTheme } from "@/lib/theme-context"; +import { + SYMBOL_INSTRUMENT_PANEL, + SYMBOL_PANEL_TITLE, + SYMBOL_SECTION_LABEL, +} from "@/lib/symbol-ui"; export interface OverviewTabProps { symbolData: SymbolData; @@ -40,27 +45,11 @@ export function OverviewTab({
    {/* Current Price Card */}
    -

    - Live pricing -

    -

    - Current Price -

    +

    Live pricing

    +

    Current Price

    {/* Price Chart */} -
    -

    - Trend -

    -

    - Price Chart -

    +
    +

    Trend

    +

    Price Chart

    = 2) return "text-white"; - return isDark ? "text-gray-200" : "text-gray-800"; + return isDark ? "text-stone-200" : "text-stone-800"; } export function SeasonalHeatmap({ data }: SeasonalHeatmapProps) { @@ -71,78 +70,55 @@ export function SeasonalHeatmap({ data }: SeasonalHeatmapProps) { const { monthlyReturns, averageByMonth, years } = aggregateSeasonalData(data); const months = Array.from({ length: 12 }, (_, i) => i + 1); - // Loading / empty state if (!data) { return ( -
    -

    - Seasonal Patterns -

    -
    - {[1, 2, 3].map((i) => ( -
    - ))} -
    -
    + + ); } if (years.length === 0) { return ( -
    -

    - Seasonal Patterns -

    -

    +

    No seasonal data available.

    -
    + ); } return ( -
    -

    - Seasonal Patterns -

    - - {/* Heatmap grid */} -
    +
    {months.map((m) => ( @@ -153,8 +129,8 @@ export function SeasonalHeatmap({ data }: SeasonalHeatmapProps) { {years.map((year) => ( +
    Year {getMonthLabel(m)}
    {year} @@ -176,22 +152,23 @@ export function SeasonalHeatmap({ data }: SeasonalHeatmapProps) { onMouseLeave={() => setHovered(null)} >
    {value !== undefined ? `${value.toFixed(1)}%` : "—"} - {/* Tooltip on hover */} {isHovered && value !== undefined && (
    ))} - {/* Average row */} -
    Avg @@ -244,7 +218,7 @@ export function SeasonalHeatmap({ data }: SeasonalHeatmapProps) { return (
    - {/* Legend */} -
    - - Legend: - +
    + Legend:
    - - Strong positive - + Strong positive
    - - Mild positive - + Mild positive
    - - Mild negative - + Mild negative
    - - Strong negative - + Strong negative
    - {/* Disclaimer - Requirement 7.3 */} -

    +

    Past seasonality does not guarantee future performance

    -
    + ); } diff --git a/components/SymbolTabShell.tsx b/components/SymbolTabShell.tsx new file mode 100644 index 0000000..c12477e --- /dev/null +++ b/components/SymbolTabShell.tsx @@ -0,0 +1,55 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + SYMBOL_INSTRUMENT_PANEL, + SYMBOL_PANEL_TITLE, + SYMBOL_SECTION_LABEL, + SYMBOL_SKELETON, +} from "@/lib/symbol-ui"; + +export interface SymbolTabShellProps { + eyebrow: string; + title: string; + ariaLabel: string; + children: ReactNode; + className?: string; +} + +export function SymbolTabShell({ + eyebrow, + title, + ariaLabel, + children, + className = "", +}: SymbolTabShellProps) { + return ( +
    +
    +

    {eyebrow}

    +

    {title}

    +
    + {children} +
    + ); +} + +export function SymbolTabSkeleton({ + blocks, + blockClassName = "h-24", +}: { + blocks: number; + blockClassName?: string; +}) { + return ( +
    + {Array.from({ length: blocks }, (_, i) => ( +
    + ))} +
    + ); +} diff --git a/components/TechnicalIndicatorsDisplay.tsx b/components/TechnicalIndicatorsDisplay.tsx index e23bd6d..f194bc4 100644 --- a/components/TechnicalIndicatorsDisplay.tsx +++ b/components/TechnicalIndicatorsDisplay.tsx @@ -2,8 +2,7 @@ /** * TechnicalIndicatorsDisplay Component - * Displays technical indicators with tooltips, color coding, and overall sentiment gauge. - * Uses "overpriced", "underpriced", "fairly priced" language — never "Buy" or "Sell". + * Signal readout layout: sentiment strip + vertical indicator ledger. * * Requirements: 5.1, 5.3, 5.4, 5.5, 5.6 */ @@ -11,6 +10,14 @@ import { TechnicalIndicators } from "@/types"; import { useTheme } from "@/lib/theme-context"; import { useState } from "react"; +import { SymbolTabShell, SymbolTabSkeleton } from "@/components/SymbolTabShell"; +import { + SYMBOL_DIVIDER, + SYMBOL_HELP_BUTTON, + SYMBOL_MUTED_TEXT, + SYMBOL_PANEL_TITLE, + SYMBOL_TOOLTIP_SURFACE, +} from "@/lib/symbol-ui"; export interface TechnicalIndicatorsDisplayProps { indicators: TechnicalIndicators | null | undefined; @@ -18,8 +25,9 @@ export interface TechnicalIndicatorsDisplayProps { type Signal = "overpriced" | "underpriced" | "fair"; -interface IndicatorCardData { +interface IndicatorRowData { name: string; + shortName: string; tooltip: string; signal: Signal; values: { label: string; value: string }[]; @@ -37,42 +45,42 @@ const SENTIMENT_LABELS: Record = { fair: "Overall: Appears Fairly Priced", }; -function getSignalColors(signal: Signal, isDark: boolean) { +function getSignalAccent(signal: Signal, isDark: boolean) { switch (signal) { case "overpriced": return { + border: isDark ? "border-red-500/70" : "border-red-500", badge: isDark - ? "bg-red-900/60 text-red-300" - : "bg-red-100 text-red-700", - border: isDark ? "border-red-700" : "border-red-300", - dot: "bg-red-500", + ? "bg-red-950/50 text-red-300" + : "bg-red-50 text-red-700", + text: isDark ? "text-red-300" : "text-red-700", }; case "underpriced": return { + border: isDark ? "border-green-500/70" : "border-green-600", badge: isDark - ? "bg-green-900/60 text-green-300" - : "bg-green-100 text-green-700", - border: isDark ? "border-green-700" : "border-green-300", - dot: "bg-green-500", + ? "bg-green-950/50 text-green-300" + : "bg-green-50 text-green-700", + text: isDark ? "text-green-300" : "text-green-700", }; - case "fair": default: return { + border: isDark ? "border-stone-500" : "border-stone-400", badge: isDark - ? "bg-gray-700 text-gray-300" - : "bg-gray-100 text-gray-600", - border: isDark ? "border-gray-600" : "border-gray-300", - dot: "bg-gray-400", + ? "bg-stone-800 text-stone-300" + : "bg-stone-100 text-stone-700", + text: isDark ? "text-stone-300" : "text-stone-700", }; } } -function buildIndicatorCards( +function buildIndicatorRows( indicators: TechnicalIndicators -): IndicatorCardData[] { +): IndicatorRowData[] { return [ { name: "RSI (Relative Strength Index)", + shortName: "RSI", tooltip: "RSI measures the speed and magnitude of recent price changes on a scale of 0 to 100. Values above 70 may suggest the asset is overpriced, while values below 30 may suggest it is underpriced.", signal: indicators.rsi.signal, @@ -80,6 +88,7 @@ function buildIndicatorCards( }, { name: "MACD", + shortName: "MACD", tooltip: "Moving Average Convergence Divergence tracks the relationship between two moving averages of price. A positive histogram may suggest upward momentum, while a negative histogram may suggest downward momentum.", signal: indicators.macd.trend, @@ -91,6 +100,7 @@ function buildIndicatorCards( }, { name: "Moving Averages", + shortName: "Moving averages", tooltip: "Moving averages smooth out price data over a period. When the 50-day average is above the 200-day average, it may suggest an upward trend. When below, it may suggest a downward trend.", signal: indicators.movingAverages.signal, @@ -101,6 +111,7 @@ function buildIndicatorCards( }, { name: "Bollinger Bands", + shortName: "Bollinger", tooltip: "Bollinger Bands consist of a middle band (moving average) with upper and lower bands based on standard deviation. Prices near the upper band may indicate the asset is overpriced, while prices near the lower band may indicate it is underpriced.", signal: indicators.bollingerBands.signal, @@ -121,108 +132,75 @@ export function TechnicalIndicatorsDisplay({ if (!indicators) { return ( -
    -

    - Technical Indicators -

    -
    - {[1, 2, 3, 4].map((i) => ( -
    - ))} -
    -
    + + ); } - const cards = buildIndicatorCards(indicators); - const sentimentColors = getSignalColors(indicators.overallSentiment, isDark); + const rows = buildIndicatorRows(indicators); + const sentiment = getSignalAccent(indicators.overallSentiment, isDark); return ( -
    -

    - Technical Indicators -

    - - {/* Overall Sentiment Gauge */}
    -
    -
    - - {SENTIMENT_LABELS[indicators.overallSentiment]} - - - {SIGNAL_LABELS[indicators.overallSentiment]} - -
    +

    + {SENTIMENT_LABELS[indicators.overallSentiment]} +

    + + {SIGNAL_LABELS[indicators.overallSentiment]} +
    - {/* Indicator Cards */} -
    - {cards.map((card) => ( - +
      + {rows.map((row) => ( + ))} -
    -
    + + ); } -interface IndicatorCardProps { - card: IndicatorCardData; +function IndicatorRow({ + row, + isDark, +}: { + row: IndicatorRowData; isDark: boolean; -} - -function IndicatorCard({ card, isDark }: IndicatorCardProps) { +}) { const [showTooltip, setShowTooltip] = useState(false); - const colors = getSignalColors(card.signal, isDark); + const accent = getSignalAccent(row.signal, isDark); return ( -
    -
    +
    - {card.name} + {row.name} - - {/* Tooltip — positioned relative to the name row */} {showTooltip && (
    - {card.tooltip} + {row.tooltip}
    )}
    - - {SIGNAL_LABELS[card.signal]} - -
    - -
    - {card.values.map((v) => ( -
    - - {v.label} - - - {v.value} - -
    - ))} +
    + {row.values.map((v) => ( +
    +
    {v.label}
    +
    + {v.value} +
    +
    + ))} +
    -
    + + {SIGNAL_LABELS[row.signal]} + + ); } diff --git a/components/__tests__/OverviewTab.test.tsx b/components/__tests__/OverviewTab.test.tsx index 56611bb..0cfb07c 100644 --- a/components/__tests__/OverviewTab.test.tsx +++ b/components/__tests__/OverviewTab.test.tsx @@ -469,8 +469,7 @@ describe("OverviewTab", () => { const tooltipText = screen.getByText( /Market Capitalization is the total value/ ); - // The tooltip container is the parent of the text's parent (the "relative" div) - const tooltipContainer = tooltipText.parentElement?.parentElement; + const tooltipContainer = tooltipText.closest('[role="tooltip"]'); expect(tooltipContainer).toHaveClass("absolute"); expect(tooltipContainer).toHaveClass("z-10"); expect(tooltipContainer).toHaveClass("rounded-lg"); diff --git a/components/__tests__/SeasonalHeatmap.test.tsx b/components/__tests__/SeasonalHeatmap.test.tsx index 4899f7c..69d69a8 100644 --- a/components/__tests__/SeasonalHeatmap.test.tsx +++ b/components/__tests__/SeasonalHeatmap.test.tsx @@ -151,7 +151,7 @@ describe("SeasonalHeatmap", () => { it("should apply gray for zero return", () => { renderWithTheme(mockSeasonalData); const cell = screen.getByText("0.0%"); - expect(cell.closest("div")).toHaveClass("bg-gray-200"); + expect(cell.closest("div")).toHaveClass("bg-stone-200"); }); it("should render a legend with color descriptions", () => { @@ -223,7 +223,7 @@ describe("SeasonalHeatmap", () => { // The inner div should have ring-2 class when hovered const innerDiv = cell.closest("div.relative"); expect(innerDiv).toHaveClass("ring-2"); - expect(innerDiv).toHaveClass("ring-blue-400"); + expect(innerDiv).toHaveClass("ring-stone-500"); }); }); diff --git a/lib/symbol-ui.ts b/lib/symbol-ui.ts new file mode 100644 index 0000000..a93047c --- /dev/null +++ b/lib/symbol-ui.ts @@ -0,0 +1,25 @@ +/** Shared surfaces and type for symbol detail tabs. */ +export const SYMBOL_INSTRUMENT_PANEL = + "rounded-xl border border-stone-200/90 bg-white/90 p-4 sm:p-6 dark:border-stone-700 dark:bg-stone-800/60"; + +export const SYMBOL_SECTION_LABEL = + "text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400"; + +export const SYMBOL_PANEL_TITLE = + "text-lg font-semibold tracking-tight text-stone-900 dark:text-stone-100"; + +export const SYMBOL_DIVIDER = + "border-stone-200 dark:border-stone-700"; + +export const SYMBOL_MUTED_TEXT = "text-stone-600 dark:text-stone-300"; + +export const SYMBOL_SUBTLE_TEXT = "text-stone-500 dark:text-stone-400"; + +export const SYMBOL_SKELETON = + "animate-pulse rounded-lg bg-stone-200 dark:bg-stone-700"; + +export const SYMBOL_TOOLTIP_SURFACE = + "absolute z-10 w-64 rounded-lg border p-3 text-sm shadow-lg border-stone-200 bg-white text-stone-700 dark:border-stone-600 dark:bg-stone-900 dark:text-stone-200"; + +export const SYMBOL_HELP_BUTTON = + "flex h-5 w-5 flex-shrink-0 cursor-help items-center justify-center rounded-full text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-500 focus-visible:ring-offset-1 bg-stone-200 text-stone-600 dark:bg-stone-700 dark:text-stone-300"; From 25bb553a40a5acf49406fae7a5ca8da04fc6e2df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Sat, 30 May 2026 10:05:44 +0300 Subject: [PATCH 10/46] fix: formatting & unit tests --- components/FinancialsTable.tsx | 25 ++++++++-- components/SeasonalHeatmap.tsx | 5 +- components/TechnicalIndicatorsDisplay.tsx | 8 ++-- .../__tests__/ResponsiveDesign.test.tsx | 48 ++++++++----------- components/__tests__/TabNavigation.test.tsx | 9 ++-- lib/symbol-ui.ts | 3 +- 6 files changed, 53 insertions(+), 45 deletions(-) diff --git a/components/FinancialsTable.tsx b/components/FinancialsTable.tsx index df432ea..6aa2218 100644 --- a/components/FinancialsTable.tsx +++ b/components/FinancialsTable.tsx @@ -307,7 +307,10 @@ function SectionHeading({ ? {showSectionTooltip && ( -
    +
    {section.tooltip}
    )} @@ -351,7 +354,9 @@ function MetricCell({ return (
    -
    +
    {metric.label} {withTooltip && ( <> @@ -378,14 +383,22 @@ function MetricCell({ )}
    -
    +
    {metric.value}
    ); } -function MetricRow({ metric, isDark }: { metric: MetricItem; isDark: boolean }) { +function MetricRow({ + metric, + isDark, +}: { + metric: MetricItem; + isDark: boolean; +}) { const [showTooltip, setShowTooltip] = useState(false); const colors = getFavorabilityColors(metric.favorability, isDark); @@ -416,7 +429,9 @@ function MetricRow({ metric, isDark }: { metric: MetricItem; isDark: boolean })
    )}
    - + {metric.value}
    diff --git a/components/SeasonalHeatmap.tsx b/components/SeasonalHeatmap.tsx index c280557..fab184f 100644 --- a/components/SeasonalHeatmap.tsx +++ b/components/SeasonalHeatmap.tsx @@ -16,10 +16,7 @@ import { getMonthLabel, } from "@/lib/seasonal-utils"; import { SymbolTabShell, SymbolTabSkeleton } from "@/components/SymbolTabShell"; -import { - SYMBOL_DIVIDER, - SYMBOL_SUBTLE_TEXT, -} from "@/lib/symbol-ui"; +import { SYMBOL_DIVIDER, SYMBOL_SUBTLE_TEXT } from "@/lib/symbol-ui"; export interface SeasonalHeatmapProps { data: SeasonalData | null | undefined; diff --git a/components/TechnicalIndicatorsDisplay.tsx b/components/TechnicalIndicatorsDisplay.tsx index f194bc4..16aeb87 100644 --- a/components/TechnicalIndicatorsDisplay.tsx +++ b/components/TechnicalIndicatorsDisplay.tsx @@ -50,9 +50,7 @@ function getSignalAccent(signal: Signal, isDark: boolean) { case "overpriced": return { border: isDark ? "border-red-500/70" : "border-red-500", - badge: isDark - ? "bg-red-950/50 text-red-300" - : "bg-red-50 text-red-700", + badge: isDark ? "bg-red-950/50 text-red-300" : "bg-red-50 text-red-700", text: isDark ? "text-red-300" : "text-red-700", }; case "underpriced": @@ -155,7 +153,9 @@ export function TechnicalIndicatorsDisplay({ data-testid="sentiment-gauge" className={`mb-6 flex flex-col gap-3 border-l-4 py-3 pl-4 sm:flex-row sm:items-center sm:justify-between ${sentiment.border}`} > -

    +

    {SENTIMENT_LABELS[indicators.overallSentiment]}

    { expect(grid!.classList.contains("sm:grid-cols-2")).toBe(true); }); - it("should render KeyMetrics with 3-column grid at md breakpoint", () => { + it("should render KeyMetrics as a horizontal strip at lg breakpoint", () => { const { container } = render(); const grid = container.querySelector(".grid"); expect(grid).not.toBeNull(); - expect(grid!.classList.contains("md:grid-cols-3")).toBe(true); + expect(grid!.classList.contains("lg:flex")).toBe(true); }); - it("should render TabNavigation with wider spacing at sm+", () => { + it("should render TabNavigation as a segmented control", () => { const onTabChange = vi.fn(); const { container } = render( ); const nav = container.querySelector("nav"); expect(nav).not.toBeNull(); - expect(nav!.className).toContain("sm:space-x-8"); + expect(nav!.className).toContain("rounded-xl"); + expect(nav!.className).toContain("p-1"); }); it("should render SymbolHeader price aligned right at sm+", () => { @@ -225,31 +226,26 @@ describe("Responsive Design", () => { mockMatchMedia(1280); }); - it("should render KeyMetrics with 4-column grid at lg breakpoint", () => { + it("should render KeyMetrics strip items with desktop padding", () => { const { container } = render(); - const grid = container.querySelector(".grid"); - expect(grid).not.toBeNull(); - expect(grid!.classList.contains("lg:grid-cols-4")).toBe(true); - }); - - it("should render KeyMetrics with 5-column grid at xl breakpoint", () => { - const { container } = render(); - const grid = container.querySelector(".grid"); - expect(grid).not.toBeNull(); - expect(grid!.classList.contains("xl:grid-cols-5")).toBe(true); + const items = container.querySelectorAll("li"); + expect(items.length).toBe(5); + items.forEach((item) => { + expect(item.className).toContain("lg:px-5"); + }); }); - it("should render KeyMetrics with larger padding at lg+", () => { + it("should render KeyMetrics with instrument panel padding at sm+", () => { const { container } = render(); const wrapper = container.firstElementChild as HTMLElement; - expect(wrapper.className).toContain("lg:p-8"); + expect(wrapper.className).toContain("sm:p-6"); }); - it("should render KeyMetrics with larger gap at lg+", () => { + it("should render KeyMetrics with horizontal dividers at lg+", () => { const { container } = render(); const grid = container.querySelector(".grid"); expect(grid).not.toBeNull(); - expect(grid!.className).toContain("lg:gap-5"); + expect(grid!.className).toContain("lg:divide-x"); }); it("should render SymbolHeader heading at sm:text-3xl for desktop", () => { @@ -331,13 +327,12 @@ describe("Responsive Design", () => { expect(input.className).toContain("px-4"); }); - it("should render metric cards with adequate padding for touch", () => { + it("should render metric strip items with adequate padding for touch", () => { const { container } = render(); - // Metric cards have p-3 base padding (touch-friendly) - const cards = container.querySelectorAll(".rounded-lg.border"); - expect(cards.length).toBeGreaterThan(0); - cards.forEach((card) => { - expect(card.className).toContain("p-3"); + const items = container.querySelectorAll("li"); + expect(items.length).toBeGreaterThan(0); + items.forEach((item) => { + expect(item.className).toContain("py-3"); }); }); @@ -348,8 +343,7 @@ describe("Responsive Design", () => { ); const buttons = container.querySelectorAll("button"); buttons.forEach((button) => { - // py-3 base padding for touch targets - expect(button.className).toContain("py-3"); + expect(button.className).toContain("min-h-[44px]"); }); }); diff --git a/components/__tests__/TabNavigation.test.tsx b/components/__tests__/TabNavigation.test.tsx index aa9edee..4a8c5c2 100644 --- a/components/__tests__/TabNavigation.test.tsx +++ b/components/__tests__/TabNavigation.test.tsx @@ -28,7 +28,8 @@ describe("TabNavigation", () => { render(); const overviewTab = screen.getByText("Overview"); - expect(overviewTab).toHaveClass("border-blue-500"); + expect(overviewTab).toHaveClass("bg-stone-900"); + expect(overviewTab).toHaveClass("text-stone-100"); }); it("should call onTabChange when tab is clicked", () => { @@ -46,10 +47,12 @@ describe("TabNavigation", () => { render(); const technicalsTab = screen.getByText("Technicals"); - expect(technicalsTab).toHaveClass("border-blue-500"); + expect(technicalsTab).toHaveClass("bg-stone-900"); + expect(technicalsTab).toHaveClass("text-stone-100"); const overviewTab = screen.getByText("Overview"); - expect(overviewTab).toHaveClass("border-transparent"); + expect(overviewTab).toHaveClass("text-stone-600"); + expect(overviewTab).not.toHaveClass("bg-stone-900"); }); it("should set aria-selected on active tab", () => { diff --git a/lib/symbol-ui.ts b/lib/symbol-ui.ts index a93047c..3568168 100644 --- a/lib/symbol-ui.ts +++ b/lib/symbol-ui.ts @@ -8,8 +8,7 @@ export const SYMBOL_SECTION_LABEL = export const SYMBOL_PANEL_TITLE = "text-lg font-semibold tracking-tight text-stone-900 dark:text-stone-100"; -export const SYMBOL_DIVIDER = - "border-stone-200 dark:border-stone-700"; +export const SYMBOL_DIVIDER = "border-stone-200 dark:border-stone-700"; export const SYMBOL_MUTED_TEXT = "text-stone-600 dark:text-stone-300"; From 229b0645280e7f49ff73b3786d0eaf70126885c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Sat, 30 May 2026 11:15:10 +0300 Subject: [PATCH 11/46] fix: lighthouse --- app/(main)/layout.tsx | 16 +++++++++++++++- components/StockOfTheDayPanel.tsx | 1 - 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 8210e54..98a7015 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -1,6 +1,7 @@ "use client"; import dynamic from "next/dynamic"; +import { Suspense } from "react"; import { Navigation } from "@/components/Navigation"; const Footer = dynamic( @@ -8,6 +9,17 @@ const Footer = dynamic( { ssr: false } ); +function NavigationFallback() { + return ( + + ); +} + export default function MainLayout({ children, }: Readonly<{ @@ -15,7 +27,9 @@ export default function MainLayout({ }>) { return (
    - + }> + +
    {children} diff --git a/components/StockOfTheDayPanel.tsx b/components/StockOfTheDayPanel.tsx index 8a448fb..bcacbf2 100644 --- a/components/StockOfTheDayPanel.tsx +++ b/components/StockOfTheDayPanel.tsx @@ -11,7 +11,6 @@ import { } from "@/components/InsightPanel"; import { HOME_INSTRUMENT_PANEL, - HOME_PANEL_TITLE, HOME_PRIMARY_BUTTON, } from "@/lib/home-ui"; import { isMissingByokApiKeyMessage } from "@/lib/missing-byok-api-key"; From ca9b5406d6faed81d00fc30966db3c71a850831f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Sat, 30 May 2026 11:36:59 +0300 Subject: [PATCH 12/46] fix: formatting --- components/StockOfTheDayPanel.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/components/StockOfTheDayPanel.tsx b/components/StockOfTheDayPanel.tsx index bcacbf2..13160c7 100644 --- a/components/StockOfTheDayPanel.tsx +++ b/components/StockOfTheDayPanel.tsx @@ -9,10 +9,7 @@ import { InsightPanelGate, InsightPanelHeader, } from "@/components/InsightPanel"; -import { - HOME_INSTRUMENT_PANEL, - HOME_PRIMARY_BUTTON, -} from "@/lib/home-ui"; +import { HOME_INSTRUMENT_PANEL, HOME_PRIMARY_BUTTON } from "@/lib/home-ui"; import { isMissingByokApiKeyMessage } from "@/lib/missing-byok-api-key"; interface StockOfTheDayPanelProps { From a94d0e9e181911635d6b537ca8b919260e8b770b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Sat, 30 May 2026 12:38:51 +0300 Subject: [PATCH 13/46] fix: minor color alignments across entire application (Hallmark) --- app/(main)/home-page-client.tsx | 6 +- app/(main)/layout.tsx | 3 +- components/AIPredictionPanel.tsx | 57 ++++---- components/AssetScreener.tsx | 34 ++--- components/AuthPrompt.tsx | 59 ++++---- components/CalendarDateRangePicker.tsx | 41 +++--- components/CalendarHub.tsx | 20 +-- components/CalendarNavigation.tsx | 16 +-- components/ChartComponent.tsx | 9 +- components/CryptoHeatmap.tsx | 34 ++--- components/DividendCalendar.tsx | 6 +- components/ETFHeatmap.tsx | 34 ++--- components/EarningsCalendar.tsx | 6 +- components/EconomicCalendar.tsx | 13 +- components/HeatmapComponent.tsx | 44 ++---- components/HeatmapHub.tsx | 20 +-- components/HeatmapNavigation.tsx | 16 +-- components/IPOCalendar.tsx | 6 +- components/Layout.tsx | 3 +- components/ScreenerHub.tsx | 128 ++++++++++-------- components/SectorHub.tsx | 72 ++++------ components/StockHeatmap.tsx | 34 ++--- components/__tests__/AssetScreener.test.tsx | 2 +- components/__tests__/ChartComponent.test.tsx | 22 +-- components/__tests__/CryptoHeatmap.test.tsx | 4 +- components/__tests__/ETFHeatmap.test.tsx | 4 +- .../__tests__/HeatmapComponent.test.tsx | 4 +- .../__tests__/RetryFunctionality.test.tsx | 2 +- components/__tests__/ScreenerHub.test.tsx | 4 + components/__tests__/StockHeatmap.test.tsx | 4 +- lib/home-ui.ts | 51 +++++++ 31 files changed, 361 insertions(+), 397 deletions(-) diff --git a/app/(main)/home-page-client.tsx b/app/(main)/home-page-client.tsx index ce23b43..ec5b1ab 100644 --- a/app/(main)/home-page-client.tsx +++ b/app/(main)/home-page-client.tsx @@ -16,6 +16,7 @@ import { } from "@/types"; import { SymbolHeader } from "@/components/SymbolHeader"; import { TabNavigation } from "@/components/TabNavigation"; +import { HOME_PRIMARY_BUTTON } from "@/lib/home-ui"; import { LoadingSpinner } from "@/components/LoadingSpinner"; import { usePricingTier } from "@/lib/use-pricing-tier"; import { EXPLANATIONS_PROVIDER_CHANGED_EVENT } from "@/lib/explanation-provider"; @@ -388,10 +389,7 @@ export function HomePageClient() { Error Loading Symbol

    {error}

    -
    diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 98a7015..1929511 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -3,6 +3,7 @@ import dynamic from "next/dynamic"; import { Suspense } from "react"; import { Navigation } from "@/components/Navigation"; +import { HOME_PAGE_BACKGROUND } from "@/lib/home-ui"; const Footer = dynamic( () => import("@/components/Footer").then((m) => m.Footer), @@ -26,7 +27,7 @@ export default function MainLayout({ children: React.ReactNode; }>) { return ( -
    +
    }> diff --git a/components/AIPredictionPanel.tsx b/components/AIPredictionPanel.tsx index c46b490..3c6466f 100644 --- a/components/AIPredictionPanel.tsx +++ b/components/AIPredictionPanel.tsx @@ -11,6 +11,14 @@ import { } from "@/components/InsightPanel"; import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux"; import { isMissingByokApiKeyMessage } from "@/lib/missing-byok-api-key"; +import { + HOME_CALLOUT, + HOME_FACTOR_GROUP, + HOME_INSTRUMENT_PANEL, + HOME_MUTED_TEXT, + HOME_PRIMARY_BUTTON, + HOME_SUBTLE_TEXT, +} from "@/lib/home-ui"; interface AIPredictionPanelProps { prediction: AIPredictionReport | null; @@ -31,12 +39,10 @@ function RecommendationBadge({ ? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300" : recommendationValue === "sell" ? "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300" - : "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300"; + : "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"; return ( - + {recommendationValue.toUpperCase()} ); @@ -80,12 +86,12 @@ function FactorGroup({ const shellClass = tone === "risk" ? "rounded-lg border border-amber-200/80 bg-amber-50/70 p-3 dark:border-amber-900/80 dark:bg-amber-950/30" - : "rounded-lg border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/30"; + : HOME_FACTOR_GROUP; const headingClass = tone === "risk" ? "mb-2 text-sm font-semibold text-amber-900 dark:text-amber-200" - : "mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100"; + : `mb-2 text-sm font-semibold text-stone-900 dark:text-stone-100`; return (
    @@ -94,10 +100,12 @@ function FactorGroup({
    {entries.map(({ section, items }) => (
    -

    +

    {section!.label}

    -
    diff --git a/components/ProfileSettings.tsx b/components/ProfileSettings.tsx index 85e6723..b507374 100644 --- a/components/ProfileSettings.tsx +++ b/components/ProfileSettings.tsx @@ -3,6 +3,14 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import APIKeyManager from "@/components/APIKeyManager"; +import { + HOME_INSTRUMENT_PANEL, + HOME_MUTED_TEXT, + HOME_PANEL_TITLE, + HOME_PRIMARY_BUTTON, + HOME_SECONDARY_BUTTON, + HOME_SUBTLE_TEXT, +} from "@/lib/home-ui"; import { EXPLANATIONS_PROVIDER_STORAGE_KEY, getDefaultProviderForTier, @@ -31,7 +39,7 @@ function StatusNotice({ message }: { message: StatusMessage }) { ? "text-red-600 dark:text-red-400" : message.tone === "success" ? "text-green-700 dark:text-green-400" - : "text-gray-600 dark:text-gray-300" + : HOME_MUTED_TEXT }`} role="status" > @@ -260,26 +268,17 @@ export function ProfileSettings() { } if (loading) { - return ( -

    - Loading profile… -

    - ); + return

    Loading profile…

    ; } if (!user) { return ( -
    -

    - User Profile -

    -

    +

    +

    User Profile

    +

    Sign in to manage your subscription, AI providers, and API keys.

    - + Sign in
    @@ -289,31 +288,23 @@ export function ProfileSettings() { return (
    -

    - User Profile -

    -

    {user.email}

    +

    User Profile

    +

    {user.email}

    {user.name && ( -

    - {user.name} -

    +

    {user.name}

    )}
    -
    -

    - Subscription -

    +
    +

    Subscription

    -
    Current plan
    -
    - {tier} -
    +
    Current plan
    +
    {tier}
    -
    Active until
    -
    +
    Active until
    +
    {subscription.activeUntil ? new Date(subscription.activeUntil).toLocaleDateString() : hasPaidPlan @@ -329,10 +320,7 @@ export function ProfileSettings() {

    )}
    - + Change plan {hasPaidPlan && ( @@ -341,7 +329,7 @@ export function ProfileSettings() { type="button" onClick={() => void handleOpenBillingPortal()} disabled={openingBilling} - className="inline-flex items-center rounded-md border border-blue-500 px-3 py-1.5 text-sm text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 disabled:opacity-50" + className={`${HOME_SECONDARY_BUTTON} disabled:opacity-50`} > {openingBilling ? "Opening…" : "Manage billing"} @@ -363,12 +351,12 @@ export function ProfileSettings() { )}
    -
    +
    -

    +

    Explanations provider

    -

    +

    Choose which AI powers metric explanations and chart analysis, then click Save to apply your choice across the app.

    @@ -392,23 +380,25 @@ export function ProfileSettings() { onClick={() => handleSelectProvider(provider.id)} className={`rounded-lg border p-4 text-left transition-colors ${ isPending - ? "border-blue-500 bg-blue-50 dark:bg-blue-950/30 ring-1 ring-blue-500" - : "border-gray-200 dark:border-gray-700" + ? "border-stone-600 bg-stone-100 ring-1 ring-stone-600 dark:border-stone-400 dark:bg-stone-800 dark:ring-stone-400" + : "border-stone-200 dark:border-stone-700" } ${ !allowed - ? "opacity-50 cursor-not-allowed" - : "hover:border-blue-400" + ? "cursor-not-allowed opacity-50" + : "hover:border-stone-400 dark:hover:border-stone-500" }`} > -

    +

    {provider.name} {isActive && ( - + Active )}

    -

    +

    {provider.subtitle}

    @@ -421,12 +411,12 @@ export function ProfileSettings() { type="button" onClick={handleSaveExplanationProvider} disabled={!hasUnsavedProvider} - className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" + className={`${HOME_PRIMARY_BUTTON} disabled:cursor-not-allowed disabled:opacity-50`} > Save explanations provider {hasUnsavedProvider && ( -

    +

    You have unsaved changes.

    )} @@ -437,17 +427,15 @@ export function ProfileSettings() { )}
    -
    -

    - API keys -

    +
    +

    API keys

    {canManageApiKeys ? ( ) : ( -

    +

    API key management is available on the BYOK plan.

    )} diff --git a/components/ScreenerPresets.tsx b/components/ScreenerPresets.tsx index 715b657..5da254b 100644 --- a/components/ScreenerPresets.tsx +++ b/components/ScreenerPresets.tsx @@ -11,10 +11,13 @@ import { useState, useEffect, useCallback } from "react"; import type { ScreenerFilter, ScreenerPreset } from "@/types"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- +import { + HOME_INPUT_SM, + HOME_MUTED_TEXT, + HOME_PRIMARY_BUTTON, + HOME_SECONDARY_BUTTON, + HOME_SUBTLE_TEXT, +} from "@/lib/home-ui"; export interface ScreenerPresetsProps { currentFilters: ScreenerFilter[]; @@ -105,7 +108,7 @@ export function ScreenerPresets({ if (loading) { return (
    Loading presets… @@ -117,7 +120,7 @@ export function ScreenerPresets({
    {/* Preset row */}
    - + Presets:
    handlePresetClick(preset)} data-testid={`preset-${preset.id}`} - className={`relative shrink-0 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${ + className={`relative shrink-0 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-600 ${ selectedPresetId === preset.id - ? "border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300" - : "border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 dark:hover:bg-gray-700" + ? "border-stone-600 bg-stone-900 text-stone-50 dark:border-stone-400 dark:bg-stone-100 dark:text-stone-900" + : "border-stone-200 bg-white text-stone-700 hover:border-stone-400 hover:bg-stone-50 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:hover:border-stone-500 dark:hover:bg-stone-700" }`} aria-pressed={selectedPresetId === preset.id} title={preset.description} @@ -159,7 +162,7 @@ export function ScreenerPresets({ type="button" onClick={() => setShowSaveForm(true)} disabled={currentFilters.length === 0} - className="rounded border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors" + className={`${HOME_SECONDARY_BUTTON} px-3 py-1.5 text-xs disabled:opacity-50`} data-testid="save-preset-btn" > Save Current Filters @@ -172,7 +175,7 @@ export function ScreenerPresets({
    @@ -182,14 +185,14 @@ export function ScreenerPresets({ value={saveName} onChange={(e) => setSaveName(e.target.value)} placeholder="My preset" - className="rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + className={HOME_INPUT_SM} data-testid="preset-name-input" />
    @@ -199,7 +202,7 @@ export function ScreenerPresets({ value={saveDescription} onChange={(e) => setSaveDescription(e.target.value)} placeholder="Optional description" - className="rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + className={HOME_INPUT_SM} data-testid="preset-description-input" />
    @@ -207,7 +210,7 @@ export function ScreenerPresets({ type="button" onClick={handleSave} disabled={saving || !saveName.trim()} - className="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors" + className={`${HOME_PRIMARY_BUTTON} px-3 py-1.5 text-xs disabled:opacity-50`} data-testid="save-preset-confirm" > {saving ? "Saving…" : "Save"} @@ -219,7 +222,7 @@ export function ScreenerPresets({ setSaveName(""); setSaveDescription(""); }} - className="rounded border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors" + className={`${HOME_SECONDARY_BUTTON} px-3 py-1.5 text-xs`} data-testid="save-preset-cancel" > Cancel diff --git a/components/ScreenerTableView.tsx b/components/ScreenerTableView.tsx index bbe75b1..42f5898 100644 --- a/components/ScreenerTableView.tsx +++ b/components/ScreenerTableView.tsx @@ -9,6 +9,11 @@ import { useState, useMemo, useCallback, useEffect } from "react"; import type { ScreenerResult } from "@/types"; +import { + HOME_MUTED_TEXT, + HOME_SECONDARY_BUTTON, + HOME_SUBTLE_TEXT, +} from "@/lib/home-ui"; // --------------------------------------------------------------------------- // Types @@ -147,25 +152,27 @@ export function ScreenerTableView({ if (results.length === 0) { return ( -
    +
    No results
    ); } return ( -
    +
    - + {COLUMNS.map((col) => ( onSymbolClick?.(row.symbol)} data-testid={`row-${row.symbol}`} > - - - - - - -
    handleSort(col.field)} aria-sort={ sort.field === col.field @@ -199,17 +206,19 @@ export function ScreenerTableView({ return (
    + {row.symbol} + {row.name} + {formatPrice(row.price)} {formatChangePercent(row.changePercent)} + {formatVolume(row.volume)} + {formatMarketCap(row.marketCap)} + {row.peRatio != null ? row.peRatio.toFixed(1) : "—"} + {row.sector} @@ -242,7 +251,7 @@ export function ScreenerTableView({ ? "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300" : row.valuationContext === "underpriced" ? "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300" - : "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300" + : "bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-200" }`} > {row.valuationContext} @@ -257,23 +266,23 @@ export function ScreenerTableView({ {/* Pagination */} {sorted.length > PAGE_SIZE && ( -
    +
    - + Page {page + 1} of {totalPages} diff --git a/components/SectorHub.tsx b/components/SectorHub.tsx index 9ec5ad5..930e114 100644 --- a/components/SectorHub.tsx +++ b/components/SectorHub.tsx @@ -16,10 +16,8 @@ import { ErrorMessage } from "@/components/ErrorMessage"; import { HOME_CHIP, HOME_INSTRUMENT_PANEL, - HOME_MUTED_TEXT, HOME_PANEL_TITLE, HOME_SECTION_LABEL, - HOME_SUBTLE_TEXT, homeChipClasses, } from "@/lib/home-ui"; @@ -280,7 +278,7 @@ export function SectorHub({

    {sector.sector}

    @@ -295,7 +293,7 @@ export function SectorHub({ {/* Tooltip on hover */} {isHovered && SECTOR_DESCRIPTIONS[sector.sector] && (
    @@ -312,7 +310,7 @@ export function SectorHub({

    Sector Comparison

    @@ -334,12 +332,12 @@ export function SectorHub({ return (
    {sector.sector}
    void; @@ -68,8 +75,6 @@ export function TechnicalIndicatorOverlay({ const [indicators, setIndicators] = useState(initialIndicators); const [isExpanded, setIsExpanded] = useState(false); - const { resolvedTheme } = useTheme(); - const isDark = resolvedTheme === "dark"; // Toggle indicator visibility const toggleIndicator = useCallback( @@ -139,39 +144,27 @@ export function TechnicalIndicatorOverlay({ return (
    {/* Header */}
    setIsExpanded(!isExpanded)} >
    - + Technical Indicators {activeCount > 0 && ( {activeCount} active )}
    -
    +
    +
    {indicators.map((indicator, index) => (
    - {/* Toggle Checkbox */} -
    ))}
    - {/* Info Footer */} -
    +

    💡 Quick Guide:

    diff --git a/components/TrialBanner.tsx b/components/TrialBanner.tsx index df736ca..0722b88 100644 --- a/components/TrialBanner.tsx +++ b/components/TrialBanner.tsx @@ -16,6 +16,7 @@ import { postEmailOtpVerify, } from "@/lib/auth/trial-auth-navigation"; import { describeAuthQueryError } from "@/lib/auth/auth-query-messages"; +import { HOME_PRIMARY_BUTTON } from "@/lib/home-ui"; /** Re-sync countdown with server so tab background / clock skew cannot shorten the trial. */ const TRIAL_STATUS_SYNC_MS = 30_000; @@ -304,7 +305,7 @@ export function TrialBanner({ onAuthenticated }: TrialBannerProps) { diff --git a/components/__tests__/AxeAccessibilityAudit.test.tsx b/components/__tests__/AxeAccessibilityAudit.test.tsx index e8ad431..ebd6aa0 100644 --- a/components/__tests__/AxeAccessibilityAudit.test.tsx +++ b/components/__tests__/AxeAccessibilityAudit.test.tsx @@ -6,7 +6,7 @@ * Validates: Requirements 18.1, 18.2, 18.3, 18.4, 18.5 */ -import { describe, it, expect, vi, beforeAll } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render } from "@testing-library/react"; import { configureAxe } from "vitest-axe"; import * as matchers from "vitest-axe/matchers"; diff --git a/components/__tests__/ColorContrast.test.tsx b/components/__tests__/ColorContrast.test.tsx index 6001a5d..93761d1 100644 --- a/components/__tests__/ColorContrast.test.tsx +++ b/components/__tests__/ColorContrast.test.tsx @@ -84,15 +84,14 @@ describe("Color Contrast Compliance (Req 18.3)", () => { }); describe("LoadingSpinner", () => { - it("uses text-gray-300 (not text-gray-400) for message text in dark mode", () => { + it("uses readable stone text (not text-gray-400) for message text in dark mode", () => { const { container } = render( ); const messageEl = container.querySelector("p"); expect(messageEl).not.toBeNull(); const cls = messageEl!.className; - // Should use gray-300 in dark mode for sufficient contrast - expect(cls).toContain("dark:text-gray-300"); + expect(cls).toContain("dark:text-stone-200"); expect(cls).not.toContain("dark:text-gray-400"); }); }); diff --git a/components/__tests__/ResponsiveDesign.test.tsx b/components/__tests__/ResponsiveDesign.test.tsx index 13fa97f..6ecb71e 100644 --- a/components/__tests__/ResponsiveDesign.test.tsx +++ b/components/__tests__/ResponsiveDesign.test.tsx @@ -10,7 +10,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { SearchBar } from "../SearchBar"; import { TabNavigation } from "../TabNavigation"; import { SymbolHeader } from "../SymbolHeader"; diff --git a/components/__tests__/ScreenerPresets.test.tsx b/components/__tests__/ScreenerPresets.test.tsx index 7d53e35..b4a2180 100644 --- a/components/__tests__/ScreenerPresets.test.tsx +++ b/components/__tests__/ScreenerPresets.test.tsx @@ -7,13 +7,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - render, - screen, - fireEvent, - waitFor, - act, -} from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ScreenerPresets } from "../ScreenerPresets"; import type { ScreenerFilter, ScreenerPreset } from "@/types"; diff --git a/components/__tests__/SeasonalHeatmap.test.tsx b/components/__tests__/SeasonalHeatmap.test.tsx index 69d69a8..1d9bceb 100644 --- a/components/__tests__/SeasonalHeatmap.test.tsx +++ b/components/__tests__/SeasonalHeatmap.test.tsx @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, within } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { SeasonalHeatmap } from "../SeasonalHeatmap"; import { ThemeProvider } from "@/lib/theme-context"; import { SeasonalData } from "@/types"; diff --git a/components/__tests__/StockOfTheDayPanel.test.tsx b/components/__tests__/StockOfTheDayPanel.test.tsx index 1dea372..500ffeb 100644 --- a/components/__tests__/StockOfTheDayPanel.test.tsx +++ b/components/__tests__/StockOfTheDayPanel.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { StockOfTheDayPanel } from "../StockOfTheDayPanel"; diff --git a/e2e/chart.spec.ts b/e2e/chart.spec.ts index b4caefd..1ad6654 100644 --- a/e2e/chart.spec.ts +++ b/e2e/chart.spec.ts @@ -40,8 +40,8 @@ test.describe("Chart Component E2E Tests", () => { // Wait for chart to re-render await page.waitForTimeout(500); - // Check if Area button is now active (has blue background) - await expect(areaButton).toHaveClass(/bg-blue-600/); + // Check if Area button is now active (stone range chip) + await expect(areaButton).toHaveClass(/bg-stone-900|dark:bg-stone-100/); }); test("should switch chart type to Candlestick", async ({ page }) => { @@ -50,7 +50,7 @@ test.describe("Chart Component E2E Tests", () => { await page.waitForTimeout(500); - await expect(candlesButton).toHaveClass(/bg-blue-600/); + await expect(candlesButton).toHaveClass(/bg-stone-900|dark:bg-stone-100/); }); test("should switch time range to 1W", async ({ page }) => { @@ -59,7 +59,7 @@ test.describe("Chart Component E2E Tests", () => { await page.waitForTimeout(300); - await expect(oneWeekButton).toHaveClass(/bg-blue-600/); + await expect(oneWeekButton).toHaveClass(/bg-stone-900|dark:bg-stone-100/); }); test("should switch time range to 1Y", async ({ page }) => { @@ -68,7 +68,7 @@ test.describe("Chart Component E2E Tests", () => { await page.waitForTimeout(300); - await expect(oneYearButton).toHaveClass(/bg-blue-600/); + await expect(oneYearButton).toHaveClass(/bg-stone-900|dark:bg-stone-100/); }); test("should display technical indicators panel", async ({ page }) => { @@ -126,7 +126,7 @@ test.describe("Chart Component E2E Tests", () => { // Line should be active await expect(page.getByRole("button", { name: "Line" })).toHaveClass( - /bg-blue-600/ + /bg-stone-900|dark:bg-stone-100/ ); }); @@ -143,7 +143,7 @@ test.describe("Chart Component E2E Tests", () => { // 1Y should be active await expect(page.getByRole("button", { name: "1Y" })).toHaveClass( - /bg-blue-600/ + /bg-stone-900|dark:bg-stone-100/ ); }); @@ -182,10 +182,10 @@ test.describe("Chart Component E2E Tests", () => { // Both should remain active await expect(page.getByRole("button", { name: "Area" })).toHaveClass( - /bg-blue-600/ + /bg-stone-900|dark:bg-stone-100/ ); await expect(page.getByRole("button", { name: "1Y" })).toHaveClass( - /bg-blue-600/ + /bg-stone-900|dark:bg-stone-100/ ); }); }); diff --git a/lib/__tests__/exponential-backoff-pbt.test.ts b/lib/__tests__/exponential-backoff-pbt.test.ts index 49989fe..a7f7eff 100644 --- a/lib/__tests__/exponential-backoff-pbt.test.ts +++ b/lib/__tests__/exponential-backoff-pbt.test.ts @@ -18,7 +18,7 @@ vi.mock("@/lib/logger", () => ({ }, })); -import { retryWithBackoff, isRetryableError } from "@/lib/retry"; +import { retryWithBackoff } from "@/lib/retry"; // --------------------------------------------------------------------------- // Helpers diff --git a/lib/home-ui.ts b/lib/home-ui.ts index cb5b3d2..2714d38 100644 --- a/lib/home-ui.ts +++ b/lib/home-ui.ts @@ -57,6 +57,19 @@ export const HOME_FACTOR_GROUP = export const HOME_NAV_BAR = "border-b border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-950"; +/** Calendar “today” highlights — stone accent, not blue. */ +export const CALENDAR_TODAY_HEADER = + "border-b border-stone-400 bg-stone-200 text-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-100"; + +export const CALENDAR_TODAY_BADGE = + "bg-stone-300 text-stone-900 dark:bg-stone-600 dark:text-stone-100"; + +export const CALENDAR_TODAY_CELL = + "bg-stone-100 hover:bg-stone-200 dark:bg-stone-800/80 dark:hover:bg-stone-700/80"; + +export const CALENDAR_DAY_HEADER = + "border-b border-stone-200 bg-stone-100 text-stone-900 dark:border-stone-700 dark:bg-stone-800 dark:text-stone-100"; + export function homeChipClasses(active: boolean): string { return active ? HOME_RANGE_BUTTON_ACTIVE : HOME_RANGE_BUTTON_IDLE; } From 7dd96d74dbd01dfe30210c95a6730fbcd600302a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Mon, 1 Jun 2026 07:46:42 +0200 Subject: [PATCH 17/46] fix: failing Lighthouse --- components/APIKeyManager.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/APIKeyManager.tsx b/components/APIKeyManager.tsx index 9226aa3..ee6810a 100644 --- a/components/APIKeyManager.tsx +++ b/components/APIKeyManager.tsx @@ -19,6 +19,7 @@ import { HOME_PRIMARY_BUTTON, HOME_RANGE_BUTTON_ACTIVE, HOME_RANGE_BUTTON_IDLE, + HOME_SUBTLE_TEXT, } from "@/lib/home-ui"; const PROVIDERS: Array<{ From 6314b9251397c820495a9a31ef12368bc5cb6591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Mon, 1 Jun 2026 10:12:18 +0200 Subject: [PATCH 18/46] fix: all Hallmark last fixes --- .eslintrc.json | 10 ++- app/__tests__/page.test.tsx | 16 ++-- app/api/market/__tests__/symbol.test.ts | 74 ++++++++++--------- app/api/market/search/__tests__/route.test.ts | 6 +- app/api/market/world-markets/route.ts | 2 +- app/api/screener/__tests__/route.test.ts | 46 ++++++------ app/auth/callback/google/page.tsx | 2 +- app/layout.tsx | 22 +++--- components/AIPredictionPanel.tsx | 4 +- components/AdBanner.tsx | 2 +- components/AssetScreener.tsx | 42 ++++++++--- components/AuthPrompt.tsx | 3 +- components/ChartComponent.tsx | 2 - components/ChartWithIndicators.tsx | 7 -- components/DividendCalendar.tsx | 56 +++++--------- components/EarningsCalendar.tsx | 43 ++++------- components/EconomicCalendar.tsx | 57 +++++--------- components/ErrorBoundary.tsx | 4 +- components/ErrorMessage.tsx | 4 +- components/FinancialsTable.tsx | 4 +- components/ForecastDisplay.tsx | 2 +- components/HeatmapComponent.tsx | 37 ++++------ components/IPOCalendar.tsx | 37 ++++------ components/MatrixHeatmap.tsx | 62 ++++++---------- components/PricingPage.tsx | 27 ++++--- components/ProfileSettings.tsx | 2 +- components/ScreenerHeatmapView.tsx | 3 +- components/SearchBar.tsx | 2 +- components/Tooltip.tsx | 6 +- components/TrialTimer.tsx | 12 ++- components/__tests__/ChartComponent.test.tsx | 5 +- components/__tests__/ColorContrast.test.tsx | 8 +- .../__tests__/DividendCalendar.test.tsx | 6 +- .../__tests__/EarningsCalendar.test.tsx | 8 +- .../__tests__/EconomicCalendar.test.tsx | 8 +- components/__tests__/ErrorBoundary.test.tsx | 2 +- components/__tests__/FearGreedGauge.test.tsx | 8 +- components/__tests__/FinancialsTable.test.tsx | 2 +- components/__tests__/IPOCalendar.test.tsx | 6 +- components/__tests__/MatrixHeatmap.test.tsx | 8 +- components/__tests__/OverviewTab.test.tsx | 9 +-- components/__tests__/SearchBar.test.tsx | 32 ++++---- components/__tests__/SeasonalHeatmap.test.tsx | 2 +- components/__tests__/SectorHub.test.tsx | 6 +- components/__tests__/WorldMarkets.test.tsx | 8 +- lib/cache.ts | 2 +- lib/home-ui.ts | 55 ++++++++++++++ .../__tests__/api-key-validation-pbt.test.ts | 20 ++--- .../__tests__/ollama-verification-pbt.test.ts | 12 +-- services/__tests__/screener.service.test.ts | 6 +- services/yahoo-finance.service.ts | 6 +- vitest.setup.ts | 4 +- 52 files changed, 409 insertions(+), 410 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 36eb852..7989c84 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,14 @@ { "extends": ["next/core-web-vitals", "next/typescript"], "rules": { - "@typescript-eslint/no-explicit-any": "warn" + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ] } } diff --git a/app/__tests__/page.test.tsx b/app/__tests__/page.test.tsx index 6cbdd79..f1fb866 100644 --- a/app/__tests__/page.test.tsx +++ b/app/__tests__/page.test.tsx @@ -43,8 +43,8 @@ vi.mock("@/lib/theme-context", () => ({ // Mock next/dynamic to render simple placeholders instead of lazy-loaded components vi.mock("next/dynamic", () => ({ __esModule: true, - default: (loader: () => Promise, opts?: any) => { - const MockComponent = (props: any) => ( + default: (_loader: () => Promise, _opts?: { ssr?: boolean }) => { + const MockComponent = (_props: Record) => (
    ); MockComponent.displayName = "DynamicMock"; @@ -53,7 +53,7 @@ vi.mock("next/dynamic", () => ({ })); vi.mock("@/components/SymbolHeader", () => ({ - SymbolHeader: ({ symbolData }: any) => ( + SymbolHeader: ({ symbolData }: { symbolData?: { name?: string } }) => (
    {symbolData?.name}
    ), })); @@ -109,7 +109,7 @@ describe("Home Page", () => { beforeEach(() => { vi.clearAllMocks(); searchParamsState.symbol = null; - (global.fetch as any).mockResolvedValue({ + vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => ({ success: true, data: {} }), }); @@ -151,7 +151,7 @@ describe("Home Page", () => { describe("Symbol selection", () => { it("shows loading state when a symbol is in the URL", async () => { searchParamsState.symbol = "AAPL"; - (global.fetch as any).mockImplementation(() => new Promise(() => {})); + vi.mocked(global.fetch).mockImplementation(() => new Promise(() => {})); render(); await waitFor(() => { expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); @@ -160,7 +160,7 @@ describe("Home Page", () => { it("shows error state when symbol fetch fails", async () => { searchParamsState.symbol = "AAPL"; - (global.fetch as any).mockResolvedValue({ + vi.mocked(global.fetch).mockResolvedValue({ ok: false, json: async () => ({ success: false }), }); @@ -185,7 +185,7 @@ describe("Home Page", () => { lastUpdated: new Date().toISOString(), }; - (global.fetch as any).mockResolvedValue({ + vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => ({ success: true, data: mockSymbolData }), }); @@ -201,7 +201,7 @@ describe("Home Page", () => { it("has a Clear Selection button on error that returns to dashboard", async () => { searchParamsState.symbol = "AAPL"; - (global.fetch as any).mockResolvedValue({ + vi.mocked(global.fetch).mockResolvedValue({ ok: false, json: async () => ({ success: false }), }); diff --git a/app/api/market/__tests__/symbol.test.ts b/app/api/market/__tests__/symbol.test.ts index ef39be3..65a7fe5 100644 --- a/app/api/market/__tests__/symbol.test.ts +++ b/app/api/market/__tests__/symbol.test.ts @@ -147,7 +147,7 @@ describe("Successful Data Retrieval", () => { describe("GET /api/market/symbol/[symbol]", () => { it("should return symbol data with success shape", async () => { - (marketDataService.getSymbolData as any).mockResolvedValue( + vi.mocked(marketDataService.getSymbolData).mockResolvedValue( mockSymbolData ); @@ -164,7 +164,7 @@ describe("Successful Data Retrieval", () => { }); it("should call marketDataService.getSymbolData with correct symbol", async () => { - (marketDataService.getSymbolData as any).mockResolvedValue( + vi.mocked(marketDataService.getSymbolData).mockResolvedValue( mockSymbolData ); @@ -178,7 +178,7 @@ describe("Successful Data Retrieval", () => { describe("GET /api/market/historical/[symbol]", () => { it("should return historical data for valid symbol", async () => { - (marketDataService.getHistoricalPrices as any).mockResolvedValue( + vi.mocked(marketDataService.getHistoricalPrices).mockResolvedValue( mockHistoricalData ); @@ -197,7 +197,7 @@ describe("Successful Data Retrieval", () => { }); it("should pass range parameter to service", async () => { - (marketDataService.getHistoricalPrices as any).mockResolvedValue( + vi.mocked(marketDataService.getHistoricalPrices).mockResolvedValue( mockHistoricalData ); @@ -215,7 +215,7 @@ describe("Successful Data Retrieval", () => { }); it("should default range to 1Y when not provided", async () => { - (marketDataService.getHistoricalPrices as any).mockResolvedValue( + vi.mocked(marketDataService.getHistoricalPrices).mockResolvedValue( mockHistoricalData ); @@ -235,7 +235,7 @@ describe("Successful Data Retrieval", () => { describe("GET /api/market/fear-greed", () => { it("should return fear and greed data", async () => { - (marketDataService.getFearGreedIndex as any).mockResolvedValue( + vi.mocked(marketDataService.getFearGreedIndex).mockResolvedValue( mockFearGreedData ); @@ -251,7 +251,7 @@ describe("Successful Data Retrieval", () => { }); it("should pass limit parameter", async () => { - (marketDataService.getFearGreedIndex as any).mockResolvedValue( + vi.mocked(marketDataService.getFearGreedIndex).mockResolvedValue( mockFearGreedData ); @@ -267,7 +267,7 @@ describe("Successful Data Retrieval", () => { describe("GET /api/market/world-markets", () => { it("should return world markets data", async () => { - (marketDataService.getWorldMarkets as any).mockResolvedValue( + vi.mocked(marketDataService.getWorldMarkets).mockResolvedValue( mockWorldMarketsData ); @@ -284,7 +284,7 @@ describe("Successful Data Retrieval", () => { describe("GET /api/market/sectors", () => { it("should return sector performance data", async () => { - (marketDataService.getSectorPerformance as any).mockResolvedValue( + vi.mocked(marketDataService.getSectorPerformance).mockResolvedValue( mockSectorData ); @@ -299,7 +299,7 @@ describe("Successful Data Retrieval", () => { }); it("should pass period parameter to service", async () => { - (marketDataService.getSectorPerformance as any).mockResolvedValue( + vi.mocked(marketDataService.getSectorPerformance).mockResolvedValue( mockSectorData ); @@ -317,7 +317,7 @@ describe("Successful Data Retrieval", () => { describe("GET /api/market/indicators/[symbol]", () => { it("should return technical indicators", async () => { - (marketDataService.getTechnicalIndicators as any).mockResolvedValue( + vi.mocked(marketDataService.getTechnicalIndicators).mockResolvedValue( mockIndicatorsData ); @@ -337,7 +337,7 @@ describe("Successful Data Retrieval", () => { describe("GET /api/market/forecast/[symbol]", () => { it("should return forecast data", async () => { - (marketDataService.getForecastData as any).mockResolvedValue( + vi.mocked(marketDataService.getForecastData).mockResolvedValue( mockForecastData ); @@ -354,7 +354,7 @@ describe("Successful Data Retrieval", () => { describe("GET /api/market/seasonal/[symbol]", () => { it("should return seasonal pattern data", async () => { - (marketDataService.getSeasonalPatterns as any).mockResolvedValue( + vi.mocked(marketDataService.getSeasonalPatterns).mockResolvedValue( mockSeasonalData ); @@ -371,7 +371,7 @@ describe("Successful Data Retrieval", () => { describe("GET /api/market/financials/[symbol]", () => { it("should return financial data", async () => { - (marketDataService.getFinancials as any).mockResolvedValue( + vi.mocked(marketDataService.getFinancials).mockResolvedValue( mockFinancialsData ); @@ -402,7 +402,9 @@ describe("Caching Behavior", () => { it("should serve cached data when service returns cached result", async () => { // The service layer handles caching internally. When called twice, // the route should return the same data shape both times. - (marketDataService.getSymbolData as any).mockResolvedValue(mockSymbolData); + vi.mocked(marketDataService.getSymbolData).mockResolvedValue( + mockSymbolData + ); const { GET } = await import("@/app/api/market/symbol/[symbol]/route"); const req1 = makeRequest("http://localhost:3000/api/market/symbol/AAPL"); @@ -420,7 +422,7 @@ describe("Caching Behavior", () => { }); it("should include timestamp in every response for cache freshness", async () => { - (marketDataService.getFearGreedIndex as any).mockResolvedValue( + vi.mocked(marketDataService.getFearGreedIndex).mockResolvedValue( mockFearGreedData ); @@ -435,7 +437,7 @@ describe("Caching Behavior", () => { }); it("should return consistent data shape across multiple calls", async () => { - (marketDataService.getWorldMarkets as any).mockResolvedValue( + vi.mocked(marketDataService.getWorldMarkets).mockResolvedValue( mockWorldMarketsData ); @@ -469,7 +471,7 @@ describe("Rate Limiting", () => { }); it("should propagate rate limit error from service as 502", async () => { - (marketDataService.getSymbolData as any).mockRejectedValue( + vi.mocked(marketDataService.getSymbolData).mockRejectedValue( new Error("Rate limit exceeded and no cached data available") ); @@ -484,7 +486,7 @@ describe("Rate Limiting", () => { }); it("should return 500 with rate limit error for fear-greed route", async () => { - (marketDataService.getFearGreedIndex as any).mockRejectedValue( + vi.mocked(marketDataService.getFearGreedIndex).mockRejectedValue( new Error("Rate limit exceeded and no cached data available") ); @@ -499,7 +501,7 @@ describe("Rate Limiting", () => { }); it("should return 500 with rate limit error for world-markets route", async () => { - (marketDataService.getWorldMarkets as any).mockRejectedValue( + vi.mocked(marketDataService.getWorldMarkets).mockRejectedValue( new Error("Rate limit exceeded and no cached data available") ); @@ -514,7 +516,7 @@ describe("Rate Limiting", () => { }); it("should return 500 with rate limit error for sectors route", async () => { - (marketDataService.getSectorPerformance as any).mockRejectedValue( + vi.mocked(marketDataService.getSectorPerformance).mockRejectedValue( new Error("Rate limit exceeded and no cached data available") ); @@ -529,7 +531,7 @@ describe("Rate Limiting", () => { }); it("should return 500 with rate limit error for indicators route", async () => { - (marketDataService.getTechnicalIndicators as any).mockRejectedValue( + vi.mocked(marketDataService.getTechnicalIndicators).mockRejectedValue( new Error("Rate limit exceeded and no cached data available") ); @@ -555,7 +557,7 @@ describe("Error Responses", () => { describe("User-friendly error messages (Req 3.5, 14.2)", () => { it("should return descriptive error for fear-greed API failure", async () => { - (marketDataService.getFearGreedIndex as any).mockRejectedValue( + vi.mocked(marketDataService.getFearGreedIndex).mockRejectedValue( new Error("Network timeout") ); @@ -571,7 +573,7 @@ describe("Error Responses", () => { }); it("should return descriptive error for world-markets API failure", async () => { - (marketDataService.getWorldMarkets as any).mockRejectedValue( + vi.mocked(marketDataService.getWorldMarkets).mockRejectedValue( new Error("Service unavailable") ); @@ -586,7 +588,7 @@ describe("Error Responses", () => { }); it("should return descriptive error for sectors API failure", async () => { - (marketDataService.getSectorPerformance as any).mockRejectedValue( + vi.mocked(marketDataService.getSectorPerformance).mockRejectedValue( new Error("External API error") ); @@ -601,7 +603,7 @@ describe("Error Responses", () => { }); it("should return descriptive error for indicators API failure", async () => { - (marketDataService.getTechnicalIndicators as any).mockRejectedValue( + vi.mocked(marketDataService.getTechnicalIndicators).mockRejectedValue( new Error("Failed to calculate indicators") ); @@ -619,7 +621,7 @@ describe("Error Responses", () => { }); it("should return descriptive error for forecast API failure", async () => { - (marketDataService.getForecastData as any).mockRejectedValue( + vi.mocked(marketDataService.getForecastData).mockRejectedValue( new Error("Forecast data unavailable") ); @@ -634,7 +636,7 @@ describe("Error Responses", () => { }); it("should return descriptive error for seasonal API failure", async () => { - (marketDataService.getSeasonalPatterns as any).mockRejectedValue( + vi.mocked(marketDataService.getSeasonalPatterns).mockRejectedValue( new Error("Seasonal data unavailable") ); @@ -649,7 +651,7 @@ describe("Error Responses", () => { }); it("should return descriptive error for financials API failure", async () => { - (marketDataService.getFinancials as any).mockRejectedValue( + vi.mocked(marketDataService.getFinancials).mockRejectedValue( new Error("Financial data unavailable") ); @@ -669,7 +671,7 @@ describe("Error Responses", () => { describe("Non-Error object handling", () => { it("should return fallback message for non-Error throws in fear-greed", async () => { - (marketDataService.getFearGreedIndex as any).mockRejectedValue( + vi.mocked(marketDataService.getFearGreedIndex).mockRejectedValue( "string error" ); @@ -684,7 +686,7 @@ describe("Error Responses", () => { }); it("should return fallback message for non-Error throws in world-markets", async () => { - (marketDataService.getWorldMarkets as any).mockRejectedValue(42); + vi.mocked(marketDataService.getWorldMarkets).mockRejectedValue(42); const { GET } = await import("@/app/api/market/world-markets/route"); const req = makeRequest("http://localhost:3000/api/market/world-markets"); @@ -697,7 +699,7 @@ describe("Error Responses", () => { }); it("should return fallback message for non-Error throws in sectors", async () => { - (marketDataService.getSectorPerformance as any).mockRejectedValue(null); + vi.mocked(marketDataService.getSectorPerformance).mockRejectedValue(null); const { GET } = await import("@/app/api/market/sectors/route"); const req = makeRequest("http://localhost:3000/api/market/sectors"); @@ -712,7 +714,7 @@ describe("Error Responses", () => { describe("Symbol route live data errors", () => { it("should return a 502 when symbol API fails", async () => { - (marketDataService.getSymbolData as any).mockRejectedValue( + vi.mocked(marketDataService.getSymbolData).mockRejectedValue( new Error("API unavailable") ); @@ -727,7 +729,7 @@ describe("Error Responses", () => { }); it("should return a 502 when historical API fails", async () => { - (marketDataService.getHistoricalPrices as any).mockRejectedValue( + vi.mocked(marketDataService.getHistoricalPrices).mockRejectedValue( new Error("API unavailable") ); @@ -747,7 +749,7 @@ describe("Error Responses", () => { describe("Response shape consistency", () => { it("should always include success, data, and timestamp on success", async () => { - (marketDataService.getSectorPerformance as any).mockResolvedValue( + vi.mocked(marketDataService.getSectorPerformance).mockResolvedValue( mockSectorData ); @@ -762,7 +764,7 @@ describe("Error Responses", () => { }); it("should always include success, error, and timestamp on failure", async () => { - (marketDataService.getFearGreedIndex as any).mockRejectedValue( + vi.mocked(marketDataService.getFearGreedIndex).mockRejectedValue( new Error("Failure") ); diff --git a/app/api/market/search/__tests__/route.test.ts b/app/api/market/search/__tests__/route.test.ts index 0f97da2..6d48fc4 100644 --- a/app/api/market/search/__tests__/route.test.ts +++ b/app/api/market/search/__tests__/route.test.ts @@ -75,7 +75,7 @@ describe("GET /api/market/search", () => { }, ]; - (marketDataService.searchSymbols as any).mockResolvedValue(mockResults); + vi.mocked(marketDataService.searchSymbols).mockResolvedValue(mockResults); const request = new NextRequest( "http://localhost:3000/api/market/search?q=AAPL" @@ -91,7 +91,7 @@ describe("GET /api/market/search", () => { }); it("should return 500 on service error", async () => { - (marketDataService.searchSymbols as any).mockRejectedValue( + vi.mocked(marketDataService.searchSymbols).mockRejectedValue( new Error("Service error") ); @@ -117,7 +117,7 @@ describe("GET /api/market/search", () => { }, ]; - (marketDataService.searchSymbols as any).mockResolvedValue(mockResults); + vi.mocked(marketDataService.searchSymbols).mockResolvedValue(mockResults); const request = new NextRequest( "http://localhost:3000/api/market/search?q=AAPL" diff --git a/app/api/market/world-markets/route.ts b/app/api/market/world-markets/route.ts index 24902b7..fe6ad91 100644 --- a/app/api/market/world-markets/route.ts +++ b/app/api/market/world-markets/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from "next/server"; import { marketDataService } from "@/services/market-data.service"; import { logger } from "@/lib/logger"; -export async function GET(request: NextRequest) { +export async function GET(_request: NextRequest) { try { const data = await marketDataService.getWorldMarkets(); diff --git a/app/api/screener/__tests__/route.test.ts b/app/api/screener/__tests__/route.test.ts index 4f3c79e..f246f31 100644 --- a/app/api/screener/__tests__/route.test.ts +++ b/app/api/screener/__tests__/route.test.ts @@ -104,7 +104,7 @@ describe("POST /api/screener/search", () => { }); it("should return results for valid filters", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue(mockResults); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue(mockResults); const request = new NextRequest( "http://localhost:3000/api/screener/search", @@ -133,8 +133,8 @@ describe("POST /api/screener/search", () => { }); it("should use preset filters when preset is specified", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue(mockResults); - (screenerService.getDefaultPresets as any).mockReturnValue(mockPresets); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue(mockResults); + vi.mocked(screenerService.getDefaultPresets).mockReturnValue(mockPresets); const request = new NextRequest( "http://localhost:3000/api/screener/search", @@ -155,8 +155,8 @@ describe("POST /api/screener/search", () => { }); it("should use provided filters when preset is not found", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue([]); - (screenerService.getDefaultPresets as any).mockReturnValue(mockPresets); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue([]); + vi.mocked(screenerService.getDefaultPresets).mockReturnValue(mockPresets); const filters = [ { field: "price", operator: "lt", value: 5, label: "Price < $5" }, @@ -176,7 +176,7 @@ describe("POST /api/screener/search", () => { }); it("should default to empty filters when none provided", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue(mockResults); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue(mockResults); const request = new NextRequest( "http://localhost:3000/api/screener/search", @@ -195,7 +195,7 @@ describe("POST /api/screener/search", () => { }); it("should pass multiple filters to service for AND logic (Req 26.8)", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue([ + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue([ mockResults[0], ]); @@ -231,8 +231,8 @@ describe("POST /api/screener/search", () => { }); it("should prefer preset filters over provided filters", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue(mockResults); - (screenerService.getDefaultPresets as any).mockReturnValue(mockPresets); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue(mockResults); + vi.mocked(screenerService.getDefaultPresets).mockReturnValue(mockPresets); const request = new NextRequest( "http://localhost:3000/api/screener/search", @@ -255,7 +255,7 @@ describe("POST /api/screener/search", () => { }); it("should return 500 on service error", async () => { - (screenerService.fetchScreenerData as any).mockRejectedValue( + vi.mocked(screenerService.fetchScreenerData).mockRejectedValue( new Error("Service unavailable") ); @@ -276,7 +276,7 @@ describe("POST /api/screener/search", () => { }); it("should return 500 with generic message for non-Error throws", async () => { - (screenerService.fetchScreenerData as any).mockRejectedValue("unknown"); + vi.mocked(screenerService.fetchScreenerData).mockRejectedValue("unknown"); const request = new NextRequest( "http://localhost:3000/api/screener/search", @@ -304,7 +304,7 @@ describe("GET /api/screener/presets", () => { }); it("should return default presets", async () => { - (screenerService.getDefaultPresets as any).mockReturnValue(mockPresets); + vi.mocked(screenerService.getDefaultPresets).mockReturnValue(mockPresets); const response = await GET(); const data = await response.json(); @@ -316,7 +316,7 @@ describe("GET /api/screener/presets", () => { }); it("should return presets with required structure (Req 26.12)", async () => { - (screenerService.getDefaultPresets as any).mockReturnValue(mockPresets); + vi.mocked(screenerService.getDefaultPresets).mockReturnValue(mockPresets); const response = await GET(); const data = await response.json(); @@ -333,7 +333,7 @@ describe("GET /api/screener/presets", () => { }); it("should return empty array when no presets exist", async () => { - (screenerService.getDefaultPresets as any).mockReturnValue([]); + vi.mocked(screenerService.getDefaultPresets).mockReturnValue([]); const response = await GET(); const data = await response.json(); @@ -344,7 +344,7 @@ describe("GET /api/screener/presets", () => { }); it("should return 500 on service error", async () => { - (screenerService.getDefaultPresets as any).mockImplementation(() => { + vi.mocked(screenerService.getDefaultPresets).mockImplementation(() => { throw new Error("Preset error"); }); @@ -533,7 +533,7 @@ describe("GET /api/screener/export", () => { }); it("should return CSV with correct headers", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue(mockResults); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue(mockResults); const request = new NextRequest( "http://localhost:3000/api/screener/export" @@ -556,7 +556,7 @@ describe("GET /api/screener/export", () => { }); it("should include result data in CSV rows", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue(mockResults); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue(mockResults); const request = new NextRequest( "http://localhost:3000/api/screener/export" @@ -572,7 +572,7 @@ describe("GET /api/screener/export", () => { }); it("should apply filters from query params", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue([]); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue([]); const filters = JSON.stringify([ { field: "price", operator: "gt", value: 100, label: "Price > $100" }, @@ -603,7 +603,7 @@ describe("GET /api/screener/export", () => { }); it("should return 500 on service error", async () => { - (screenerService.fetchScreenerData as any).mockRejectedValue( + vi.mocked(screenerService.fetchScreenerData).mockRejectedValue( new Error("Export failed") ); @@ -620,7 +620,7 @@ describe("GET /api/screener/export", () => { }); it("should return header-only CSV when no results match", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue([]); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue([]); const request = new NextRequest( "http://localhost:3000/api/screener/export" @@ -649,7 +649,7 @@ describe("GET /api/screener/export", () => { matchScore: 0, }, ]; - (screenerService.fetchScreenerData as any).mockResolvedValue( + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue( resultWithoutOptionals ); @@ -685,7 +685,7 @@ describe("GET /api/screener/export", () => { matchScore: 0, }, ]; - (screenerService.fetchScreenerData as any).mockResolvedValue( + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue( resultWithQuotes ); @@ -700,7 +700,7 @@ describe("GET /api/screener/export", () => { }); it("should export with no filters when param is absent", async () => { - (screenerService.fetchScreenerData as any).mockResolvedValue(mockResults); + vi.mocked(screenerService.fetchScreenerData).mockResolvedValue(mockResults); const request = new NextRequest( "http://localhost:3000/api/screener/export" diff --git a/app/auth/callback/google/page.tsx b/app/auth/callback/google/page.tsx index 4d5752e..6410e4a 100644 --- a/app/auth/callback/google/page.tsx +++ b/app/auth/callback/google/page.tsx @@ -82,7 +82,7 @@ export default function GoogleOAuthCallbackPage() { return (
    -

    {message}

    +

    {message}

    ); } diff --git a/app/layout.tsx b/app/layout.tsx index 5407e91..4419f28 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import Script from "next/script"; import { Ibarra_Real_Nova, Merriweather } from "next/font/google"; import { JsonLd } from "@/components/JsonLd"; import { buildRootMetadata } from "@/lib/site-seo"; @@ -31,18 +32,6 @@ export default function RootLayout({ return ( - {gtmId ? ( - + ) : null} {gtmId ? (