'use client'; import { useRef, useState, type MouseEvent } from 'react'; import type { AccountState, FirmAccountType, FirmConfig, KeptRulesWindow } from '@/types'; import { evidenceDay, pickedDayRulesDate, pickKeptRules, unchangedDaySaves } from '@/lib/kept-rules'; /** * An account's rules date, shown next to its name, and the "old rules" chip. * * When a firm's rules change on the master with "New accounts only", accounts * dated before the change keep the old rules. The date defaults to when the * account started (or when this machine first saw it); clicking it edits it * inline — a past day is stored as its start, today as now (the master's * "today means from now") — with a preview of which rules that lands on. * Used on the dashboard row and the account page. */ /** What the master's Firms page calls each rule field (its lib/kept-rules.ts * ruleLabel), capitalized — so an "old rules" difference reads the same on * both. */ const RULE_LABELS: Record = { profitTarget: 'Profit target', consistency: 'Consistency', minDayPnL: 'Min day P&L', minTradingDays: 'Min trading days', maxLoss: 'Max drawdown', maxPositionSize: 'Max contracts', targetSameEquity: 'Same equity after first w/d', splitEnabled: 'Split legs', minLegs: 'Min legs', firstDayStop: 'Day 1 $1k stop', minDayStop: 'Min-day 2× stop', combineCmeHolidays: 'CME holiday combining', withdrawalStages: 'Withdrawal stages', payoutable: 'Payoutable', trailingDrawdown: 'Trailing drawdown', dailyLossLimit: 'Hard daily loss', }; function money(n: number): string { return `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`; } /** One rule value the way the Firms page shows it: money as $3,000, ratios as * 50%, switches on/off, stages by count. */ function formatRule(field: string, value: unknown): string { if (value === undefined || value === null) return 'unset'; if (typeof value === 'boolean') return value ? 'on' : 'off'; if (Array.isArray(value)) { return value.length === 0 ? 'none' : `${value.length} stage${value.length === 1 ? '' : 's'}`; } if (typeof value !== 'number') return JSON.stringify(value); switch (field) { case 'consistency': return value === 0 ? 'none' : `${Math.round(value * 100)}%`; case 'minDayPnL': return value <= -999 ? 'none' : money(value); case 'maxPositionSize': return value === 0 ? 'no limit' : String(value); case 'minLegs': return value === 0 ? 'auto' : String(value); case 'trailingDrawdown': return value === 0 ? 'unset' : money(value); case 'maxLoss': case 'dailyLossLimit': return value === 0 ? 'none' : money(Math.abs(value)); case 'profitTarget': return money(value); default: return String(value); } } /** "Consistency 50% (type now 40%)". Two edits that summarize alike — three * stages either way — just say the field changed. */ function describeDiff(d: { field: string; kept: unknown; now: unknown }): string { const label = RULE_LABELS[d.field] ?? d.field; const kept = formatRule(d.field, d.kept); const now = formatRule(d.field, d.now); return kept === now ? `${label} changed since` : `${label} ${kept} (type now ${now})`; } /** "Oct 18, 2026, 2:05 PM". */ function fullWhen(iso: string): string { return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', }); } /** "Oct 12", with the year when it isn't this year's; `time` adds "2:05 PM". */ function shortWhen(iso: string, time = false): string { const d = new Date(iso); const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }; if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; if (time) { opts.hour = 'numeric'; opts.minute = '2-digit'; } return d.toLocaleString('en-US', opts); } /** Which rules dates a kept set covers, for the chip's tooltip. */ function windowText(k: KeptRulesWindow): string { return k.openedAt ? `kept for accounts dated from ${fullWhen(k.openedAt)} to before ${fullWhen(k.closedAt)}` : `kept for accounts dated before ${fullWhen(k.closedAt)}`; } /** The same, short, for the editor's preview line. */ function windowShort(k: KeptRulesWindow): string { return k.openedAt ? `${shortWhen(k.openedAt, true)} – ${shortWhen(k.closedAt, true)}` : `before ${shortWhen(k.closedAt, true)}`; } /** "Oct 12" for a calendar day (YYYY-MM-DD) itself, whatever the browser's * zone, with the year when it isn't this year's; `year` always adds it. */ function dayLabel(day: string, year = false): string { const d = new Date(`${day}T12:00:00.000Z`); const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', timeZone: 'UTC' }; if (year || d.getUTCFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; return d.toLocaleString('en-US', opts); } /** YYYY-MM-DD of a moment in the browser's time zone — a date input's value. */ function localDay(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } /** A date input's value as that day's local midnight; null when unparsable. */ function localMidnight(day: string): Date | null { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); if (!m) return null; const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); return Number.isNaN(d.getTime()) ? null : d; } /** The rules date a picked day stores (lib/kept-rules.ts pickedDayRulesDate): * today as now, any other day as its start. null when unparsable. */ function storedFor(day: string): string | null { return pickedDayRulesDate(day, { today: localDay(new Date()), nowMs: Date.now(), localMidnightMs: localMidnight(day)?.getTime() ?? NaN, }); } /** The amber chip on an account trading on one of its type's old rule sets. */ export function OldRulesChip({ keptRules }: { keptRules: NonNullable }) { const lines = keptRules.differs.length > 0 ? keptRules.differs.map(describeDiff) : ['Same as the type\'s current rules']; return ( old rules ); } /** The rules date and, when the account is on an old rule set, its chip. * `firm` names the firm whose type's kept windows the editor's preview * reads (none without it) — fetched fresh each time the editor opens, as a * page's firm listing can predate a rule change on the master; `onSaved` * should refresh /api/state. */ export function RulesDate({ account, firm, onSaved }: { account: AccountState; firm: FirmConfig | null; onSaved: () => void; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(''); // The date and day the editor opened on: the 5s state poll can move the // date underneath (evidence lowering it), and an untouched ✓ must still // mean "leave it", not "store the day it showed when opened". const [opened, setOpened] = useState<{ date: string; day: string } | null>(null); // The type's kept windows as the server has them now; null while loading // or when unknown. const [kept, setKept] = useState(null); const openCount = useRef(0); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); // No assignment row yet (the resolver hasn't seen the account): nothing to // show or set. if (account.rulesDate === null) return null; const rulesDate = account.rulesDate; // Broker evidence is the end of a Chicago day: shown as that day in any // zone, not as the next one east of Chicago. const brokerDay = evidenceDay(rulesDate); const currentDay = brokerDay ?? localDay(new Date(rulesDate)); // What ✓ would store, and the rules it lands on. const chosen = editing ? storedFor(draft) : null; const landsOn = kept && chosen ? pickKeptRules(kept, chosen) : null; const preview = !kept || !chosen ? null : landsOn ? `→ old rules (${windowShort(landsOn)})` : '→ current rules'; function open(e: MouseEvent) { e.stopPropagation(); setDraft(currentDay); setOpened({ date: rulesDate, day: currentDay }); setError(''); setKept(null); setEditing(true); const ticket = ++openCount.current; const fallback = firm?.accounts.find((t) => t.id === account.accountTypeId)?.kept ?? null; if (!firm || !account.accountTypeId) return; void (async () => { let windows: KeptRulesWindow[] | null = fallback; try { const res = await fetch(`/api/firms/${encodeURIComponent(firm.id)}`); if (res.ok) { const data = await res.json() as { accounts?: FirmAccountType[] }; windows = data.accounts?.find((t) => t.id === account.accountTypeId)?.kept ?? null; } } catch { // Offline: the page's copy is the best there is. } if (ticket === openCount.current) setKept(windows); })(); } async function save() { const value = storedFor(draft); if (!value || !opened) { setError('Pick a date'); return; } // An unchanged day keeps the exact date, unless storing it moves the // account to other rules — what the preview then says ✓ does. if (draft === opened.day && !unchangedDaySaves(kept, opened.date, value)) { setEditing(false); return; } setSaving(true); setError(''); try { const res = await fetch(`/api/accounts/${account.id}/rules-date`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rulesDate: value }), }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed'); setEditing(false); onSaved(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed'); } finally { setSaving(false); } } if (editing) { return ( e.stopPropagation()}> setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); }} className="border border-slate-200 rounded-md px-1 py-0.5 text-[11px] bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400" /> {preview && {preview}} {error && {error}} ); } return ( {account.keptRules && } ); }