Compare commits

..

No commits in common. "b3b798ff2f86ab46e1f5fd28f8dc750c6b398abd" and "d10bb3dd7879c9cb232788f21af753b294fa91f7" have entirely different histories.

17 changed files with 91 additions and 189 deletions

View File

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

21
src/api/strategy.js Normal file
View File

@ -0,0 +1,21 @@
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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -52,10 +52,8 @@ type PositionsResponse = {
}; };
type MarketStatusResponse = { type MarketStatusResponse = {
status?: "OPEN" | "CLOSED" | "HOLIDAY"; status?: "OPEN" | "CLOSED";
reason?: string;
checked_at?: string; checked_at?: string;
next_open_at?: string | null;
}; };
type FundsResponse = { type FundsResponse = {
@ -197,16 +195,6 @@ function getNetQuantity(item: any) {
return getEffectiveQuantity(item); return getEffectiveQuantity(item);
} }
function formatFrequencyLabel(value?: number | null, unit?: string | null) {
const safeValue = Number(value);
if (!Number.isFinite(safeValue) || safeValue <= 0 || !unit) {
return null;
}
const normalizedUnit = unit === "minutes" ? "minute" : unit === "days" ? "day" : unit;
const pluralizedUnit = safeValue === 1 ? normalizedUnit : `${normalizedUnit}s`;
return `${safeValue} ${pluralizedUnit}`;
}
function getAveragePrice(item: any) { function getAveragePrice(item: any) {
return firstNumber(item?.average_price, item?.avg_price); return firstNumber(item?.average_price, item?.avg_price);
} }
@ -448,6 +436,16 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected, enabled: !!brokerStatus?.connected,
retry: 1, retry: 1,
retryDelay: 600, retryDelay: 600,
onSuccess: (data) => {
setCachedHoldings(data?.holdings || []);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
}); });
const fundsQuery = useQuery<FundsResponse>({ const fundsQuery = useQuery<FundsResponse>({
@ -459,6 +457,16 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected, enabled: !!brokerStatus?.connected,
retry: 1, retry: 1,
retryDelay: 600, retryDelay: 600,
onSuccess: (data) => {
setCachedFunds(data?.funds ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
}); });
const [startDate, setStartDate] = useState(() => const [startDate, setStartDate] = useState(() =>
@ -599,6 +607,16 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected, enabled: !!brokerStatus?.connected,
retry: 1, retry: 1,
retryDelay: 600, retryDelay: 600,
onSuccess: (data) => {
setCachedEquityCurve(data ?? null);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
}); });
const positionsQuery = useQuery<PositionsResponse>({ const positionsQuery = useQuery<PositionsResponse>({
@ -610,6 +628,16 @@ export default function PortfolioSection() {
enabled: !!brokerStatus?.connected, enabled: !!brokerStatus?.connected,
retry: 1, retry: 1,
retryDelay: 600, retryDelay: 600,
onSuccess: (data) => {
setCachedPositions(data?.positions || []);
setSessionExpired(false);
setReconnectAttempted(false);
},
onError: () => {
if (brokerStatus?.connected) {
setSessionExpired(true);
}
},
}); });
const refreshBrokerData = useCallback( const refreshBrokerData = useCallback(
@ -617,7 +645,7 @@ export default function PortfolioSection() {
if (!brokerStatus?.connected) { if (!brokerStatus?.connected) {
return; return;
} }
const tasks: Promise<unknown>[] = [ const tasks = [
holdingsQuery.refetch(), holdingsQuery.refetch(),
positionsQuery.refetch(), positionsQuery.refetch(),
fundsQuery.refetch(), fundsQuery.refetch(),
@ -640,58 +668,11 @@ export default function PortfolioSection() {
const isConnected = !!brokerStatus?.connected; const isConnected = !!brokerStatus?.connected;
const isAuthed = brokerStatus !== null; const isAuthed = brokerStatus !== null;
const holdings = holdingsQuery.data?.holdings ?? cachedHoldings; const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
const positions = positionsQuery.data?.positions ?? cachedPositions; const positions = positionsQuery.data ? positionsQuery.data.positions : cachedPositions;
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds; const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
const noHoldings = holdings.length === 0; const noHoldings = holdings.length === 0;
const noPositions = positions.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(() => { useEffect(() => {
if (!isConnected) { if (!isConnected) {
setSessionExpired(false); setSessionExpired(false);
@ -781,38 +762,6 @@ export default function PortfolioSection() {
: strategyStatus === "WAITING" : strategyStatus === "WAITING"
? "WAITING" ? "WAITING"
: "STOPPED"; : "STOPPED";
const savedStrategyAmount = Number(strategyDetails?.config?.sip_amount);
const savedStrategyFrequencyValue = Number(strategyDetails?.config?.sip_frequency?.value);
const savedStrategyFrequencyUnit =
strategyDetails?.config?.sip_frequency?.unit === "minutes"
? "minutes"
: strategyDetails?.config?.sip_frequency?.unit === "days"
? "days"
: null;
const savedStrategyCadence = formatFrequencyLabel(
savedStrategyFrequencyValue,
savedStrategyFrequencyUnit,
);
const savedStrategyAmountLabel =
Number.isFinite(savedStrategyAmount) && savedStrategyAmount > 0
? formatCurrency(savedStrategyAmount)
: null;
const savedStrategyMode =
strategyDetails?.config?.mode === "LIVE"
? "Live"
: strategyDetails?.config?.mode === "PAPER"
? "Paper"
: null;
const savedStrategyBroker =
strategyDetails?.config?.broker ? formatBrokerName(strategyDetails.config.broker) : null;
const savedStrategySummary = [
savedStrategyAmountLabel ? `${savedStrategyAmountLabel} SIP` : null,
savedStrategyCadence ? `every ${savedStrategyCadence}` : null,
savedStrategyMode,
savedStrategyBroker,
]
.filter(Boolean)
.join(" · ");
const isStrategyActive = const isStrategyActive =
normalizedStrategyStatus === "RUNNING" || normalizedStrategyStatus === "RUNNING" ||
normalizedStrategyStatus === "WAITING"; normalizedStrategyStatus === "WAITING";
@ -1534,12 +1483,6 @@ export default function PortfolioSection() {
Minutes mode is for live testing only. The engine checks every few seconds in this mode. Minutes mode is for live testing only. The engine checks every few seconds in this mode.
</div> </div>
) : null} ) : null}
{savedStrategySummary ? (
<div className="rounded-lg border border-border/60 bg-background/40 px-3 py-2 text-xs text-muted-foreground">
{isStrategyActive ? "Current run" : showResumeStrategy ? "Resume will continue" : "Saved strategy config"}
: <span className="font-medium text-foreground"> {savedStrategySummary}</span>
</div>
) : null}
{showResumeStrategy ? ( {showResumeStrategy ? (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Resume uses the previously saved SIP configuration. Choose restart to begin a fresh cycle. Resume uses the previously saved SIP configuration. Choose restart to begin a fresh cycle.

View File

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

View File

@ -43,6 +43,7 @@ const trustTimeline = [
export default function About() { export default function About() {
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const heroMotion = prefersReducedMotion ? {} : pageEnterMotion;
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
@ -51,9 +52,9 @@ export default function About() {
<main className="pt-24 pb-16"> <main className="pt-24 pb-16">
<motion.section <motion.section
className="relative overflow-hidden" className="relative overflow-hidden"
{...(prefersReducedMotion initial={heroMotion.initial}
? {} animate={heroMotion.animate}
: { initial: pageEnterMotion.initial, animate: pageEnterMotion.animate })} transition={heroMotion.transition}
> >
<div className="absolute inset-0 pointer-events-none"> <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]" /> <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,6 +35,7 @@ const posts: BlogPost[] = [
export default function Blog() { export default function Blog() {
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const heroMotion = prefersReducedMotion ? {} : pageEnterMotion;
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@ -49,9 +50,9 @@ export default function Blog() {
<main className="pt-24 pb-16"> <main className="pt-24 pb-16">
<motion.section <motion.section
className="relative overflow-hidden px-6 py-16" className="relative overflow-hidden px-6 py-16"
{...(prefersReducedMotion initial={heroMotion.initial}
? {} animate={heroMotion.animate}
: { initial: pageEnterMotion.initial, animate: pageEnterMotion.animate })} transition={heroMotion.transition}
> >
<div className="absolute inset-0 pointer-events-none"> <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" /> <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,6 +51,7 @@ export default function Disclosures() {
className="relative overflow-hidden px-6 py-16" className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial} initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate} animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
> >
<div className="absolute inset-0 pointer-events-none"> <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]" /> <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,6 +59,7 @@ export default function LearnMore() {
className="relative overflow-hidden px-6 py-16" className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial} initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate} animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
> >
<div className="absolute inset-0 pointer-events-none"> <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]" /> <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,10 +88,8 @@ type PaperMtmResponse = {
}; };
type MarketStatusResponse = { type MarketStatusResponse = {
status?: "OPEN" | "CLOSED" | "HOLIDAY"; status?: "OPEN" | "CLOSED";
reason?: string;
checked_at?: string; checked_at?: string;
next_open_at?: string | null;
}; };
type EngineStatus = { type EngineStatus = {
@ -168,7 +166,7 @@ function formatSignedCurrency(value: number, decimals = 2) {
} }
function formatTimestamp(value?: string) { function formatTimestamp(value?: string) {
if (!value) return "-"; if (!value) return "";
const parsed = new Date(value); const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) { if (Number.isNaN(parsed.getTime())) {
return value; return value;

View File

@ -63,6 +63,7 @@ export default function Privacy() {
className="relative overflow-hidden px-6 py-16" className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial} initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate} animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
> >
<div className="absolute inset-0 pointer-events-none"> <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]" /> <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,6 +51,7 @@ export default function Terms() {
className="relative overflow-hidden px-6 py-16" className="relative overflow-hidden px-6 py-16"
initial={prefersReducedMotion ? undefined : pageEnterMotion.initial} initial={prefersReducedMotion ? undefined : pageEnterMotion.initial}
animate={prefersReducedMotion ? undefined : pageEnterMotion.animate} animate={prefersReducedMotion ? undefined : pageEnterMotion.animate}
transition={prefersReducedMotion ? undefined : pageEnterMotion.transition}
> >
<div className="absolute inset-0 pointer-events-none"> <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]" /> <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]" />