Align frontend contracts, type safety, and portfolio status handling

This commit is contained in:
Thigazhezhilan J 2026-04-08 22:03:50 +05:30
parent 8c160d6443
commit b3b798ff2f
17 changed files with 141 additions and 91 deletions

View File

@ -6,9 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "npm run typecheck",
"preview": "vite preview",
"start": "vite --host 0.0.0.0 --port 3001"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",

View File

@ -1,21 +0,0 @@
import { apiRequest } from "@/lib/queryClient";
export async function startStrategy(data) {
const res = await apiRequest("POST", "/strategy/start", data);
return res.json();
}
export async function stopStrategy() {
const res = await apiRequest("POST", "/strategy/stop");
return res.json();
}
export async function resumeStrategy() {
const res = await apiRequest("POST", "/strategy/resume");
return res.json();
}
export async function getStrategyStatus() {
const res = await apiRequest("GET", "/strategy/status");
return res.json();
}

64
src/api/strategy.ts Normal file
View File

@ -0,0 +1,64 @@
import { apiRequest } from "@/lib/queryClient";
export type StrategyFrequencyUnit = "days" | "minutes";
export type StrategyStartRequest = {
strategy_name: string;
sip_amount: number;
sip_frequency: {
value: number;
unit: StrategyFrequencyUnit;
};
mode: "LIVE" | "PAPER";
initial_cash?: number;
};
export type StrategyActionResponse = {
status: string;
run_id?: string | null;
message?: string;
redirect_url?: string | null;
broker?: string | null;
warning?: string;
};
export type StrategyStatusResponse = {
status?: string;
last_updated?: string | null;
last_execution_ts?: string | null;
next_eligible_ts?: string | null;
run_id?: string | null;
can_resume?: boolean;
can_restart?: boolean;
config?: {
strategy?: string | null;
sip_amount?: number | null;
sip_frequency?: {
value?: number | null;
unit?: StrategyFrequencyUnit | null;
} | null;
mode?: string | null;
broker?: string | null;
active?: boolean | null;
} | null;
};
export async function startStrategy(data: StrategyStartRequest): Promise<StrategyActionResponse> {
const res = await apiRequest("POST", "/strategy/start", data);
return res.json();
}
export async function stopStrategy(): Promise<StrategyActionResponse> {
const res = await apiRequest("POST", "/strategy/stop");
return res.json();
}
export async function resumeStrategy(): Promise<StrategyActionResponse> {
const res = await apiRequest("POST", "/strategy/resume");
return res.json();
}
export async function getStrategyStatus(): Promise<StrategyStatusResponse> {
const res = await apiRequest("GET", "/strategy/status");
return res.json();
}

View File

@ -33,9 +33,7 @@ type StrategyTimelineProps = {
function parseTimestamp(value?: string | null) {
if (!value) return null;
const hasTimezone = /Z|[+-]\d{2}:?\d{2}$/.test(value);
const normalized = hasTimezone ? value : `${value}Z`;
const parsed = new Date(normalized);
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
@ -199,7 +197,7 @@ function VerboseStrategyTimeline() {
prev.map((entry) => entry.seq).filter((seq) => typeof seq === "number"),
);
const next = normalized.filter(
(entry) => typeof entry.seq !== "number" || !seenSeq.has(entry.seq),
(entry: StrategyEvent) => typeof entry.seq !== "number" || !seenSeq.has(entry.seq),
);
return [...prev, ...next];
});
@ -208,7 +206,7 @@ function VerboseStrategyTimeline() {
latestSeqRef.current = data.latest_seq;
} else {
const lastSeq = normalized.reduce(
(max, entry) => Math.max(max, entry.seq ?? 0),
(max: number, entry: StrategyEvent) => Math.max(max, entry.seq ?? 0),
latestSeqRef.current,
);
latestSeqRef.current = lastSeq;
@ -306,7 +304,7 @@ function VerboseStrategyTimeline() {
<div className="mt-1 text-xs text-green-300/80">Reason: {reason}</div>
) : null}
<div className="mt-3 space-y-3">
{run.events.map((entry, index) => {
{run.events.map((entry: StrategyEvent, index: number) => {
const badge = getBadge(entry);
const timestamp = entry.ts ?? entry.timestamp;
const message = entry.message ?? entry.event ?? "Unknown event";

View File

@ -1,5 +1,5 @@
import FinalCTA from "../landing/FinalCTA";
export default function FinalCTAExample() {
return <FinalCTA />;
return <FinalCTA onExploreStrategies={() => {}} />;
}

View File

@ -1,5 +1,5 @@
import HeroSection from "../landing/HeroSection";
export default function HeroSectionExample() {
return <HeroSection />;
return <HeroSection onExploreStrategies={() => {}} />;
}

View File

@ -252,7 +252,7 @@ export default function AuthDialogs({ layout = "desktop" }: AuthDialogsProps) {
</Button>
<Button
type="button"
variant="link"
variant="ghost"
className="w-full text-sm"
onClick={() => {
setResetEmail(loginForm.email.trim());

View File

@ -1,7 +1,7 @@
import { GOLDEN_NIFTY_PATH } from "@/lib/routes";
export default function Footer() {
const links = {
const links: Record<string, { label: string; href: string; newTab?: boolean }[]> = {
Product: [
{ label: "Strategies", href: GOLDEN_NIFTY_PATH, newTab: true },
{ label: "Pricing", href: "#" },

View File

@ -52,8 +52,10 @@ type PositionsResponse = {
};
type MarketStatusResponse = {
status?: "OPEN" | "CLOSED";
status?: "OPEN" | "CLOSED" | "HOLIDAY";
reason?: string;
checked_at?: string;
next_open_at?: string | null;
};
type FundsResponse = {
@ -446,16 +448,6 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedHoldings(data?.holdings || []);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const fundsQuery = useQuery<FundsResponse>({
@ -467,16 +459,6 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedFunds(data?.funds ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const [startDate, setStartDate] = useState(() =>
@ -617,16 +599,6 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedEquityCurve(data ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const positionsQuery = useQuery<PositionsResponse>({
@ -638,16 +610,6 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected,
retry: 1,
retryDelay: 600,
onSuccess: (data) => {
setCachedPositions(data?.positions || []);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
});
const refreshBrokerData = useCallback(
@ -655,7 +617,7 @@ export default function PortfolioSection() {
if (!brokerStatus?.connected) {
return;
}
const tasks = [
const tasks: Promise<unknown>[] = [
holdingsQuery.refetch(),
positionsQuery.refetch(),
fundsQuery.refetch(),
@ -678,11 +640,58 @@ export default function PortfolioSection() {
const isConnected = !!brokerStatus?.connected;
const isAuthed = brokerStatus !== null;
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
const positions = positionsQuery.data ? positionsQuery.data.positions : cachedPositions;
const holdings = holdingsQuery.data?.holdings ?? cachedHoldings;
const positions = positionsQuery.data?.positions ?? cachedPositions;
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
const noHoldings = holdings.length === 0;
const noPositions = positions.length === 0;
useEffect(() => {
if (holdingsQuery.data) {
setCachedHoldings(holdingsQuery.data.holdings ?? []);
setSessionExpired(false);
setReconnectAttempted(false);
}
}, [holdingsQuery.data]);
useEffect(() => {
if (fundsQuery.data) {
setCachedFunds(fundsQuery.data.funds ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
}
}, [fundsQuery.data]);
useEffect(() => {
if (equityCurveQuery.data) {
setCachedEquityCurve(equityCurveQuery.data);
setSessionExpired(false);
setReconnectAttempted(false);
}
}, [equityCurveQuery.data]);
useEffect(() => {
if (positionsQuery.data) {
setCachedPositions(positionsQuery.data.positions ?? []);
setSessionExpired(false);
setReconnectAttempted(false);
}
}, [positionsQuery.data]);
useEffect(() => {
if (
brokerStatus?.connected &&
(holdingsQuery.isError || fundsQuery.isError || equityCurveQuery.isError || positionsQuery.isError)
) {
setSessionExpired(true);
}
}, [
brokerStatus?.connected,
holdingsQuery.isError,
fundsQuery.isError,
equityCurveQuery.isError,
positionsQuery.isError,
]);
useEffect(() => {
if (!isConnected) {
setSessionExpired(false);

View File

@ -43,8 +43,11 @@ async function getResponseErrorMessage(res: Response) {
if (contentType.includes("application/json")) {
try {
const data = await res.json();
const structuredDetail = typeof data?.detail === "object" && data?.detail !== null ? data.detail : null;
const detail =
(typeof data?.detail === "string" && data.detail) ||
(typeof structuredDetail?.message === "string" && structuredDetail.message) ||
(typeof structuredDetail?.detail === "string" && structuredDetail.detail) ||
(typeof data?.message === "string" && data.message) ||
(typeof data?.error === "string" && data.error);
if (detail) {
@ -161,7 +164,7 @@ export const queryClient = new QueryClient({
queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
staleTime: 5_000,
retry: false,
},
mutations: {

View File

@ -43,7 +43,6 @@ const trustTimeline = [
export default function About() {
const prefersReducedMotion = useReducedMotion();
const heroMotion = prefersReducedMotion ? {} : pageEnterMotion;
return (
<div className="min-h-screen bg-background text-foreground">
@ -52,9 +51,9 @@ export default function About() {
<main className="pt-24 pb-16">
<motion.section
className="relative overflow-hidden"
initial={heroMotion.initial}
animate={heroMotion.animate}
transition={heroMotion.transition}
{...(prefersReducedMotion
? {}
: { initial: pageEnterMotion.initial, animate: pageEnterMotion.animate })}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-24 top-0 h-80 w-80 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent blur-[120px]" />

View File

@ -35,7 +35,6 @@ const posts: BlogPost[] = [
export default function Blog() {
const prefersReducedMotion = useReducedMotion();
const heroMotion = prefersReducedMotion ? {} : pageEnterMotion;
const [loading, setLoading] = useState(true);
useEffect(() => {
@ -50,9 +49,9 @@ export default function Blog() {
<main className="pt-24 pb-16">
<motion.section
className="relative overflow-hidden px-6 py-16"
initial={heroMotion.initial}
animate={heroMotion.animate}
transition={heroMotion.transition}
{...(prefersReducedMotion
? {}
: { initial: pageEnterMotion.initial, animate: pageEnterMotion.animate })}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,rgba(59,130,246,0.12),transparent_0)] [background-size:120px_120px] opacity-40" />

View File

@ -51,7 +51,6 @@ export default function Disclosures() {
className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-24 top-0 h-80 w-80 bg-gradient-to-br from-primary/18 via-primary/5 to-transparent blur-[120px]" />

View File

@ -59,7 +59,6 @@ export default function LearnMore() {
className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-16 top-0 h-80 w-80 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent blur-[120px]" />

View File

@ -88,8 +88,10 @@ type PaperMtmResponse = {
};
type MarketStatusResponse = {
status?: "OPEN" | "CLOSED";
status?: "OPEN" | "CLOSED" | "HOLIDAY";
reason?: string;
checked_at?: string;
next_open_at?: string | null;
};
type EngineStatus = {
@ -166,7 +168,7 @@ function formatSignedCurrency(value: number, decimals = 2) {
}
function formatTimestamp(value?: string) {
if (!value) return "";
if (!value) return "-";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;

View File

@ -63,7 +63,6 @@ export default function Privacy() {
className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-24 top-0 h-80 w-80 bg-gradient-to-br from-primary/18 via-primary/5 to-transparent blur-[120px]" />

View File

@ -51,7 +51,6 @@ export default function Terms() {
className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -left-24 top-0 h-80 w-80 bg-gradient-to-br from-primary/18 via-primary/5 to-transparent blur-[120px]" />